Compare commits
126
Commits
fb6bde279e
..
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12011d330 | ||
|
|
7143c3d2eb | ||
|
|
71b2d97fae | ||
|
|
9b4e3e4b45 | ||
|
|
563142a30a | ||
|
|
07624f6b9f | ||
|
|
ded71f567b | ||
|
|
b6dee87eb3 | ||
|
|
4760e8650c | ||
|
|
e9013b703b | ||
|
|
58eba642f7 | ||
|
|
969dc1400c | ||
|
|
b7d3fde225 | ||
|
|
2a0b611459 | ||
|
|
cf667453a7 | ||
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 | ||
|
|
9cc4c7e47a | ||
|
|
46bcb21cdb | ||
|
|
15a23dfaf4 | ||
|
|
2ec72fd08f | ||
|
|
15a010855e | ||
|
|
dda4aff746 | ||
|
|
e1e4ca0df6 | ||
|
|
ae5d790011 | ||
|
|
33ed2398ab | ||
|
|
c6437a605c | ||
|
|
431530af2a | ||
|
|
61d4dacb3a | ||
|
|
cdd433cb15 | ||
|
|
3f2ca30775 | ||
|
|
4d036f532d | ||
|
|
0314560885 | ||
|
|
0286961e92 | ||
|
|
6372ef9c8d | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c | ||
|
|
d52ceeb0f9 | ||
|
|
a9f146d77e | ||
|
|
04e80a3531 | ||
|
|
5569d08b59 | ||
|
|
c628997934 | ||
|
|
8353761b6e | ||
|
|
15f058f760 | ||
|
|
8dfd81ebe4 | ||
|
|
bef5542ce3 | ||
|
|
57463f993b | ||
|
|
c7cd527045 | ||
|
|
00235d1594 | ||
|
|
6d177d5d8a | ||
|
|
89b08349c4 | ||
|
|
f1d61ebd6e | ||
|
|
dbf60d3a7e | ||
|
|
b0c1e2f826 | ||
|
|
0eb2d60569 | ||
|
|
3b305b0f8e | ||
|
|
a451d4187f | ||
|
|
8a692bfe2f | ||
|
|
cde01ff02b | ||
|
|
d250f41749 | ||
|
|
4fb05748c3 | ||
|
|
10d1455eff | ||
|
|
e6a222f196 | ||
|
|
1fdc6720a0 | ||
|
|
080379cc64 | ||
|
|
8c9cf370e8 | ||
|
|
c7588c5aa2 | ||
|
|
9b81fcf2fc | ||
|
|
242941740b | ||
|
|
7e51fe6345 | ||
|
|
8c6fceb682 | ||
|
|
0dadabfea6 | ||
|
|
261578357a | ||
|
|
447016f03b | ||
|
|
e13722eae9 | ||
|
|
2b30dd4708 | ||
|
|
440bf0cd56 | ||
|
|
8add483a5b | ||
|
|
3258a24f20 | ||
|
|
82b6c6b333 | ||
|
|
08b00627e5 | ||
|
|
fc65914f55 | ||
|
|
8e23f31685 | ||
|
|
f5aaa1cccb | ||
|
|
b4878b7795 | ||
|
|
9cc63b7854 | ||
|
|
e7b5082398 | ||
|
|
446403cdf2 | ||
|
|
a7ae9ed3a4 | ||
|
|
ed289b9ecd | ||
|
|
b92a167b5a | ||
|
|
a06c5ed281 | ||
|
|
10915d04fc | ||
|
|
a459579f8b | ||
|
|
a6799b91d3 | ||
|
|
c44fd8495e | ||
|
|
7ef7eef963 | ||
|
|
8cd0f1dc88 | ||
|
|
b26d382c11 | ||
|
|
9e2f83a85e | ||
|
|
a1d2599c47 | ||
|
|
ca1eee5425 | ||
|
|
6bc5e8f006 | ||
|
|
ea9f17e8d2 | ||
|
|
7e65df1d21 | ||
|
|
f149d02e9a | ||
|
|
da669db1eb | ||
|
|
7c1f08d943 | ||
|
|
f687322c3f | ||
|
|
752b300474 | ||
|
|
3d8cb22235 |
No files matched your search
+11
-1
@@ -40,4 +40,14 @@ dist-ssr
|
||||
# Optional
|
||||
.vercel
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
supabase/.temp/
|
||||
|
||||
# Backup locali del database (contengono dati reali degli utenti):
|
||||
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
|
||||
backups/*
|
||||
!backups/backup.sh
|
||||
!backups/download-storage.mjs
|
||||
!backups/storage-core.ts
|
||||
!backups/README.md
|
||||
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
|
||||
ops/
|
||||
@@ -1,38 +0,0 @@
|
||||
# Obiettivi di squadra: sezione in Squadra + widget in home
|
||||
|
||||
## Stato attuale
|
||||
|
||||
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
|
||||
|
||||
## Cosa faccio
|
||||
|
||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
||||
|
||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
||||
|
||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
||||
|
||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
||||
|
||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
||||
|
||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
||||
|
||||
4. **Obiettivi demo iniziali**
|
||||
|
||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
||||
- 70% di risposte entro 24h nel prossimo mese.
|
||||
- Prima vittoria del campionato (collegato allo storico match).
|
||||
- 5 vittorie in campionato (collegato allo storico match).
|
||||
- 10 vittorie in campionato (collegato allo storico match).
|
||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
||||
- Bottom nav e rotte invariate.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
|
||||
|
||||
## Stato attuale
|
||||
|
||||
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
|
||||
|
||||
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
|
||||
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
|
||||
|
||||
## Cosa serve per renderla operativa
|
||||
|
||||
1. **Rosa e profili giocatori sul database**
|
||||
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
|
||||
- Collegare ogni riga all'utente autenticato corrispondente.
|
||||
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
|
||||
|
||||
2. **Autenticazione reale**
|
||||
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
|
||||
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
|
||||
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
|
||||
|
||||
3. **Calendario eventi persistente**
|
||||
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
|
||||
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
|
||||
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
|
||||
|
||||
4. **Statistiche e badge dinamici**
|
||||
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
|
||||
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
|
||||
|
||||
5. **Scout live collegato ai dati reali**
|
||||
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
|
||||
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
|
||||
|
||||
6. **Classifica CSI**
|
||||
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
|
||||
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
|
||||
|
||||
7. **Notifiche push definitive**
|
||||
- Verificare che i cron job inviino correttamente i promemoria palloni.
|
||||
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
|
||||
|
||||
8. **Ruoli e permessi**
|
||||
- Definire chi può creare/modificare eventi (capitano/admin).
|
||||
- Chi può fare scout (designato per partita).
|
||||
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
|
||||
|
||||
## Cosa resta salvato nel Cloud
|
||||
|
||||
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
|
||||
|
||||
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
|
||||
|
||||
## Decisioni da prendere insieme
|
||||
|
||||
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
|
||||
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
|
||||
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
|
||||
@@ -1,25 +0,0 @@
|
||||
# Ridimensionamento badge nella lista squadra
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
||||
|
||||
## Modifiche previste
|
||||
|
||||
1. **Lista squadra (`src/routes/squadra.tsx`)**
|
||||
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
|
||||
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
|
||||
- Lasciare invariati avatar, nome, ruolo ed età.
|
||||
|
||||
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
|
||||
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
|
||||
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
|
||||
|
||||
3. **Verifica**
|
||||
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
|
||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
||||
- Layout generale della pagina e bottom navigation.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
||||
|
||||
## Modifica
|
||||
|
||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
||||
|
||||
## Verifica
|
||||
|
||||
- Build senza errori.
|
||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Selezione giocatore al primo avvio
|
||||
|
||||
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
1. **Nuovo store `src/lib/user-store.ts`**
|
||||
- Persiste in `localStorage` l'`id` del giocatore scelto.
|
||||
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
|
||||
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
|
||||
|
||||
2. **Nuova route `/benvenuto`**
|
||||
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
|
||||
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
|
||||
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
|
||||
- Non mostra la bottom navigation.
|
||||
|
||||
3. **Reindirizzamento condizionato in `__root.tsx`**
|
||||
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
|
||||
- Dopo la scelta, l'app funziona normalmente.
|
||||
|
||||
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
|
||||
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
|
||||
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
|
||||
|
||||
5. **Cambio utente dalle impostazioni**
|
||||
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
|
||||
|
||||
## Note tecniche
|
||||
|
||||
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
|
||||
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
|
||||
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
|
||||
- Nessun backend richiesto: resta un prototipo locale.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Turno palloni nel calendario + promemoria
|
||||
|
||||
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
|
||||
|
||||
## Cosa vedrà la squadra
|
||||
|
||||
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
|
||||
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
|
||||
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
|
||||
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
|
||||
- **Due promemoria per l'incaricato**:
|
||||
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
|
||||
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
|
||||
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
|
||||
|
||||
## Impostazione tecnica
|
||||
|
||||
**Backend (Lovable Cloud)**
|
||||
|
||||
- Attivazione di Lovable Cloud.
|
||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
||||
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
|
||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
||||
|
||||
**Notifiche push**
|
||||
|
||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
||||
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
|
||||
|
||||
## Ordine di lavoro
|
||||
|
||||
1. Attivare Lovable Cloud e creare tabelle + policy.
|
||||
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
|
||||
3. Rotazione automatica suggerita + storico turni.
|
||||
4. Banner promemoria in-app.
|
||||
5. Notifiche push + job giornaliero.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_current",
|
||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
||||
}
|
||||
@@ -5,8 +5,8 @@ su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ri
|
||||
|
||||
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).
|
||||
squadra e usare l'AI solo quando porta un beneficio reale. Deve restare semplice, veloce e
|
||||
usabile dallo smartphone anche da chi non è pratico.
|
||||
|
||||
## Prima di modificare il codice
|
||||
|
||||
@@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
|
||||
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 typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
|
||||
npm run format # prettier --write .
|
||||
npm run test # suite di test (test/); npm run test:all per quella completa
|
||||
|
||||
@@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso 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);
|
||||
2. `npm run lint`, `npm run typecheck` 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.
|
||||
|
||||
@@ -83,8 +84,7 @@ 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).
|
||||
[docs/ROADMAP.md](docs/ROADMAP.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
|
||||
|
||||
+89
-8
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 04/09/2026
|
||||
Ultimo aggiornamento: 29/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -10,7 +10,12 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
|
||||
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
||||
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
||||
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
||||
reali (M9).
|
||||
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha
|
||||
portato il ruolo allenatore. La versione corrente è la 1.2.1: notifiche su tre canali (push, in-app
|
||||
ed email), promemoria che non si ripetono, turno palloni e solleciti presenze automatici (vedi
|
||||
`docs/CHANGELOG.md`).
|
||||
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
|
||||
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +26,12 @@ reali (M9).
|
||||
- Cursor e Claude Code come ambienti di sviluppo
|
||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||
- 20 migration in `supabase/migrations/`, fino a `m9_risposte_presenze_risposto_il`
|
||||
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
|
||||
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
|
||||
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
|
||||
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
|
||||
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
|
||||
su `develop`, non ancora su `main`
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
@@ -36,7 +46,52 @@ reali (M9).
|
||||
|
||||
## Database
|
||||
|
||||
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase
|
||||
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
|
||||
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
|
||||
`npx supabase migration list`)
|
||||
- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
|
||||
per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca
|
||||
schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche,
|
||||
route aggiornate) non è ancora deployato: la tabella si riempie già in background
|
||||
- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna
|
||||
`giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge
|
||||
l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del
|
||||
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
|
||||
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
|
||||
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
|
||||
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
|
||||
in `npx supabase migration list` del 24/09/2026)
|
||||
- Migration `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()`
|
||||
include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi
|
||||
(li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con
|
||||
`npx supabase db reset` e i test di integrazione, **non ancora in produzione**
|
||||
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
|
||||
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
|
||||
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
|
||||
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
|
||||
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
|
||||
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
|
||||
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
|
||||
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
|
||||
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
|
||||
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
|
||||
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
|
||||
ancora in produzione**
|
||||
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
|
||||
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
|
||||
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
|
||||
esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e
|
||||
una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal
|
||||
29/09/2026 (`supabase db push`) con il worker in funzione
|
||||
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
|
||||
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
|
||||
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
|
||||
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
|
||||
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
|
||||
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
|
||||
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
|
||||
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
|
||||
da `/admin` dopo il deploy del codice nuovo
|
||||
- `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
|
||||
@@ -48,10 +103,31 @@ reali (M9).
|
||||
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 `m10_azzera_turni_palloni_allenamenti`: toglie i turni palloni salvati sugli
|
||||
allenamenti, che non ricevono più una proposta automatica (vedi
|
||||
[docs/modules/palloni.md](docs/modules/palloni.md))
|
||||
- Migration `m11_scritture_per_ruolo`: le policy di scrittura rispecchiano i permessi
|
||||
dell'interfaccia (DD-023). Fino a M10 un qualsiasi utente autenticato poteva svuotare il
|
||||
calendario o riscrivere il voto di un altro parlando direttamente con PostgREST; la tabella
|
||||
dei permessi sta in [docs/DATABASE.md](docs/DATABASE.md) ed è verificata da
|
||||
`test/integration/permessi.test.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
|
||||
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
|
||||
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
|
||||
pagelle anche `pagelle_chiuse = false`)
|
||||
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
|
||||
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
|
||||
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
|
||||
`scout_sessioni`, `scout_live`, `scout_partite`)
|
||||
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
|
||||
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
|
||||
social legati a id Scout o CSI
|
||||
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
|
||||
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
|
||||
role), se mai servisse di nuovo
|
||||
|
||||
---
|
||||
|
||||
@@ -59,13 +135,18 @@ reali (M9).
|
||||
|
||||
- Squadra
|
||||
- Presenze
|
||||
- Badge
|
||||
- Badge (incluso badge social)
|
||||
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
||||
- Pagelle
|
||||
- MVP
|
||||
- Obiettivi di squadra
|
||||
- Turno palloni (specifica in `docs/modules/palloni.md`)
|
||||
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
|
||||
- Notifiche
|
||||
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
||||
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
|
||||
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
|
||||
|
||||
---
|
||||
|
||||
@@ -125,9 +206,9 @@ 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).
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0), le notifiche su tre
|
||||
canali (DD-036–DD-039, 1.2.0) e quelle automatiche di palloni e solleciti (DD-040, 1.2.1) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ npm run dev # http://localhost:8080
|
||||
```bash
|
||||
npm run build # build di produzione
|
||||
npm run lint # eslint (include prettier)
|
||||
npm run typecheck # controllo dei tipi TypeScript
|
||||
npm run test # test unit; npm run test:all per la suite completa
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Backup di disaster recovery
|
||||
|
||||
Script per scaricare su questo host un backup completo del progetto Supabase:
|
||||
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
|
||||
se il progetto Supabase (o Vercel) venisse perso.
|
||||
|
||||
## Preparazione, passo per passo
|
||||
|
||||
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
|
||||
`./backups/backup.sh`).
|
||||
|
||||
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
|
||||
```bash
|
||||
npx supabase --version
|
||||
```
|
||||
2. **Accedi con il tuo account Supabase**:
|
||||
```bash
|
||||
npx supabase login
|
||||
```
|
||||
Si apre il browser per l'OAuth; il token resta salvato in locale.
|
||||
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
|
||||
`supabase/config.toml`, quindi in genere basta):
|
||||
```bash
|
||||
npx supabase link --project-ref <project_id>
|
||||
```
|
||||
Verifica che sia andato a buon fine con `npx supabase projects list`
|
||||
(il progetto deve comparire con `"linked":true`).
|
||||
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
|
||||
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
|
||||
```
|
||||
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
|
||||
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
|
||||
```
|
||||
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
|
||||
Project Settings → API. La **service role key** è quella con permessi pieni
|
||||
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
|
||||
esposta lato client né committata.
|
||||
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
|
||||
```bash
|
||||
gpg --version
|
||||
```
|
||||
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
|
||||
|
||||
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
|
||||
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||
# passphrase a te scelta) e cancella cartella+tar.gz
|
||||
# in chiaro
|
||||
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
|
||||
# (nessuna passphrase richiesta)
|
||||
```
|
||||
|
||||
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
|
||||
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
|
||||
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
|
||||
|
||||
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
|
||||
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
|
||||
mano quando non servono più.
|
||||
|
||||
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
|
||||
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
|
||||
incompleto non va considerato valido, rilancialo.
|
||||
|
||||
Il backup contiene:
|
||||
|
||||
| File/cartella | Contenuto |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
|
||||
| `data_public.sql` | dati delle tabelle applicative |
|
||||
| `schema_auth.sql` | schema utenti Supabase Auth |
|
||||
| `data_auth.sql` | utenti reali, incluso l'hash della password |
|
||||
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
|
||||
|
||||
## Decomprimere
|
||||
|
||||
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
|
||||
del backup:
|
||||
|
||||
```bash
|
||||
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
|
||||
```
|
||||
|
||||
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
|
||||
|
||||
Archivio non cifrato (generato con `--no-encrypt`):
|
||||
|
||||
```bash
|
||||
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
|
||||
```
|
||||
|
||||
## Ripristino
|
||||
|
||||
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
|
||||
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
|
||||
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
|
||||
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
|
||||
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
|
||||
contenuto di `storage/`.
|
||||
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
|
||||
backup — vedi sotto).
|
||||
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
|
||||
GitHub e Gitea).
|
||||
|
||||
## Cosa NON è incluso
|
||||
|
||||
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
|
||||
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
|
||||
- **Codice applicativo**: vive nel repo git, non nel database.
|
||||
|
||||
## Sicurezza
|
||||
|
||||
Questi backup contengono password hash, documenti d'identità e altri dati
|
||||
personali reali. Per questo:
|
||||
|
||||
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
|
||||
quelli sì restano in git) — **non committare mai i dati generati**;
|
||||
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
|
||||
invece del tar.gz in chiaro generato con `--no-encrypt`;
|
||||
- cancella i backup in chiaro quando non ti servono più.
|
||||
|
||||
## File di questa cartella
|
||||
|
||||
| File | Ruolo |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
|
||||
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
|
||||
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
||||
# Uso:
|
||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
|
||||
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
|
||||
#
|
||||
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
|
||||
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
|
||||
# installato (esegue download-storage.mjs, che importa un modulo .ts).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STAMP="$(date +%Y%m%d_%H%M)"
|
||||
RUN_DIR="$SCRIPT_DIR/$STAMP"
|
||||
|
||||
mkdir -p "$RUN_DIR"
|
||||
echo "==> Backup in $RUN_DIR"
|
||||
|
||||
echo "==> Dump schema pubblico"
|
||||
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
|
||||
|
||||
echo "==> Dump dati pubblici"
|
||||
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
|
||||
|
||||
echo "==> Dump schema auth"
|
||||
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
|
||||
|
||||
echo "==> Dump dati auth (utenti)"
|
||||
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
|
||||
|
||||
echo "==> Download file storage (bucket)"
|
||||
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
|
||||
|
||||
echo "==> Backup completato: $RUN_DIR"
|
||||
|
||||
TARBALL="$RUN_DIR.tar.gz"
|
||||
echo "==> Comprimo in $TARBALL"
|
||||
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
|
||||
|
||||
if [[ "${1:-}" == "--no-encrypt" ]]; then
|
||||
echo "==> Archivio pronto: $TARBALL"
|
||||
else
|
||||
ARCHIVE="$TARBALL.gpg"
|
||||
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
||||
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
|
||||
rm -rf "$RUN_DIR" "$TARBALL"
|
||||
echo "==> Archivio cifrato pronto: $ARCHIVE"
|
||||
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
|
||||
fi
|
||||
@@ -0,0 +1,45 @@
|
||||
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
|
||||
// Uso: node backups/download-storage.mjs [cartella-output]
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { elencaFile, leggiEnv } from "./storage-core.ts";
|
||||
|
||||
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
|
||||
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
|
||||
|
||||
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
|
||||
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
|
||||
|
||||
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
const elencaCartella = (bucket) => async (prefisso) => {
|
||||
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
};
|
||||
|
||||
const falliti = [];
|
||||
|
||||
for (const bucket of BUCKETS) {
|
||||
console.log(`Bucket: ${bucket}`);
|
||||
const files = await elencaFile(elencaCartella(bucket));
|
||||
console.log(` ${files.length} file trovati`);
|
||||
for (const path of files) {
|
||||
const { data, error } = await supabase.storage.from(bucket).download(path);
|
||||
if (error) {
|
||||
console.error(` ERRORE ${path}:`, error.message);
|
||||
falliti.push(`${bucket}/${path}`);
|
||||
continue;
|
||||
}
|
||||
const destPath = join(OUT_DIR, bucket, path);
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
|
||||
console.log(` scaricato: ${bucket}/${path}`);
|
||||
}
|
||||
}
|
||||
if (falliti.length) {
|
||||
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Fatto.");
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
|
||||
|
||||
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
|
||||
export function leggiEnv(testo: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const riga of testo.split("\n")) {
|
||||
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
|
||||
if (!match) continue;
|
||||
const [, chiave, grezzo] = match as unknown as [string, string, string];
|
||||
const tra = /^(["'])(.*)\1$/.exec(grezzo);
|
||||
env[chiave] = tra ? tra[2]! : grezzo;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export type Voce = { name: string; id: string | null };
|
||||
|
||||
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
|
||||
export async function elencaFile(
|
||||
lista: (prefisso: string) => Promise<Voce[]>,
|
||||
prefisso = "",
|
||||
): Promise<string[]> {
|
||||
const file: string[] = [];
|
||||
for (const voce of await lista(prefisso)) {
|
||||
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
|
||||
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
|
||||
else file.push(percorso);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tanstack_start_ts",
|
||||
"name": "crapp",
|
||||
"dependencies": {
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
@@ -27,7 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -130,14 +129,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
|
||||
|
||||
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
|
||||
|
||||
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
@@ -424,7 +415,7 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
@@ -800,10 +791,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -820,6 +807,8 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
|
||||
|
||||
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@ saveTextLockfile = true
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
|
||||
minimumReleaseAgeExcludes = []
|
||||
@@ -60,8 +60,9 @@ ricorrente:
|
||||
`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`);
|
||||
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
|
||||
eccezione: `scout-live.ts`, dove il lock può essere stato preso da un altro dispositivo,
|
||||
quindi quello che abbiamo scritto non è detto sia quello che vale;
|
||||
- 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
|
||||
@@ -107,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a
|
||||
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 typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
|
||||
npm run format # prettier --write .
|
||||
npm run test # test unit (veloci, senza rete né database)
|
||||
npm run test:integration # route server vere
|
||||
|
||||
+239
-174
@@ -1,209 +1,274 @@
|
||||
# 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).
|
||||
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
|
||||
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
|
||||
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
|
||||
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
|
||||
registra solo _quando_ una voce è stata rilasciata e con quale versione.
|
||||
|
||||
## Versione attuale — agosto 2026
|
||||
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
|
||||
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
|
||||
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
|
||||
versioni `0.y.z` erano pre-release.
|
||||
|
||||
### Le notifiche push arrivano anche ad app chiusa
|
||||
## [Non rilasciato]
|
||||
|
||||
- Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291)
|
||||
invece di essere recuperato dal service worker con una fetch al risveglio: era quella fetch
|
||||
a non arrivare mai in tempo a telefono bloccato, e la notifica non compariva affatto
|
||||
(DD-026).
|
||||
- Spariscono la route `/api/public/push-messaggio`, la coda `promemoria_push` e
|
||||
`messaggioPalloniOggi()`: esistevano solo per rimediare al payload vuoto.
|
||||
## [1.2.1] - 2026-09-30
|
||||
|
||||
### Lo Scout Live si apre dalla pagina della partita
|
||||
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
|
||||
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
|
||||
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
|
||||
|
||||
- Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo
|
||||
l'URL. Ora la card `ScoutEntry` sta in `/partita/$id`, sezione «Scout live».
|
||||
- Si accende solo se la partita aperta è quella di oggi (nuova prop `eventoId`), altrimenti
|
||||
resta grigia con «Si attiva il giorno della partita». Lock di sessione invariato.
|
||||
- Non è più riservato agli admin: può scoutare chiunque sia autenticato, uno per volta grazie
|
||||
al lock. Sparisce il messaggio «Scout riservato».
|
||||
### Aggiunto
|
||||
|
||||
### Il sondaggio pre-partita apre alle 8:00 del giorno della partita
|
||||
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
|
||||
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
|
||||
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
|
||||
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
|
||||
turno» resta.
|
||||
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
|
||||
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
|
||||
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
|
||||
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
|
||||
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
|
||||
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
|
||||
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
|
||||
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
|
||||
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
|
||||
non riceve tutte le notifiche insieme.
|
||||
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
|
||||
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
|
||||
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
|
||||
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
|
||||
- **Documentazione** — DD-040 in `DESIGN_DECISIONS.md`; sezione «Testi delle notifiche» nel catalogo di
|
||||
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
|
||||
|
||||
- Prima era sempre votabile, anche settimane prima: ora la card resta chiusa con l'avviso di
|
||||
apertura e si sblocca alle 8:00 del giorno stesso.
|
||||
- Quando è aperto, gli amministratori hanno nella card il pulsante «Avvisa tutti del
|
||||
sondaggio» (`POST /api/public/apri-sondaggio`), che manda la push a tutti i dispositivi
|
||||
iscritti — stesso meccanismo del sollecito presenze. Nessun cron: l'invio è manuale.
|
||||
### Modificato
|
||||
|
||||
### Le note dell'evento si leggono aprendolo
|
||||
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
|
||||
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
|
||||
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
|
||||
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
|
||||
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
|
||||
palloni manuale non parte più per email.
|
||||
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
|
||||
di riconsegna, come già descritto nel catalogo.
|
||||
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
|
||||
«successivo» coincidono con quelli dei job del database.
|
||||
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
|
||||
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
|
||||
|
||||
- Il campo Note del form eventi si poteva scrivere ma non lo vedeva nessuno: ora compare
|
||||
nella scheda di `/allenamento/$id` e `/partita/$id`, sotto orario e luogo, con gli a capo
|
||||
mantenuti. Se è vuoto non compare niente.
|
||||
### Corretto
|
||||
|
||||
### Form eventi: data e ora non sfondano più la card su iOS
|
||||
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
|
||||
|
||||
- Lo stesso difetto già corretto sul tesseramento: `Campo` e le classi degli input erano
|
||||
ricopiati identici in tre file, quindi il `min-w-0` aggiunto in `ProfiloAmministrativo`
|
||||
non arrivava né al form «Nuovo evento» né alla dashboard admin. Ora `Campo` e
|
||||
`classiInput` stanno una volta sola in `ui-bits`.
|
||||
- La regola CSS che rende ridimensionabili i controlli nativi copre anche
|
||||
`input[type="time"]`, che nel form eventi sta affiancato alla data in `grid-cols-2`.
|
||||
## [1.2.0] - 2026-09-29
|
||||
|
||||
### Profilo: tab Documenti e Opzioni, barra senza scroll
|
||||
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
|
||||
Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione;
|
||||
questa versione porta il codice dell'app che le usa.
|
||||
|
||||
- L'etichetta nominava lo scopo (il tesseramento CSI) invece del contenuto: dentro ci sono
|
||||
dati personali, documento, certificato medico e foto tessera. Cambia anche la rotta
|
||||
(`/profilo?tab=documenti`): i vecchi link `?tab=tesseramento` aprono la tab Stagione.
|
||||
- «Impostazioni» diventa «Opzioni»: con le quattro etichette accorciate la barra delle
|
||||
sottosezioni ci sta in uno schermo da telefono. Le voci ora si dividono la riga in parti
|
||||
uguali (`grow basis-0`) e tornano a scorrere solo se non ci stanno, quindi vale anche per
|
||||
le barre di Squadra e Classifica.
|
||||
### Aggiunto
|
||||
|
||||
### Palloni: allenamenti senza proposta automatica
|
||||
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria
|
||||
a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email
|
||||
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
|
||||
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
|
||||
`m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di
|
||||
errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore
|
||||
«Email», acceso di default, per account (tabella `preferenze_utente`; DD-036).
|
||||
- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email,
|
||||
partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database,
|
||||
che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app
|
||||
(migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e
|
||||
una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi
|
||||
VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email.
|
||||
- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare
|
||||
anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038).
|
||||
- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione,
|
||||
diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in
|
||||
[notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali;
|
||||
tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni
|
||||
DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro
|
||||
dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello
|
||||
staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del
|
||||
worker.
|
||||
|
||||
- `completaTurni` non assegna più gli allenamenti: restano «da assegnare» finché non si
|
||||
sceglie a mano. Le partite tengono la rotazione. Migration M10 cancella eventuali turni
|
||||
salvati su allenamenti da oggi in poi.
|
||||
### Modificato
|
||||
|
||||
### Profilo: etichetta notifiche allineata al comportamento
|
||||
- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non
|
||||
c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di
|
||||
squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration
|
||||
`m25_promemoria_solo_giocatori`.
|
||||
- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che
|
||||
l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche
|
||||
attive».
|
||||
- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato.
|
||||
|
||||
- L’interruttore in Impostazioni non è più «Notifiche turno palloni»: iscrive il dispositivo
|
||||
a tutte le push (palloni, solleciti) e alle smart in app. Testo e docs aggiornati.
|
||||
- Il widget Home «Completa il tuo profilo» apre direttamente la tab Tesseramento
|
||||
(`/profilo?tab=tesseramento`).
|
||||
### Corretto
|
||||
|
||||
### Revisione dell'interfaccia: accessibilità, movimento, peso
|
||||
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
|
||||
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
|
||||
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
|
||||
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
|
||||
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
|
||||
|
||||
- **Contrasto**: `--success`, `--info` e `--training` erano tra 3.3:1 e 3.5:1 con il testo
|
||||
bianco sopra (chip «Presente», «Allenamento», celle del calendario): ora sono sotto la
|
||||
soglia di luminosità che garantisce 4.5:1. I gradi dei badge usavano `text-oro` e
|
||||
`text-argento` su bianco, cioè 1.9:1 e 2.5:1 — praticamente invisibili: nascono i token
|
||||
`--oro-testo`, `--argento-testo`, `--bronzo-testo` per il testo, mentre le versioni chiare
|
||||
restano su sfondi e bordi.
|
||||
- **PWA**: mancava `viewport-fit=cover`, quindi `env(safe-area-inset-bottom)` valeva sempre 0
|
||||
e su iPhone la BottomNav finiva sotto la home bar. `theme-color` e `background_color` erano
|
||||
`#111111` su un'app chiara: barra di stato nera e splash nero prima di una UI bianca.
|
||||
- **`lang="it"`** al posto di `lang="en"`, su un'app interamente in italiano; 404 e schermata
|
||||
d'errore tradotte; anteprima social ripulita dall'immagine Lovable scaduta e da
|
||||
`twitter:site` che puntava a `@Lovable`.
|
||||
- **Tocco e tastiera**: nessun `:focus-visible` era definito (ora c'è una regola globale);
|
||||
chip presenza, filtri e bottoni icona portati a 44px; `aria-current` sulla navigazione,
|
||||
`aria-pressed` sui controlli a stato, `aria-controls` sulle sezioni a tendina, `aria-busy`
|
||||
sui caricamenti. Il testo sotto i 12px è sparito (108 occorrenze).
|
||||
- **Movimento** (DD-021): molle interrompibili di `motion` al posto delle `@keyframes` a
|
||||
durata fissa. `Reveal` compare quando entra davvero nel viewport (prima consumava
|
||||
l'animazione a vuoto sotto la piega); `Barra` anima `scaleX` invece di `width`; `Numero`
|
||||
cambia rotta se il dato cambia a metà conteggio. Il calendario si cambia mese anche con lo
|
||||
swipe, con il punto d'arrivo scelto proiettando la velocità di rilascio.
|
||||
- **Meno dipendenze** (DD-021): rimossi 43 componenti `src/components/ui/` non importati da
|
||||
nessuna parte e ~45 dipendenze (tutti i `@radix-ui/*`, `recharts`, `react-hook-form`,
|
||||
`date-fns`, `embla`, `cmdk`; `zod` resta perché lo usano le route API). Restano `drawer` e
|
||||
`sonner`. Il bundle **non** cala per questo — quel codice era già escluso dal
|
||||
tree-shaking — ma cala la superficie da aggiornare e da controllare: 50 dipendenze dirette
|
||||
diventano 21. Il bundle client cresce di ~42 KB gzip per `motion` (267 → 308 KB).
|
||||
- **Primitiva `Card`**: `rounded-3xl bg-card p-4 shadow-card` era ricopiato a mano 22 volte.
|
||||
- **Tema scuro rimosso** (DD-022): esisteva un blocco `.dark` mai applicato e incoerente.
|
||||
- Tolte le quattro switch di notifica in `/profilo` che erano `defaultChecked` e non facevano
|
||||
niente, e la conferma nativa prima di _cambiare_ la foto profilo (resta su quella che la
|
||||
rimuove, che è irreversibile).
|
||||
- Le celle del calendario con più tipi di evento non usano più un gradiente a fette con
|
||||
un'ombra bianca sul numero per restare leggibili: fondo neutro e un puntino per tipo.
|
||||
## [1.1.1] - 2026-09-28
|
||||
|
||||
### Serie di presenze calcolate sui dati reali
|
||||
### Aggiunto
|
||||
|
||||
- `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%).
|
||||
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
|
||||
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
|
||||
pausa dopo 7 giorni senza richieste API.
|
||||
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
|
||||
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
|
||||
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
|
||||
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
|
||||
pertinente.
|
||||
|
||||
### Segnalazioni dal profilo
|
||||
### Modificato
|
||||
|
||||
- «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)).
|
||||
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
|
||||
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
|
||||
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
|
||||
di decompressione nel README.
|
||||
|
||||
### Autenticazione e dashboard amministratore (in produzione)
|
||||
### Corretto
|
||||
|
||||
- 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.
|
||||
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
|
||||
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
|
||||
mostrare subito tutto.
|
||||
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
|
||||
il cambio tab verso Obiettivi.
|
||||
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
|
||||
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
|
||||
sono allenamenti o partite.
|
||||
|
||||
### Test
|
||||
## [1.1.0] - 2026-09-27
|
||||
|
||||
- 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.
|
||||
### Aggiunto
|
||||
|
||||
### Collegamento CSI
|
||||
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
|
||||
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
|
||||
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
|
||||
nessuna push (DD-035).
|
||||
|
||||
- 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).
|
||||
## [1.0.0] - 2026-09-24
|
||||
|
||||
### Infrastruttura
|
||||
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
||||
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
|
||||
|
||||
- Migrazione completa da Lovable a sviluppo locale.
|
||||
- Configurazione Git.
|
||||
- Repository GitHub indipendente.
|
||||
- Deploy automatico tramite Vercel.
|
||||
- Branch main e develop.
|
||||
### Aggiunto
|
||||
|
||||
## Versione 1.0 — luglio 2026
|
||||
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
|
||||
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
|
||||
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
|
||||
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
|
||||
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
|
||||
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
|
||||
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
|
||||
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
|
||||
`m17_notifiche_utente` (DD-030).
|
||||
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
|
||||
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
|
||||
notifiche attive.
|
||||
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
|
||||
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
|
||||
di un evento esistente compare una conferma, come per l'eliminazione.
|
||||
- **Backup completo** — uno script scarica schema e dati del database e i file dello
|
||||
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
|
||||
|
||||
Prima versione usata dalla squadra. Funzionalità incluse: vedi
|
||||
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
|
||||
### Modificato
|
||||
|
||||
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
|
||||
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
|
||||
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
|
||||
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
|
||||
storico partite è diviso per stagione.
|
||||
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
|
||||
mensile, che si scorre anche con lo swipe come quello della squadra.
|
||||
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
|
||||
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
|
||||
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
|
||||
|
||||
### Corretto
|
||||
|
||||
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
|
||||
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
|
||||
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
|
||||
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
|
||||
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
|
||||
|
||||
## [0.9.2] - 2026-09-10
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
|
||||
le notifiche push attive e chi sono.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
|
||||
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
|
||||
alla volta.
|
||||
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
|
||||
su chi vede quei dati).
|
||||
|
||||
### Rimosso
|
||||
|
||||
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
|
||||
verso l'editor): l'app non ci gira più.
|
||||
|
||||
## [0.9.1] - 2026-09-10
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
|
||||
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
|
||||
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
|
||||
|
||||
## [0.9.0] - 2026-09-09
|
||||
|
||||
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
|
||||
questa release riunisce tutto ciò che l'app fa oggi in produzione.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
|
||||
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
|
||||
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
|
||||
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
|
||||
certificati resta un'estensione futura.
|
||||
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
|
||||
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
|
||||
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
|
||||
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
|
||||
squadra al posto di chat e fogli condivisi.
|
||||
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
|
||||
reali della rosa.
|
||||
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
|
||||
durante la partita.
|
||||
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
|
||||
votati tra compagni.
|
||||
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
|
||||
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
|
||||
compagni, un voto a testa.
|
||||
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
|
||||
convocazioni, pagelle e risultati di campionato.
|
||||
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
|
||||
allenamenti e partite.
|
||||
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
|
||||
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
|
||||
dati della rosa, con download CSV.
|
||||
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
|
||||
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
|
||||
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
|
||||
manuale da parte degli amministratori.
|
||||
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
|
||||
presenza già registrato per le convocazioni.
|
||||
|
||||
### Sicurezza
|
||||
|
||||
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
|
||||
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
|
||||
+59
-32
@@ -7,31 +7,37 @@ non in questo file.
|
||||
|
||||
## Permessi di scrittura
|
||||
|
||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
|
||||
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
|
||||
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
|
||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
|
||||
nessuna di esse da M4 (DD-011).
|
||||
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
|
||||
| `user_roles` | solo admin |
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
|
||||
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
|
||||
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
|
||||
|
||||
## 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). |
|
||||
| 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. 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. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
|
||||
| `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). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
|
||||
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
@@ -44,12 +50,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
||||
|
||||
## 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). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
@@ -61,19 +67,40 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
||||
|
||||
## 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. | |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. Migration `m26_notifiche_automatiche` (DD-040): il vincolo `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job; `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti. Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi automatici ma non i due dei pulsanti. |
|
||||
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). Con DD-040 (migration `m26`) il trigger salta i tipi del turno palloni, che non mandano email. |
|
||||
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
|
||||
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. Con DD-040 (migration `m26`) il `CHECK` sul tipo ammette anche i tipi dei palloni (12h, 6h, 3h, revocato) e dei solleciti (24h, 12h, 6h); è anche la memoria che permette la revoca. |
|
||||
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. Con DD-040 (migration `m26`) accoda anche turno palloni e solleciti presenze. |
|
||||
|
||||
### Funzioni SQL e job delle notifiche
|
||||
|
||||
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
|
||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26), con `genera_avvisi_palloni_fascia()` | Avvisi del turno palloni a 12, 6 e 3 ore per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26), con `..._fascia()` | Solleciti a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
|
||||
+941
-53
File diff suppressed because it is too large.
Load diff
@@ -11,8 +11,9 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
|
||||
(`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`.
|
||||
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
|
||||
`src/lib/scout-live.ts`, dove il lock di sessione può essere stato preso da un altro
|
||||
dispositivo e la riga scritta non basta a sapere chi ha vinto.
|
||||
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à
|
||||
|
||||
+19
-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. |
|
||||
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
|
||||
## Regole da rispettare nelle prossime modifiche
|
||||
|
||||
@@ -28,6 +28,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
||||
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
|
||||
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
|
||||
|
||||
## Backup e disaster recovery
|
||||
|
||||
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
|
||||
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
|
||||
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
|
||||
Uso, contenuto del backup e procedura di ripristino sono descritti in
|
||||
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
|
||||
variabili d'ambiente Vercel (richiede login interattivo).
|
||||
|
||||
## Migrazione su server proprio (sintesi)
|
||||
|
||||
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
|
||||
|
||||
+17
-17
@@ -6,35 +6,35 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
|
||||
|
||||
## 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 |
|
||||
| Documento | Risponde a |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
|
||||
| [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 (backup/disaster recovery incluso) |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
|
||||
| [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/`.
|
||||
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
|
||||
→ `DATABASE.md` → `DESIGN_DECISIONS.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;
|
||||
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco: segue
|
||||
il formato Keep a Changelog, con il lavoro non ancora rilasciato sotto `[Non rilasciato]`
|
||||
e le voci divise per categoria (Aggiunto, Modificato, Sicurezza…);
|
||||
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
|
||||
- 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
|
||||
|
||||
+34
-22
@@ -1,10 +1,12 @@
|
||||
# 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.
|
||||
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con quale versione,
|
||||
[PROJECT_STATE.md](../PROJECT_STATE.md) cosa si sta facendo adesso.
|
||||
|
||||
## Versione 1.0 — rilasciata
|
||||
## Fatto
|
||||
|
||||
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
@@ -14,35 +16,45 @@ sta facendo adesso.
|
||||
- [x] Badge
|
||||
- [x] Badge social
|
||||
- [x] Pagelle
|
||||
- [x] Votazione MVP
|
||||
- [x] Obiettivi di squadra
|
||||
- [x] Turno palloni
|
||||
- [x] Infortuni — conteggio eventi saltati, in forma minima
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
|
||||
## 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] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del
|
||||
profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
|
||||
sollecito presenze)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
|
||||
resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
||||
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
|
||||
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
||||
([specifica](modules/notifiche-email.md), DD-036)
|
||||
- [x] Notifiche automatiche — promemoria con «oggi/domani», turno palloni 12, 6 e 3 ore prima, sollecito
|
||||
presenze a 24, 12 e 6 ore (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||
|
||||
## Versione 1.2
|
||||
## Prossimo
|
||||
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
- [ ] 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
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
|
||||
- Correzione push Android pronta in locale: aggiornamento del worker nelle sessioni della
|
||||
webapp e test del canale cifrato verificati. Resta la verifica su Motorola installato,
|
||||
ad app chiusa e schermo bloccato; procedura e limiti in [Notifiche](modules/notifiche.md).
|
||||
- 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).
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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,246 @@
|
||||
# Worker email: installazione e gestione
|
||||
|
||||
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
|
||||
per email e le push delle notifiche che nascono nel database: promemoria a 24 e 3 ore, turno palloni a 12, 6 e 3 ore con revoca, solleciti presenze a 24, 12 e 6 ore (DD-040). Cosa fa il canale email sta in
|
||||
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
|
||||
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
|
||||
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
|
||||
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
|
||||
Il nome del documento resta quello delle email, che sono il canale principale.
|
||||
|
||||
**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
|
||||
su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
|
||||
worker e la ricostruzione del container.
|
||||
|
||||
## Cosa serve
|
||||
|
||||
| Requisito | Note |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. |
|
||||
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
|
||||
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
|
||||
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
|
||||
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
|
||||
|
||||
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
|
||||
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
|
||||
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
|
||||
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
|
||||
file usati, quindi nemmeno il `.env` finisce nell'immagine.
|
||||
|
||||
## Account Gmail e password per app
|
||||
|
||||
Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**.
|
||||
|
||||
1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo.
|
||||
2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso).
|
||||
3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu.
|
||||
4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di
|
||||
16 caratteri, a gruppi di quattro: copiala.
|
||||
5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come
|
||||
script di shell, si rompe. Non va mai incollata in chat né nel repository.
|
||||
|
||||
Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account.
|
||||
|
||||
**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:**
|
||||
|
||||
| Causa | Cosa fare |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). |
|
||||
| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. |
|
||||
| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. |
|
||||
|
||||
Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un
|
||||
dominio proprio (vedi DD-036).
|
||||
|
||||
## Installazione
|
||||
|
||||
1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla
|
||||
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
|
||||
nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche
|
||||
accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie
|
||||
(il 29/09/2026 sono partiti così due messaggi di prova dell'admin).
|
||||
|
||||
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
|
||||
|
||||
| Variabile | Default | Contenuto |
|
||||
| --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
|
||||
| `SUPABASE_URL` | — | URL del progetto |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
|
||||
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
|
||||
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
|
||||
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
|
||||
| `POLL_SECONDI` | 30 | intervallo di polling |
|
||||
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
|
||||
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
|
||||
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
|
||||
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
|
||||
|
||||
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
|
||||
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
|
||||
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
|
||||
|
||||
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
|
||||
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
|
||||
worker con l'elenco degli errori. Il `.env` è ignorato da git.
|
||||
|
||||
**`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch
|
||||
(`…-git-<branch>-….vercel.app`). L'indirizzo di un singolo deploy (`…-<codice>-….vercel.app`)
|
||||
cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la
|
||||
Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a
|
||||
Vercel.
|
||||
|
||||
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
|
||||
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
|
||||
|
||||
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
|
||||
«push dei promemoria: attiva»;
|
||||
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
|
||||
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
|
||||
|
||||
## Gestione
|
||||
|
||||
| Cosa | Comando (dentro `mailer/`) |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Vedere il log in diretta | `docker compose logs -f` |
|
||||
| Ultime righe del log | `docker compose logs --tail 50` |
|
||||
| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) |
|
||||
| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) |
|
||||
| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` |
|
||||
| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) |
|
||||
| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. |
|
||||
|
||||
Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`,
|
||||
`ciclo fermato`), mai indirizzi né testi.
|
||||
|
||||
## Vedere cosa è partito
|
||||
|
||||
- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il
|
||||
riscontro più sicuro.
|
||||
- **SQL Editor di Supabase**, per stato e contenuto:
|
||||
|
||||
```sql
|
||||
select c.stato, c.inviata_il, c.tentativi, c.errore,
|
||||
g.nome, g.cognome, n.tipo, n.titolo, n.corpo
|
||||
from notifiche_email_coda c
|
||||
join notifiche_utente n on n.id = c.notifica_id
|
||||
join giocatori_squadra g on g.id = n.giocatore_id
|
||||
order by n.creato_il desc
|
||||
limit 50;
|
||||
```
|
||||
|
||||
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
|
||||
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
|
||||
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
|
||||
|
||||
## Spostare il worker su un altro host
|
||||
|
||||
Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in
|
||||
Supabase. Cambiare host non fa perdere né duplicare mail.
|
||||
|
||||
1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera.
|
||||
L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare.
|
||||
2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella
|
||||
`mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola.
|
||||
3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da
|
||||
chat né da git: contiene la service role e la password per app.
|
||||
4. **Spegni il vecchio:** `docker compose down` sul vecchio host.
|
||||
5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP
|
||||
raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`.
|
||||
|
||||
Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un
|
||||
po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa
|
||||
`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log.
|
||||
Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del
|
||||
nuovo worker.
|
||||
|
||||
Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La
|
||||
password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`,
|
||||
controlla la posta dell'account mittente e conferma l'accesso.
|
||||
|
||||
## Diagnosi
|
||||
|
||||
| Sintomo | Causa probabile | Cosa fare |
|
||||
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
|
||||
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
|
||||
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
|
||||
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
|
||||
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
|
||||
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
|
||||
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
|
||||
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
|
||||
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
|
||||
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
|
||||
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
|
||||
|
||||
## Prova in locale, senza toccare la produzione
|
||||
|
||||
`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`),
|
||||
con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può
|
||||
lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`.
|
||||
|
||||
1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con
|
||||
`npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si
|
||||
usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale.
|
||||
Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la
|
||||
porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si
|
||||
omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa.
|
||||
2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con
|
||||
`email`. In locale:
|
||||
|
||||
```bash
|
||||
. <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')
|
||||
UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \
|
||||
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
|
||||
docker exec supabase_db_<project_id> psql -U postgres -c \
|
||||
"set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='<slot>';"
|
||||
```
|
||||
|
||||
`session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la
|
||||
service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella
|
||||
tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed,
|
||||
si usa quello slot invece di impostarlo su un secondo.
|
||||
|
||||
3. **Una notifica di prova:**
|
||||
|
||||
```bash
|
||||
docker exec supabase_db_<project_id> psql -U postgres -c \
|
||||
"insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('<slot>','admin','Prova CrAPP','Messaggio di prova');"
|
||||
```
|
||||
|
||||
Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella).
|
||||
|
||||
4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito.
|
||||
|
||||
Per provare anche l'immagine Docker, il container si collega alla rete di Supabase
|
||||
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
|
||||
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
|
||||
|
||||
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
|
||||
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
|
||||
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
|
||||
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
|
||||
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
|
||||
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
|
||||
così.
|
||||
|
||||
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
|
||||
non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
|
||||
`bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
|
||||
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
|
||||
test. I test del database e del worker non ne hanno bisogno.
|
||||
|
||||
## Sicurezza
|
||||
|
||||
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
|
||||
- La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
|
||||
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
|
||||
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
|
||||
dispositivo del giocatore.
|
||||
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
|
||||
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
|
||||
@@ -1,5 +1,5 @@
|
||||
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
|
||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
|
||||
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
||||
-- `profili-giocatore` creato dalla migration M3.
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Modulo — Ruolo Allenatore
|
||||
|
||||
**Stato:** implementato
|
||||
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
|
||||
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
|
||||
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
|
||||
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
|
||||
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
|
||||
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
|
||||
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
|
||||
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
|
||||
`test/integration/permessi-route.test.ts`
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
|
||||
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
|
||||
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
|
||||
riceve voti e non ha statistiche, badge o stagione.
|
||||
|
||||
## Registrazione
|
||||
|
||||
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
|
||||
|
||||
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
|
||||
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
|
||||
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
|
||||
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
|
||||
3. Entra in Home.
|
||||
|
||||
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
|
||||
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
|
||||
|
||||
| Tab | Giocatore | Allenatore |
|
||||
| ------------ | --------- | ---------------------------------------------- |
|
||||
| Stagione | sì | **no** |
|
||||
| Badge | sì | **no** |
|
||||
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
|
||||
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
|
||||
|
||||
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
|
||||
|
||||
### Dati modificabili dall'allenatore
|
||||
|
||||
| Campo | Dove sta oggi | Nota |
|
||||
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
|
||||
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
|
||||
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
|
||||
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
|
||||
| Telefono | `profili_giocatore.telefono` | |
|
||||
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
|
||||
|
||||
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto
|
||||
tessera, tesseramento CSI.
|
||||
|
||||
### Completamento profilo
|
||||
|
||||
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
|
||||
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
|
||||
quando quei campi sono compilati.
|
||||
|
||||
## Home
|
||||
|
||||
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
||||
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
||||
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
||||
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
|
||||
vedono solo gli admin e il giocatore interessato (DD-035).
|
||||
|
||||
## Squadra
|
||||
|
||||
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
|
||||
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
|
||||
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
|
||||
statistiche, e non compare nella tab Stats né nelle classifiche.
|
||||
|
||||
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
|
||||
non compaiono.
|
||||
|
||||
| Tab | Giocatore | Allenatore |
|
||||
| --------- | --------- | ---------- |
|
||||
| Rosa | sì | sì |
|
||||
| Stats | sì | sì |
|
||||
| Obiettivi | sì | **no** |
|
||||
| Badge | sì | **no** |
|
||||
|
||||
## Badge e cacche nascosti
|
||||
|
||||
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
|
||||
nessuna schermata:
|
||||
|
||||
| Dove | Cosa si nasconde all'allenatore |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
||||
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
|
||||
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
|
||||
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
|
||||
|
||||
Presenze, media voto, MVP e palloni restano visibili.
|
||||
|
||||
## Gestione eventi
|
||||
|
||||
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
|
||||
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
|
||||
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
|
||||
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
|
||||
a lui (`usePuoGestireEventi()`).
|
||||
|
||||
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
|
||||
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
|
||||
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono solleciti automatici a 24, 12 e 6 ore prima dell'evento, ma il pulsante resta.
|
||||
|
||||
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
|
||||
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
|
||||
risposte presenze altrui, gestione dei ruoli.
|
||||
|
||||
## Notifiche
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
|
||||
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
|
||||
|
||||
| Notifica | L'allenatore la riceve? | Note |
|
||||
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
|
||||
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
|
||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||
|
||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
|
||||
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
|
||||
funziona come per i giocatori, con i soli messaggi dello staff.
|
||||
|
||||
## Esclusione dalla rosa
|
||||
|
||||
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
|
||||
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
|
||||
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
|
||||
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
|
||||
includono.
|
||||
|
||||
| Livello | Dove si esclude l'allenatore |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
|
||||
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
|
||||
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
|
||||
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
|
||||
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
|
||||
|
||||
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
|
||||
giocatore).
|
||||
|
||||
## Permessi (riepilogo)
|
||||
|
||||
| Azione | Giocatore | Allenatore | Admin |
|
||||
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
|
||||
| Modificare nome e cognome | no | sì, i propri | tutti |
|
||||
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
||||
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
||||
| Avviso certificati in Home | il proprio | no | tutta la rosa |
|
||||
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
||||
| Sollecitare le presenze | no | sì | sì |
|
||||
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
||||
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
|
||||
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
|
||||
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
|
||||
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
|
||||
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
||||
| Tab Stagione e Badge del profilo | sì | no | sì |
|
||||
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
||||
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
||||
| Dashboard `/admin` | no | no | sì |
|
||||
|
||||
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
|
||||
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
|
||||
|
||||
## Implementazione
|
||||
|
||||
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
|
||||
lascia usare un valore di enum nella transazione che lo crea.
|
||||
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
|
||||
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
|
||||
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
|
||||
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
|
||||
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
|
||||
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
|
||||
allenatore; presenze, cacche e voti chiusi all'allenatore.
|
||||
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
|
||||
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
|
||||
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
|
||||
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
|
||||
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
|
||||
|
||||
## Scelte confermate
|
||||
|
||||
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
|
||||
messaggi dello staff.
|
||||
- Sollecita le presenze: sì. Promemoria palloni: no.
|
||||
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
|
||||
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
|
||||
posto del ruolo.
|
||||
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
|
||||
gli autenticati.
|
||||
+403
-8
@@ -1,6 +1,6 @@
|
||||
# Modulo — Badge
|
||||
|
||||
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
|
||||
**Stato:** implementato, coerente con DD-007 e DD-008
|
||||
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
|
||||
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
|
||||
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
|
||||
@@ -26,16 +26,45 @@ badge assegnati per voto dai compagni.
|
||||
## Implementazione
|
||||
|
||||
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
|
||||
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
|
||||
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
|
||||
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
|
||||
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
|
||||
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
|
||||
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
|
||||
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
|
||||
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
|
||||
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
|
||||
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
|
||||
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
|
||||
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
|
||||
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
|
||||
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
|
||||
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
|
||||
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
|
||||
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
|
||||
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
|
||||
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
|
||||
criterio delle presenze).
|
||||
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
|
||||
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
|
||||
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
|
||||
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
|
||||
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
|
||||
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
|
||||
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
|
||||
stessa `mediePagelle()`.
|
||||
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
|
||||
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
|
||||
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
|
||||
(modificabile), con **auto-voto escluso sia in UI sia in logica** (`VotoSocial.tsx`). A
|
||||
(modificabile), con **auto-voto escluso in interfaccia** (`VotoSocial.tsx`) e rifiutato dal
|
||||
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
|
||||
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
|
||||
`votante_id`/`votato_id` sono entrambi visibili.
|
||||
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
|
||||
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
|
||||
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
|
||||
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
|
||||
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
|
||||
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
|
||||
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
|
||||
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
|
||||
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
||||
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
|
||||
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
|
||||
@@ -45,6 +74,61 @@ badge assegnati per voto dai compagni.
|
||||
|
||||
---
|
||||
|
||||
## Elenco badge
|
||||
|
||||
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||
informazioni verso l'interfaccia.
|
||||
|
||||
### Badge normali (gradi bronzo/argento/oro)
|
||||
|
||||
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
|
||||
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||
|
||||
| id | nome | come si guadagna | soglie B/A/O |
|
||||
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
|
||||
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
|
||||
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||
|
||||
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
|
||||
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
|
||||
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
|
||||
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
|
||||
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||
|
||||
| id | nome | condizione esatta |
|
||||
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
|
||||
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||
|
||||
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
|
||||
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
|
||||
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
|
||||
voti ricevuti).
|
||||
|
||||
| id | nome | cosa premia |
|
||||
| ------------ | -------------------------- | ------------------------------------------------ |
|
||||
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
|
||||
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
|
||||
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
|
||||
| `meme` | Meme della partita | la scena più memorabile della partita |
|
||||
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
|
||||
@@ -54,6 +138,285 @@ badge assegnati per voto dai compagni.
|
||||
|
||||
---
|
||||
|
||||
## Copertura test
|
||||
|
||||
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
|
||||
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
|
||||
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
|
||||
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
|
||||
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
|
||||
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
|
||||
end-to-end completa.
|
||||
|
||||
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
|
||||
`badges.test.ts`) sull'output di altri moduli:
|
||||
|
||||
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
|
||||
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
|
||||
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
|
||||
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
|
||||
bronzo". Più la soglia minima di voti (vedi sotto).
|
||||
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
|
||||
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
|
||||
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
|
||||
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
|
||||
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
|
||||
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
|
||||
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
|
||||
tabella.
|
||||
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
|
||||
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
|
||||
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
|
||||
gli altri badge da tabella.
|
||||
|
||||
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
|
||||
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
|
||||
voto/turni/presenze
|
||||
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
|
||||
|
||||
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
|
||||
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
|
||||
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
|
||||
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
|
||||
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
|
||||
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
|
||||
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
|
||||
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
|
||||
|
||||
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
|
||||
copertura di questo badge):
|
||||
|
||||
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
|
||||
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
|
||||
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
|
||||
(`mvp_no_autovoto`).
|
||||
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
|
||||
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
|
||||
votato non convocati vengono rifiutati.
|
||||
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
|
||||
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
|
||||
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
|
||||
deve contare come vittoria.
|
||||
|
||||
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
|
||||
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
|
||||
|
||||
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
|
||||
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
|
||||
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
|
||||
(`pagelle_no_autovoto`), già presente prima di questa sessione.
|
||||
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
|
||||
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
|
||||
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
|
||||
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
|
||||
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
|
||||
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
|
||||
applichi le soglie normali sopra soglia.
|
||||
|
||||
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
|
||||
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
|
||||
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
|
||||
|
||||
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
|
||||
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
|
||||
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
|
||||
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
|
||||
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
|
||||
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
|
||||
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
|
||||
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
|
||||
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
|
||||
pur avendo già una conferma.
|
||||
|
||||
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
|
||||
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
|
||||
M13 — vedi sotto):
|
||||
|
||||
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
|
||||
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
|
||||
filtro sui convocati, solo partite/allenamenti).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
|
||||
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
|
||||
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
|
||||
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
|
||||
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
|
||||
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
|
||||
scritta per un evento senza convocazione **non conta comunque**, perché
|
||||
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
|
||||
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
|
||||
funzione pura che il badge consuma, non solo in UI.
|
||||
|
||||
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
|
||||
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
|
||||
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
|
||||
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
|
||||
pura.
|
||||
|
||||
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
|
||||
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
|
||||
convocati che non spezzano la serie di chi non era coinvolto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
|
||||
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
|
||||
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
|
||||
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
|
||||
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
|
||||
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
|
||||
non riparte da 1).
|
||||
|
||||
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
|
||||
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
|
||||
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
|
||||
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
|
||||
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
|
||||
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
|
||||
database, non possono garantire.
|
||||
|
||||
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
|
||||
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
|
||||
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
|
||||
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
|
||||
azzera), e un evento futuro che non entra ancora nel calcolo.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
|
||||
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
|
||||
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
|
||||
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
|
||||
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
|
||||
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
|
||||
|
||||
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
|
||||
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
|
||||
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
|
||||
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
|
||||
|
||||
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
|
||||
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
|
||||
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
|
||||
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
|
||||
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
|
||||
esattamente al confine (3 infortuni, 5 ritardi).
|
||||
|
||||
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
|
||||
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
|
||||
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
|
||||
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
|
||||
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
|
||||
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
|
||||
|
||||
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
|
||||
`cacche.test.ts` (già completo su `giornateTop`).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
|
||||
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
|
||||
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
|
||||
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
|
||||
|
||||
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
|
||||
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
|
||||
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
|
||||
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
|
||||
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
|
||||
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
|
||||
|
||||
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
|
||||
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
|
||||
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
|
||||
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
|
||||
`mvpVintiPerGiocatore()`/`mediePagelle()` → `statoBadge()` che userebbe l'app.
|
||||
|
||||
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
|
||||
segreto a combinare due statistiche indipendenti (`serieConferme()` e
|
||||
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
|
||||
|
||||
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
|
||||
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
|
||||
bastano".
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
|
||||
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
|
||||
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
|
||||
già accumulate (le due statistiche restano indipendenti anche a database).
|
||||
|
||||
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
|
||||
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
|
||||
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
|
||||
meccanismo:
|
||||
|
||||
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
|
||||
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
|
||||
vince in `m1` e `m2` → `{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
|
||||
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
|
||||
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
|
||||
mischiano in `badgeSocialVinti()`.
|
||||
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
|
||||
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
|
||||
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
|
||||
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
|
||||
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
|
||||
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
|
||||
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
|
||||
tocca il conteggio delle altre 4 nella stessa partita.
|
||||
|
||||
### Riepilogo per badge
|
||||
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
|
||||
---
|
||||
|
||||
## Problemi noti da sistemare
|
||||
|
||||
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
|
||||
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
|
||||
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
|
||||
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
|
||||
CHECK o FK verso i 5 id di `categorieSocial`
|
||||
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
|
||||
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
|
||||
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
||||
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
||||
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
|
||||
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
|
||||
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
|
||||
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
|
||||
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
@@ -63,11 +426,39 @@ badge assegnati per voto dai compagni.
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- 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.
|
||||
|
||||
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
|
||||
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
|
||||
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
|
||||
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
|
||||
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
|
||||
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
|
||||
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
|
||||
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
||||
conseguenza.
|
||||
|
||||
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
||||
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
||||
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
|
||||
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
|
||||
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
|
||||
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
|
||||
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
|
||||
|
||||
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
|
||||
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
|
||||
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
|
||||
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
|
||||
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
|
||||
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
|
||||
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
|
||||
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
|
||||
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
@@ -75,3 +466,7 @@ badge assegnati per voto dai compagni.
|
||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||
ora che le serie sono calcolate.
|
||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
|
||||
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Modulo — Calendario ed Eventi
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
|
||||
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
|
||||
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
|
||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
|
||||
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
|
||||
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
|
||||
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
|
||||
|
||||
## Due schermate, due pubblici
|
||||
|
||||
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
|
||||
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
|
||||
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
|
||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
|
||||
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
|
||||
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
|
||||
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
|
||||
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
|
||||
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
|
||||
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
|
||||
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
|
||||
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
|
||||
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
|
||||
|
||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
||||
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
|
||||
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||
`eventi_app`.
|
||||
|
||||
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
|
||||
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
|
||||
|
||||
## Lettura lato server
|
||||
|
||||
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
|
||||
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
|
||||
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
|
||||
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
|
||||
(`m14_pulizia_dati_evento_cancellato`,
|
||||
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
|
||||
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
|
||||
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
|
||||
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
|
||||
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
|
||||
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
|
||||
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
|
||||
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
|
||||
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
|
||||
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
|
||||
@@ -1,7 +1,8 @@
|
||||
# Modulo — Collegamento CSI
|
||||
|
||||
**Stato:** implementato (stagione 2025/26)
|
||||
**Route interessata:** `/classifica`
|
||||
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
|
||||
(dettaglio di una gara: formazioni e scontri diretti)
|
||||
|
||||
---
|
||||
|
||||
@@ -21,26 +22,134 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
|
||||
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
|
||||
API key, ma **non offrono alcuna garanzia di stabilità**.
|
||||
|
||||
| Endpoint | Formato | Uso |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
|
||||
|
||||
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
||||
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
||||
`project-last_results.php`, `team-roster.php`, `team-results.php`.
|
||||
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
|
||||
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
|
||||
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
|
||||
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
|
||||
un `project_id`), nessun codice le chiama direttamente.
|
||||
|
||||
### Identificativi (stagione 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` | `767` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
| Cosa | Valore |
|
||||
| --------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
|
||||
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||
`src/lib/csi-core.ts`.
|
||||
|
||||
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
|
||||
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
|
||||
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
|
||||
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
|
||||
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
|
||||
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
|
||||
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
|
||||
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
|
||||
|
||||
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
|
||||
`team_details.php` nel browser, questo componente basta.
|
||||
|
||||
### Endpoint usati dall'app
|
||||
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||
|
||||
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
|
||||
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
|
||||
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
|
||||
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
|
||||
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
|
||||
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
|
||||
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
|
||||
condivisa, nessun parser dedicato per la Coppa.
|
||||
|
||||
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
|
||||
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
|
||||
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
|
||||
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
|
||||
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
|
||||
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
|
||||
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
|
||||
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
|
||||
|
||||
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
|
||||
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
|
||||
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
|
||||
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
|
||||
(URL del referto ufficiale, `match_details.php?id=…`).
|
||||
|
||||
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
||||
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
||||
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
|
||||
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
|
||||
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
|
||||
`team-scorers.php`.
|
||||
|
||||
### Dettaglio di una singola gara (formazioni e scontri diretti)
|
||||
|
||||
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
|
||||
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
|
||||
`assets/js/match.js`:
|
||||
|
||||
| Endpoint | Formato | Uso |
|
||||
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
|
||||
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
|
||||
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
|
||||
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
|
||||
|
||||
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
|
||||
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
|
||||
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
|
||||
riepilogo aggregato — vedi sotto).
|
||||
|
||||
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
|
||||
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
|
||||
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
|
||||
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
|
||||
interpretata come un flag booleano.
|
||||
|
||||
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
|
||||
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
|
||||
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
|
||||
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
|
||||
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
|
||||
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
|
||||
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
|
||||
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
|
||||
(referto non ancora compilato o formato cambiato).
|
||||
|
||||
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
|
||||
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
|
||||
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
|
||||
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
|
||||
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
|
||||
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
|
||||
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
|
||||
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
|
||||
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
|
||||
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
|
||||
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
|
||||
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
|
||||
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
|
||||
identificabile).
|
||||
|
||||
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
|
||||
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
|
||||
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
|
||||
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,29 +158,84 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
|
||||
```
|
||||
CSI (portale)
|
||||
↓ fetch server-side, cache 6 ore
|
||||
/api/public/csi → src/routes/api/public/csi.ts
|
||||
↓ JSON { classifica, partite, girone, aggiornato }
|
||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
/api/public/csi → src/routes/api/public/csi.ts
|
||||
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
|
||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
/classifica → src/routes/classifica.tsx
|
||||
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
|
||||
sotto; tab "Storico partite": ogni squadra col proprio logo,
|
||||
chevron di dettaglio sulle gare cliccabili, gare divise
|
||||
per stagione; stagione anche nel sottotitolo dell'header)
|
||||
|
||||
CSI (portale, 3 endpoint)
|
||||
↓ fetch server-side on-demand, cache per-partita 6 ore
|
||||
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
|
||||
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
|
||||
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
|
||||
↓
|
||||
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
|
||||
```
|
||||
|
||||
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
|
||||
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
|
||||
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
|
||||
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
|
||||
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
|
||||
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
|
||||
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
|
||||
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
|
||||
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
|
||||
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
|
||||
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
|
||||
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
|
||||
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
|
||||
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
|
||||
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
|
||||
del girone.
|
||||
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
|
||||
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
|
||||
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
|
||||
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
|
||||
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
|
||||
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
|
||||
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
|
||||
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
|
||||
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
|
||||
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
|
||||
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
|
||||
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
|
||||
per riga, vedi "Regole rispettate" sotto).
|
||||
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
|
||||
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
|
||||
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
|
||||
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
|
||||
(formazioni + scontri diretti, monta `useCsiPartita()`).
|
||||
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
|
||||
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
|
||||
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
|
||||
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
|
||||
(`PartitaCsi.id`), non un evento CrAPP.
|
||||
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
|
||||
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
|
||||
prima, quando `csiMatch` esiste per quella data.
|
||||
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
|
||||
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
|
||||
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
|
||||
gara giocata.
|
||||
|
||||
### Regole rispettate
|
||||
|
||||
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
|
||||
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
|
||||
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
|
||||
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
|
||||
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
|
||||
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
|
||||
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
|
||||
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
|
||||
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
|
||||
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
|
||||
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
|
||||
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
|
||||
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
||||
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
||||
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
||||
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
|
||||
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
|
||||
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
|
||||
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
|
||||
|
||||
---
|
||||
@@ -83,18 +247,103 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
||||
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
|
||||
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
|
||||
a mano in `csi-core.ts`.
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
|
||||
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
|
||||
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
|
||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
|
||||
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
|
||||
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
|
||||
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
|
||||
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
|
||||
dati in una tabella Supabase riempita da un job cron (stesso pattern di
|
||||
`promemoria-palloni`).
|
||||
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
|
||||
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
|
||||
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
|
||||
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
|
||||
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
|
||||
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
|
||||
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
|
||||
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
|
||||
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
|
||||
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
|
||||
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
|
||||
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
|
||||
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
|
||||
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
|
||||
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
|
||||
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
|
||||
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
|
||||
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
|
||||
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
|
||||
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
|
||||
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
|
||||
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
|
||||
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
|
||||
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
|
||||
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
|
||||
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
|
||||
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
|
||||
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
|
||||
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
|
||||
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
|
||||
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
|
||||
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
|
||||
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
|
||||
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
|
||||
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
|
||||
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
|
||||
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
|
||||
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
|
||||
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
|
||||
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
|
||||
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
|
||||
pagina campionato molto meno. Il fix, quando succede, è isolato a
|
||||
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
|
||||
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
|
||||
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
|
||||
fixture nel nuovo formato.
|
||||
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
|
||||
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
|
||||
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
|
||||
errore SQL del loro backend in chiaro al posto del JSON
|
||||
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
|
||||
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
|
||||
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
|
||||
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
|
||||
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
|
||||
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
|
||||
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
|
||||
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
|
||||
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
|
||||
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
|
||||
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
|
||||
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
|
||||
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
|
||||
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
|
||||
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
|
||||
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
|
||||
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
|
||||
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
|
||||
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
|
||||
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
|
||||
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
|
||||
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
|
||||
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
|
||||
del CSI", non come cronaca esatta di chi ha giocato quanto.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
|
||||
- Creazione automatica degli eventi partita da calendario CSI.
|
||||
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
|
||||
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
|
||||
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
|
||||
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
|
||||
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
|
||||
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
|
||||
`project-sheets.php?project_id=848` invece di essere una costante.
|
||||
@@ -27,6 +27,12 @@ volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna
|
||||
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
|
||||
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
|
||||
|
||||
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
|
||||
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
|
||||
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
|
||||
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
|
||||
altro codice.
|
||||
|
||||
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.
|
||||
@@ -38,7 +44,8 @@ profilo che mostri il numero di infortuni come statistica di superficie.
|
||||
- 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.
|
||||
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
|
||||
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
|
||||
- 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.
|
||||
|
||||
+30
-12
@@ -1,6 +1,6 @@
|
||||
# Modulo — Votazione MVP
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
|
||||
---
|
||||
@@ -17,16 +17,29 @@ calcolato a runtime.
|
||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||
partita, sovrascrivibile.
|
||||
|
||||
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
|
||||
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
|
||||
restano nel database ma non vengono più letti da nessuna schermata).
|
||||
|
||||
---
|
||||
|
||||
## 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".
|
||||
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
|
||||
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
|
||||
quando aprirà. Nessun legame con il risultato caricato.
|
||||
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
|
||||
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
|
||||
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
|
||||
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
||||
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||
incoronare nessuno, nemmeno senza concorrenza.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
@@ -35,18 +48,23 @@ partita, sovrascrivibile.
|
||||
|
||||
## 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.
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
|
||||
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
|
||||
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||
partita.
|
||||
|
||||
---
|
||||
|
||||
## 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,244 @@
|
||||
# Modulo — Notifiche email
|
||||
|
||||
**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`)
|
||||
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
|
||||
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
|
||||
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
|
||||
(interruttore «Email»)
|
||||
|
||||
Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si
|
||||
diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in
|
||||
[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è
|
||||
registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app
|
||||
([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email
|
||||
**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque
|
||||
destinatario.
|
||||
|
||||
## Architettura
|
||||
|
||||
```
|
||||
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
|
||||
▲
|
||||
polling in uscita │ RPC con service role
|
||||
│
|
||||
Container Docker sull'host di casa (mailer)
|
||||
│ SMTP 587 (STARTTLS)
|
||||
▼
|
||||
smtp.gmail.com ──▶ casella Gmail del giocatore
|
||||
```
|
||||
|
||||
- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**:
|
||||
interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico,
|
||||
nessun tunnel.
|
||||
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima:
|
||||
le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e
|
||||
partono alla ripartenza: consegna in ritardo, mai persa.
|
||||
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron`
|
||||
compresi.
|
||||
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
|
||||
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
|
||||
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
|
||||
dell'app, che non guadagna nessuna dipendenza.
|
||||
- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato
|
||||
nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta
|
||||
entrambe (verificato sul progetto di produzione).
|
||||
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a
|
||||
3 ore, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||
**non** vengono inviate: il trigger non ha backfill.
|
||||
|
||||
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
|
||||
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
|
||||
arrivano anche per email (DD-038).
|
||||
|
||||
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
|
||||
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
|
||||
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
|
||||
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
|
||||
|
||||
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
|
||||
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
|
||||
`notifiche_utente`, da decidere a parte.
|
||||
|
||||
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
|
||||
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
|
||||
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
|
||||
|
||||
## Mittente
|
||||
|
||||
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
|
||||
una **password per app**. Come crearla e i problemi noti stanno in
|
||||
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
|
||||
|
||||
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
|
||||
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
|
||||
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
|
||||
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
|
||||
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
|
||||
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
|
||||
le mail finiscono in spam o vengono rifiutate.
|
||||
|
||||
## Dati
|
||||
|
||||
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
|
||||
funzionale.
|
||||
|
||||
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
|
||||
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
|
||||
anche la mail non ancora partita).
|
||||
|
||||
| Colonna | Note |
|
||||
| -------------------- | ----------------------------------------------------------------------- |
|
||||
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
|
||||
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
|
||||
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
|
||||
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
|
||||
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
|
||||
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
|
||||
|
||||
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
|
||||
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
|
||||
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
|
||||
legge e la scrive solo la service role, cioè il worker.
|
||||
|
||||
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
|
||||
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
|
||||
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
|
||||
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
|
||||
modificare solo in parte (DD-016).
|
||||
|
||||
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
|
||||
|
||||
| Funzione | Cosa fa |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
|
||||
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
|
||||
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
|
||||
|
||||
## Flusso
|
||||
|
||||
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
|
||||
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
|
||||
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
|
||||
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
|
||||
dei cron non toccano quella colonna e non accodano nulla.
|
||||
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
|
||||
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
|
||||
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
|
||||
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
|
||||
o con le email spente.
|
||||
4. Ogni invio registra l'esito con `esito_notifica_email`.
|
||||
|
||||
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
|
||||
ferma anche le mail già in coda.
|
||||
|
||||
## Affidabilità
|
||||
|
||||
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
|
||||
|
||||
| Tipo | Esempi | Cosa succede |
|
||||
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
|
||||
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
|
||||
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
|
||||
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
|
||||
|
||||
Altre situazioni:
|
||||
|
||||
| Situazione | Comportamento |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
|
||||
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
|
||||
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
|
||||
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
|
||||
|
||||
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
|
||||
messaggi che l'account dedicato potesse mandare per altre vie.
|
||||
|
||||
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
|
||||
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
|
||||
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
|
||||
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
|
||||
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
|
||||
giornaliero è normale e resta sano.
|
||||
|
||||
## Contenuto delle mail
|
||||
|
||||
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
|
||||
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
|
||||
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
|
||||
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
|
||||
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
|
||||
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
|
||||
|
||||
## Interfaccia
|
||||
|
||||
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
|
||||
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
|
||||
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
|
||||
Nessuna schermata nuova.
|
||||
|
||||
## Privacy e sicurezza
|
||||
|
||||
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
|
||||
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
|
||||
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
|
||||
Vercel.
|
||||
- La password per app si può revocare da Google senza toccare l'account.
|
||||
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
|
||||
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
|
||||
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
|
||||
per la push (nessuna preferenza per tipo).
|
||||
|
||||
## Test
|
||||
|
||||
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
|
||||
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
|
||||
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
|
||||
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
|
||||
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
|
||||
«acceso» senza riga.
|
||||
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
|
||||
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
|
||||
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
|
||||
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
|
||||
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
|
||||
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
|
||||
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
|
||||
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
|
||||
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
|
||||
una.
|
||||
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
|
||||
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
|
||||
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
|
||||
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
|
||||
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
|
||||
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
|
||||
che `notifiche_utente` ha una riga per `giocatore_id`.
|
||||
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
|
||||
`inviata` non si toccano.
|
||||
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
|
||||
spariscono quando la notifica viene eliminata.
|
||||
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
|
||||
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
|
||||
restano in spam o si supera il tetto di Gmail.
|
||||
- Riepilogo settimanale unico al posto di una mail per notifica.
|
||||
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
|
||||
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
|
||||
+275
-13
@@ -1,9 +1,11 @@
|
||||
# Modulo — Notifiche
|
||||
|
||||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
|
||||
**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-subscribe.ts`, `public/push-sw.js`
|
||||
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
|
||||
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
|
||||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
|
||||
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||||
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
|
||||
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
|
||||
|
||||
---
|
||||
|
||||
@@ -12,10 +14,154 @@
|
||||
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).
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||
|
||||
---
|
||||
|
||||
## Catalogo delle notifiche
|
||||
|
||||
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
|
||||
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
|
||||
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | **Promemoria a 24 ore** — «Promemoria evento di _domani_ (o _oggi_): _titolo_», con data, ora e luogo | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
|
||||
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **12, 6 e 3 ore prima** dell'evento (DD-040): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipi `turno_palloni_12h`, `_6h` e `_3h`, push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, in tre tempi (DD-040): **24, 12 e 6 ore** prima dell'evento, dallo stesso job; tipi `sollecita_presenze_24h`, `_12h`, `_6h`, push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (1, 2, 4 e 5) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
|
||||
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta
|
||||
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante.
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
### Testi delle notifiche
|
||||
|
||||
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
|
||||
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
|
||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
|
||||
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
|
||||
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
|
||||
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
|
||||
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
|
||||
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
|
||||
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
|
||||
|
||||
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
|
||||
dall'emoji. Non entrano nel centro notifiche e non generano email.
|
||||
|
||||
| Situazione | Titolo | Corpo |
|
||||
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
|
||||
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
|
||||
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
|
||||
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
|
||||
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
|
||||
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
|
||||
|
||||
### Cosa non genera nessuna notifica
|
||||
|
||||
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
|
||||
dell'inizio (DD-030).
|
||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||
- Assenze e infortuni.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
|
||||
### Comportamenti da conoscere
|
||||
|
||||
Sono l'effetto attuale della logica, non scelte documentate altrove:
|
||||
|
||||
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
|
||||
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
|
||||
allenamenti e partite.
|
||||
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
|
||||
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
|
||||
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
|
||||
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
|
||||
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
|
||||
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
|
||||
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
|
||||
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
|
||||
dell'evento.
|
||||
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
|
||||
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
|
||||
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
|
||||
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
|
||||
perché il registro ricorda di averle già generate.
|
||||
- **Un evento già iniziato non genera più promemoria.**
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
|
||||
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
|
||||
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
|
||||
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
|
||||
- **I solleciti cadono in finestre che non si sovrappongono** (DD-040): 24 ore tra 24 e 12 ore
|
||||
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore
|
||||
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo
|
||||
smette di riceverli; chi resta su «forse» li riceve tutti e tre.
|
||||
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due
|
||||
promemoria e tre solleciti, più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 12·6·3 ore e revoca, solleciti a 24·12·6 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`) | `integration/notifiche-automatiche.test.ts` (nuovo), `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
|
||||
codice; l'unico pezzo puro, `inRosa`, è coperto.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
@@ -55,14 +201,15 @@ copre in particolare le sessioni lunghe della webapp (vedi il
|
||||
|
||||
- **`push-config`** — espone la sola chiave pubblica VAPID.
|
||||
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
|
||||
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i
|
||||
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
|
||||
[Scout Live](scout-live.md)).
|
||||
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
|
||||
iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
|
||||
`notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
|
||||
(DD-038; vedi [Scout Live](scout-live.md)).
|
||||
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
|
||||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts` .
|
||||
|
||||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||||
@@ -81,10 +228,27 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
|
||||
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
|
||||
variabile d'ambiente.
|
||||
|
||||
| Route | Controllo | Chi la chiama |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
| Route | Controllo | Chi la chiama |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
|
||||
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
|
||||
|
||||
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
|
||||
anche per l'allenatore, `richiediGestoreEventi()`).
|
||||
|
||||
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
|
||||
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
|
||||
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
|
||||
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
|
||||
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
|
||||
l'ha ancora attivata.
|
||||
|
||||
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
|
||||
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
|
||||
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
|
||||
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
|
||||
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
|
||||
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
|
||||
|
||||
---
|
||||
|
||||
@@ -96,8 +260,106 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto.
|
||||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
|
||||
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
|
||||
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
|
||||
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
|
||||
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
|
||||
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
---
|
||||
|
||||
## Centro notifiche in-app (M17)
|
||||
|
||||
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
|
||||
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
|
||||
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
|
||||
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
|
||||
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
|
||||
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
|
||||
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
|
||||
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
|
||||
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
|
||||
|
||||
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
|
||||
job `pg_cron` come la 2, che scrive `turno_palloni_12h`/`_6h`/`_3h`/`_revocato` e `sollecita_presenze_24h`/
|
||||
`_12h`/`_6h`; i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
|
||||
|
||||
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
|
||||
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
|
||||
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
|
||||
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
|
||||
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
|
||||
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
|
||||
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
|
||||
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
|
||||
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
|
||||
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
|
||||
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
|
||||
da `avvisiPalloniEvento()`, in parallelo alla push.
|
||||
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
|
||||
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
|
||||
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
|
||||
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
|
||||
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
|
||||
|
||||
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
|
||||
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
|
||||
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
|
||||
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
|
||||
|
||||
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
|
||||
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
|
||||
|
||||
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
|
||||
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
|
||||
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
|
||||
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
|
||||
separato.
|
||||
|
||||
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
|
||||
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
|
||||
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
|
||||
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
|
||||
ritrova per sempre nell'elenco, solo segnata come letta.
|
||||
|
||||
## Push dei promemoria (M24)
|
||||
|
||||
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una
|
||||
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
|
||||
DD-038).
|
||||
|
||||
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
|
||||
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
|
||||
dall'app, e una seconda sarebbe un doppione).
|
||||
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
|
||||
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
|
||||
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
|
||||
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
|
||||
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
|
||||
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
|
||||
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
|
||||
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
|
||||
route.
|
||||
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
|
||||
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
|
||||
|
||||
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
|
||||
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
|
||||
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
|
||||
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
|
||||
promemoria.
|
||||
|
||||
## Canale email (M22)
|
||||
|
||||
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
|
||||
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
|
||||
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
|
||||
indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
|
||||
scadenza resta una card in Home e non genera mail (DD-035).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Modulo — Obiettivi di squadra
|
||||
|
||||
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
|
||||
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
|
||||
---
|
||||
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
|
||||
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
|
||||
serie).
|
||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
|
||||
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
|
||||
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
|
||||
ordina mettendo i completati in coda e gli altri per progresso decrescente.
|
||||
|
||||
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
|
||||
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
|
||||
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
|
||||
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
|
||||
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
|
||||
cancellazione: risolto a livello database con un trigger (migration
|
||||
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
|
||||
|
||||
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
|
||||
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
|
||||
corrente dinamico (vedi sotto).
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| Obiettivo | Calcolo | Target | Fonte |
|
||||
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
|
||||
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
|
||||
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
|
||||
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
|
||||
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
|
||||
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
|
||||
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
|
||||
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`).
|
||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
## Obiettivi mensili — mese dinamico
|
||||
|
||||
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
|
||||
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
|
||||
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
|
||||
squadra risponde alle convocazioni, non solo la presenza.
|
||||
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
|
||||
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
|
||||
sono "congelati" su un mese già passato.
|
||||
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
|
||||
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
|
||||
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
|
||||
ogni stagione.
|
||||
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
|
||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||
seguono di conseguenza.
|
||||
|
||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
## Continuità di squadra — il target 12 è il minimo per un 6vs6
|
||||
|
||||
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
|
||||
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
|
||||
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
|
||||
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
|
||||
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
|
||||
|
||||
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
|
||||
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
|
||||
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
|
||||
|
||||
---
|
||||
|
||||
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
|
||||
|
||||
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
|
||||
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
|
||||
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
|
||||
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
|
||||
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
|
||||
proprio target, nemmeno con più vittorie di quante ne servano.
|
||||
|
||||
### Il limite: il parsing del JSON può rompersi in silenzio
|
||||
|
||||
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
|
||||
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
|
||||
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
|
||||
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
|
||||
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
|
||||
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
|
||||
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
|
||||
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
|
||||
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
|
||||
comunque `200` con `partite: []`.
|
||||
|
||||
### Come è mitigato oggi
|
||||
|
||||
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
|
||||
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
|
||||
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
|
||||
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
|
||||
anche loro).
|
||||
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
|
||||
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
|
||||
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
|
||||
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
|
||||
|
||||
### Come fixarlo, se succede
|
||||
|
||||
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
|
||||
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
|
||||
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
|
||||
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
|
||||
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
|
||||
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
|
||||
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
|
||||
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
|
||||
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
|
||||
restano verdi contro un formato che non esiste più.
|
||||
|
||||
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
|
||||
[Collegamento CSI](collegamento-csi.md).
|
||||
|
||||
---
|
||||
|
||||
## Copertura test
|
||||
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||
(evento dentro/fuori mese), scadenza dinamica.
|
||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
|
||||
arrotondata e conteggio.
|
||||
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
|
||||
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
|
||||
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
|
||||
verifica cap e target su dati reali.
|
||||
|
||||
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
|
||||
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
|
||||
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
|
||||
+25
-12
@@ -1,6 +1,6 @@
|
||||
# Modulo — Pagelle
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
||||
|
||||
---
|
||||
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
|
||||
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
|
||||
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
|
||||
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
|
||||
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
|
||||
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
|
||||
`squadra.tsx`.
|
||||
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
|
||||
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
|
||||
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
|
||||
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
|
||||
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
|
||||
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
|
||||
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
|
||||
|
||||
---
|
||||
|
||||
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
|
||||
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
||||
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
||||
voto in UI.
|
||||
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
|
||||
`evento_permette_voto()`, `pagelle_voti`).
|
||||
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
|
||||
database, non solo in UI (stessa RLS di sopra).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
|
||||
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
|
||||
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
||||
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
||||
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
||||
- Nessun controllo a livello database che il votante sia realmente un convocato della
|
||||
partita: solo filtro applicativo.
|
||||
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
|
||||
coincide con quel voto.
|
||||
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
|
||||
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
|
||||
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
|
||||
considerarla — profilo e squadra no.
|
||||
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
|
||||
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
|
||||
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
||||
- Far rispettare `pagelleChiuse` anche via RLS.
|
||||
+40
-14
@@ -1,6 +1,6 @@
|
||||
# Modulo — Palloni
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
||||
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
||||
`src/routes/api/public/promemoria-palloni.ts`
|
||||
@@ -10,8 +10,9 @@
|
||||
## 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.
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
notifiche e senza email a 12, 6 e 3 ore dall'evento (DD-040). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
compleanni non c'entrano: non richiedono palloni.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,16 +26,28 @@ compaiono.
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra 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. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
|
||||
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
|
||||
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** 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. Gli **allenamenti**
|
||||
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
|
||||
incaricato in `TurnoPalloni` (scelta della squadra).
|
||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||
upsert su `evento_id`.
|
||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
|
||||
confermate** (partite/eventi) — non è uno storico in tabella dedicata.
|
||||
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
|
||||
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
|
||||
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
|
||||
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
|
||||
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
|
||||
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
|
||||
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
|
||||
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
|
||||
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
|
||||
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
|
||||
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
|
||||
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
||||
è il banner in Home per il giocatore di turno.
|
||||
|
||||
@@ -42,6 +55,13 @@ compaiono.
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
|
||||
> `pg_cron` ogni 15 minuti manda l'avviso **12, 6 e 3 ore prima** dell'evento, solo se per l'evento c'è un
|
||||
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
|
||||
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
|
||||
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
|
||||
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
|
||||
|
||||
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
|
||||
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
|
||||
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
|
||||
@@ -56,13 +76,19 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
|
||||
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
|
||||
possono contare turni mai effettivamente convalidati da nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
|
||||
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
|
||||
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
|
||||
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
|
||||
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
|
||||
dedicato in `palloni-core.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+41
-15
@@ -1,8 +1,8 @@
|
||||
# Modulo — Presenze
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
||||
`src/routes/api/public/sollecita-presenze.ts`
|
||||
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -24,7 +24,11 @@ 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".
|
||||
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
|
||||
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
|
||||
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
|
||||
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
|
||||
(`RosaPresenze`, prop `tipo`).
|
||||
|
||||
---
|
||||
|
||||
@@ -40,25 +44,46 @@ risposte_presenze (Supabase)
|
||||
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
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
|
||||
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
|
||||
partita.$id.tsx / evento.$id.tsx
|
||||
|
||||
EventoCard → src/components/crapp/EventoCard.tsx
|
||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||
|
||||
--- statistiche ---
|
||||
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
|
||||
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
||||
(percentuale ultimi 30gg, da cache già in memoria)
|
||||
|
||||
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
|
||||
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||
|
||||
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
|
||||
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
|
||||
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
|
||||
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
|
||||
--- sollecito (solo admin) ---
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
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"
|
||||
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
|
||||
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
|
||||
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
|
||||
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **24, 12 e 6 ore prima** dell'evento a chi in
|
||||
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
|
||||
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
|
||||
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
|
||||
|
||||
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`.
|
||||
@@ -69,25 +94,26 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
- 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.
|
||||
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
(DD-023). Verificato da `test/integration/permessi.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 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)`).
|
||||
- La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
|
||||
finestra di chiusura, né in UI né in RLS.
|
||||
- `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.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Modulo — Profilo Giocatore
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
|
||||
`src/routes/admin.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
||||
@@ -8,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
|
||||
|
||||
## Utenti
|
||||
|
||||
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
|
||||
la specifica sta in [allenatore.md](allenatore.md).
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
@@ -29,7 +38,8 @@ Può:
|
||||
- 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)
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
|
||||
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
|
||||
- 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
|
||||
@@ -54,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
@@ -70,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035).
|
||||
|
||||
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||
già salvata: niente tabella, niente migration, niente push.
|
||||
|
||||
#### Chi lo vede
|
||||
|
||||
| Utente | Cosa vede | Al tocco |
|
||||
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
|
||||
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
|
||||
| Altri giocatori | niente | — |
|
||||
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||
|
||||
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||
nuovo, come il widget «Completa il tuo profilo».
|
||||
|
||||
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||
solo proprio a un giocatore.
|
||||
|
||||
#### Quando compare
|
||||
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
|
||||
`GIORNI_AVVISO_CERTIFICATO = 7`.
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| -------------------- | ----------- | ------------------ |
|
||||
| `giorni > 7` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | nessuno |
|
||||
|
||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
||||
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
#### Testi
|
||||
|
||||
| Avviso | Titolo (staff / personale) | Riga |
|
||||
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||
|
||||
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
|
||||
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||
scadono molti insieme.
|
||||
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||
nero, poi quello giallo.
|
||||
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||
|
||||
#### Aspetto e posizione
|
||||
|
||||
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||
di confondersi con lo sfondo.
|
||||
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||
ciò che scade oggi, poi ciò che manca.
|
||||
|
||||
#### Rinnovo
|
||||
|
||||
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||
|
||||
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||
data, chiunque l'abbia scritta.
|
||||
|
||||
#### Caricamento
|
||||
|
||||
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||
la Home non deve rompersi per un dato accessorio.
|
||||
|
||||
#### Implementazione
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||
|
||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||
i profili.
|
||||
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
|
||||
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||
di anno nel calcolo dei giorni.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
@@ -85,7 +217,11 @@ Il profilo viene suddiviso in sette aree.
|
||||
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita
|
||||
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
|
||||
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
|
||||
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
|
||||
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
|
||||
anche quel valore pubblico.
|
||||
- Luogo di nascita
|
||||
- Indirizzo di residenza
|
||||
- Telefono
|
||||
@@ -118,6 +254,10 @@ Upload.
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home e compare in quello degli admin (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
@@ -159,9 +299,20 @@ Contiene.
|
||||
## Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Opzioni).
|
||||
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
|
||||
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
|
||||
c'è almeno un giocatore disattivato) e Notifiche.
|
||||
|
||||
Per ogni giocatore vengono mostrati.
|
||||
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
|
||||
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
|
||||
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
|
||||
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||
di altri.
|
||||
|
||||
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
|
||||
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
|
||||
|
||||
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
@@ -230,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
@@ -244,6 +398,7 @@ Non carica file al posto di altri.
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Modulo — Scout Live
|
||||
|
||||
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
|
||||
**Stato:** implementato (fix M7 per la persistenza condivisa)
|
||||
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
||||
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
||||
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
**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`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
||||
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +60,18 @@ UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo
|
||||
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
|
||||
per le conferme.
|
||||
|
||||
Le due colonne si confondono facilmente, e sbagliarle non rompe niente di visibile: la serie
|
||||
comincia solo a raccontare il falso. Per questo il confine è verificato in
|
||||
`test/integration/scritture.test.ts` («la risposta di presenza si aggiorna senza far ripartire
|
||||
il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto_il` e controlla
|
||||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | ----------------------- |
|
||||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||
|
||||
@@ -102,8 +116,11 @@ ordine:
|
||||
- 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).
|
||||
2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
|
||||
Il confronto è **stretto**: l'evento di oggi non conta ancora, perché nessuno ha potuto
|
||||
presentarsi e conterebbe come assenza, azzerando la serie di tutta la squadra la mattina
|
||||
della partita. Entra in gioco dal giorno dopo. Il parametro `oggi` è iniettabile — di
|
||||
default `dataOggi()` — e i test lo fissano a una data per non dipendere dall'orologio.
|
||||
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**.
|
||||
@@ -121,9 +138,10 @@ Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili so
|
||||
|
||||
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.
|
||||
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
|
||||
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
|
||||
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
|
||||
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
|
||||
- **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.
|
||||
@@ -265,9 +283,10 @@ il costo diventerebbe per-render e andrebbe stabilizzata a monte.
|
||||
|
||||
- **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.
|
||||
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
|
||||
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
|
||||
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
|
||||
assenza ai fini statistici.
|
||||
- **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".
|
||||
@@ -298,8 +317,14 @@ nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non determini
|
||||
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".
|
||||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Modulo — Squadra
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
|
||||
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
|
||||
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
|
||||
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
|
||||
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
|
||||
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
|
||||
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
|
||||
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
---
|
||||
|
||||
## Due letture diverse, per non pagare due volte lo stesso costo
|
||||
|
||||
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
|
||||
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
|
||||
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita
|
||||
viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`,
|
||||
DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il
|
||||
giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null`
|
||||
finché non è stata inserita da nessuno.
|
||||
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
|
||||
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
|
||||
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
|
||||
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
|
||||
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
|
||||
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
|
||||
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
|
||||
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
|
||||
(`obiettivi-squadra.md`).
|
||||
|
||||
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
|
||||
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
|
||||
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
|
||||
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
||||
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
|
||||
|
||||
## Allenatori in Squadra
|
||||
|
||||
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
|
||||
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
|
||||
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
|
||||
specifica completa sta in [allenatore.md](allenatore.md).
|
||||
|
||||
## Gestione dati squadra (solo amministratore)
|
||||
|
||||
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
|
||||
|
||||
| Azione | Hook | Effetto |
|
||||
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
|
||||
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
|
||||
dell'admin.
|
||||
|
||||
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
|
||||
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
|
||||
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
|
||||
all'amministratore.
|
||||
|
||||
## Classifica interna di Squadra
|
||||
|
||||
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
|
||||
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
|
||||
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
|
||||
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
|
||||
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
|
||||
pagella" per il criterio media voto, non sempre "presenze consecutive").
|
||||
|
||||
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
|
||||
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
|
||||
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
|
||||
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
|
||||
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
|
||||
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
|
||||
quella reale.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Copia in .env (permessi 600) e compila. Il .env non va mai committato.
|
||||
|
||||
# Progetto Supabase e chiave service role (salta la RLS: trattala come una password)
|
||||
SUPABASE_URL=https://<project-ref>.supabase.co
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Account Gmail dedicato con verifica in due passaggi e una password per app
|
||||
SMTP_USER=crapp.notifiche@gmail.com
|
||||
SMTP_PASS=
|
||||
|
||||
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
|
||||
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# VAPID_SUBJECT=mailto:tuo@indirizzo
|
||||
|
||||
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
|
||||
APP_URL=https://<dominio-dell-app>
|
||||
|
||||
# Facoltative (valori di default)
|
||||
# MAIL_FROM_NAME=CrAPP
|
||||
# MAIL_FROM= # default: SMTP_USER
|
||||
# POLL_SECONDI=30
|
||||
# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail
|
||||
# MAIL_LOTTO=10
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false # true solo per la porta 465
|
||||
@@ -0,0 +1,11 @@
|
||||
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
|
||||
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
|
||||
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
|
||||
FROM oven/bun:1-alpine
|
||||
WORKDIR /app/mailer
|
||||
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
|
||||
RUN bun install --frozen-lockfile --production
|
||||
COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
|
||||
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
|
||||
USER bun
|
||||
CMD ["bun", "mailer.mjs"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
|
||||
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
|
||||
# nel contesto né il .env né il resto del progetto.
|
||||
*
|
||||
!mailer/package.json
|
||||
!mailer/bun.lock
|
||||
!mailer/bunfig.toml
|
||||
!mailer/mailer.mjs
|
||||
!mailer/mailer-core.ts
|
||||
!mailer/healthcheck.mjs
|
||||
!src/lib/webpush.server.ts
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "crapp-mailer",
|
||||
"dependencies": {
|
||||
"nodemailer": "^10.0.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[install]
|
||||
saveTextLockfile = true
|
||||
# 24h supply-chain guard: skip package versions published less than a day ago.
|
||||
minimumReleaseAge = 86400
|
||||
# Each entry bypasses the 24h guard for one package — confirm with the user
|
||||
# before adding any.
|
||||
minimumReleaseAgeExcludes = []
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
mailer:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: mailer/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD", "bun", "healthcheck.mjs"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,11 @@
|
||||
// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs).
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { staSano } from "./mailer-core.ts";
|
||||
|
||||
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
|
||||
const MAX_MS = 5 * 60_000;
|
||||
|
||||
const ultimo = await readFile(FILE_SALUTE, "utf8")
|
||||
.then((t) => Number(t))
|
||||
.catch(() => null);
|
||||
process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1);
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare:
|
||||
* configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il
|
||||
* ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla.
|
||||
*/
|
||||
|
||||
/** Riga restituita da `prendi_notifiche_email()`. */
|
||||
export type NotificaEmail = {
|
||||
id_notifica: string;
|
||||
destinatario: string;
|
||||
oggetto: string;
|
||||
testo: string;
|
||||
id_evento: string | null;
|
||||
tipo_notifica: string;
|
||||
tentativi_fatti: number;
|
||||
};
|
||||
|
||||
export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita";
|
||||
|
||||
export type MailComposta = {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
// --- Configurazione ---------------------------------------------------------------------------
|
||||
|
||||
export type Config = {
|
||||
supabaseUrl: string;
|
||||
serviceRoleKey: string;
|
||||
smtp: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string } | null;
|
||||
};
|
||||
from: string;
|
||||
fromName: string;
|
||||
appUrl: string;
|
||||
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
|
||||
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
|
||||
pollSecondi: number;
|
||||
limiteGiorno: number;
|
||||
lotto: number;
|
||||
};
|
||||
|
||||
export type LetturaConfig = { config: Config } | { errori: string[] };
|
||||
|
||||
/**
|
||||
* Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti
|
||||
* e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o
|
||||
* entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit).
|
||||
*/
|
||||
export function leggiConfig(env: Record<string, string | undefined>): LetturaConfig {
|
||||
const errori: string[] = [];
|
||||
const testo = (nome: string): string => (env[nome] ?? "").trim();
|
||||
const obbligatoria = (nome: string): string => {
|
||||
const valore = testo(nome);
|
||||
if (!valore) errori.push(`${nome} mancante`);
|
||||
return valore;
|
||||
};
|
||||
const intero = (nome: string, predefinito: number): number => {
|
||||
const grezzo = testo(nome);
|
||||
if (!grezzo) return predefinito;
|
||||
const n = Number(grezzo);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
errori.push(`${nome} deve essere un intero positivo`);
|
||||
return predefinito;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, "");
|
||||
const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY");
|
||||
const appUrl = obbligatoria("APP_URL").replace(/\/+$/, "");
|
||||
const user = testo("SMTP_USER");
|
||||
const pass = testo("SMTP_PASS");
|
||||
if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme");
|
||||
const from = testo("MAIL_FROM") || user;
|
||||
if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)");
|
||||
const vapidPubblica = testo("VAPID_PUBLIC_KEY");
|
||||
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
|
||||
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
|
||||
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
|
||||
}
|
||||
const porta = intero("SMTP_PORT", 587);
|
||||
const pollSecondi = intero("POLL_SECONDI", 30);
|
||||
const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400);
|
||||
const lotto = intero("MAIL_LOTTO", 10);
|
||||
|
||||
if (errori.length > 0) return { errori };
|
||||
return {
|
||||
config: {
|
||||
supabaseUrl,
|
||||
serviceRoleKey,
|
||||
smtp: {
|
||||
host: testo("SMTP_HOST") || "smtp.gmail.com",
|
||||
port: porta,
|
||||
secure: testo("SMTP_SECURE") === "true",
|
||||
auth: user ? { user, pass } : null,
|
||||
},
|
||||
from,
|
||||
fromName: testo("MAIL_FROM_NAME") || "CrAPP",
|
||||
appUrl,
|
||||
vapid:
|
||||
vapidPubblica && vapidPrivata
|
||||
? {
|
||||
publicKey: vapidPubblica,
|
||||
privateKey: vapidPrivata,
|
||||
subject: testo("VAPID_SUBJECT") || null,
|
||||
}
|
||||
: null,
|
||||
pollSecondi,
|
||||
limiteGiorno,
|
||||
lotto,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Errori SMTP e backoff --------------------------------------------------------------------
|
||||
|
||||
export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite";
|
||||
|
||||
type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown };
|
||||
|
||||
/**
|
||||
* Distingue cosa fare dopo un invio fallito:
|
||||
* - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che
|
||||
* sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo;
|
||||
* - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma;
|
||||
* - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare;
|
||||
* - `temporaneo`: 4xx e errori di rete, si riprova con backoff.
|
||||
*/
|
||||
export function classificaErroreSmtp(errore: unknown): TipoErrore {
|
||||
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
|
||||
const codice = typeof e.code === "string" ? e.code : "";
|
||||
const risposta = typeof e.responseCode === "number" ? e.responseCode : 0;
|
||||
const testo = `${typeof e.response === "string" ? e.response : ""} ${
|
||||
typeof e.message === "string" ? e.message : ""
|
||||
}`.toLowerCase();
|
||||
|
||||
if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite";
|
||||
if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione";
|
||||
if (codice === "EENVELOPE" || risposta >= 500) return "permanente";
|
||||
return "temporaneo";
|
||||
}
|
||||
|
||||
const RITARDI_MINUTI = [1, 5, 30, 120] as const;
|
||||
|
||||
/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */
|
||||
export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1;
|
||||
|
||||
/**
|
||||
* Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i
|
||||
* tentativi sono finiti e la mail va segnata come fallita.
|
||||
*/
|
||||
export function ritardoRiprova(tentativiFatti: number): number | null {
|
||||
return RITARDI_MINUTI[tentativiFatti] ?? null;
|
||||
}
|
||||
|
||||
/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */
|
||||
export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number {
|
||||
return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h));
|
||||
}
|
||||
|
||||
/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */
|
||||
export function descriviErrore(errore: unknown): string {
|
||||
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
|
||||
const parti = [
|
||||
typeof e.code === "string" ? e.code : "",
|
||||
typeof e.responseCode === "number" ? String(e.responseCode) : "",
|
||||
].filter(Boolean);
|
||||
return parti.length > 0 ? parti.join(" ") : "errore sconosciuto";
|
||||
}
|
||||
|
||||
// --- Composizione della mail ------------------------------------------------------------------
|
||||
|
||||
export function escapeHtml(testo: string): string {
|
||||
return testo
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */
|
||||
export function pulisciOggetto(titolo: string): string {
|
||||
const pulito = titolo
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return (pulito || "Notifica CrAPP").slice(0, 200);
|
||||
}
|
||||
|
||||
/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */
|
||||
export function linkNotifica(appUrl: string, idEvento: string | null): string {
|
||||
const base = appUrl.replace(/\/+$/, "");
|
||||
return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base;
|
||||
}
|
||||
|
||||
/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */
|
||||
export function messageId(idNotifica: string, dominio: string): string {
|
||||
return `<${idNotifica}@${dominio}>`;
|
||||
}
|
||||
|
||||
const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email.";
|
||||
|
||||
export function componiMail(
|
||||
n: NotificaEmail,
|
||||
opzioni: { appUrl: string; dominioMessageId: string },
|
||||
): MailComposta {
|
||||
const link = linkNotifica(opzioni.appUrl, n.id_evento);
|
||||
const subject = pulisciOggetto(n.oggetto);
|
||||
const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const corpoHtml = n.testo.trim()
|
||||
? `<p style="margin:0 0 16px">${escapeHtml(n.testo.trim()).replace(/\n/g, "<br>")}</p>`
|
||||
: "";
|
||||
const html = [
|
||||
`<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:16px;line-height:1.5;color:#111;max-width:480px">`,
|
||||
`<h2 style="margin:0 0 12px;font-size:18px">${escapeHtml(subject)}</h2>`,
|
||||
corpoHtml,
|
||||
`<p style="margin:0 0 24px"><a href="${escapeHtml(link)}" style="display:inline-block;padding:12px 20px;border-radius:12px;background:#111;color:#fff;text-decoration:none">Apri CrAPP</a></p>`,
|
||||
`<p style="margin:0;font-size:12px;color:#666">${escapeHtml(AVVISO_DISATTIVAZIONE)}</p>`,
|
||||
`</div>`,
|
||||
].join("");
|
||||
return {
|
||||
to: n.destinatario,
|
||||
subject,
|
||||
text: testo,
|
||||
html,
|
||||
messageId: messageId(n.id_notifica, opzioni.dominioMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Salute del container ---------------------------------------------------------------------
|
||||
|
||||
/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */
|
||||
export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean {
|
||||
return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs;
|
||||
}
|
||||
|
||||
// --- Ciclo di invio ---------------------------------------------------------------------------
|
||||
|
||||
export type Dipendenze = {
|
||||
prendi: (max: number) => Promise<NotificaEmail[]>;
|
||||
segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise<void>;
|
||||
invia: (mail: MailComposta) => Promise<void>;
|
||||
inviateUltime24h: () => Promise<number>;
|
||||
adesso: () => Date;
|
||||
log: (messaggio: string) => void;
|
||||
};
|
||||
|
||||
export type OpzioniCiclo = {
|
||||
limiteGiorno: number;
|
||||
lotto: number;
|
||||
appUrl: string;
|
||||
dominioMessageId: string;
|
||||
};
|
||||
|
||||
export type RisultatoCiclo = {
|
||||
inviate: number;
|
||||
riprovate: number;
|
||||
fallite: number;
|
||||
differite: number;
|
||||
/** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */
|
||||
fermato: "limite" | "configurazione" | null;
|
||||
};
|
||||
|
||||
const ATTESA_LIMITE_MINUTI = 60;
|
||||
const ATTESA_CONFIGURAZIONE_MINUTI = 15;
|
||||
|
||||
const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000);
|
||||
|
||||
/**
|
||||
* Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una.
|
||||
* I log riportano solo id, tipo ed esito: mai indirizzi né testi.
|
||||
*/
|
||||
export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<RisultatoCiclo> {
|
||||
const risultato: RisultatoCiclo = {
|
||||
inviate: 0,
|
||||
riprovate: 0,
|
||||
fallite: 0,
|
||||
differite: 0,
|
||||
fermato: null,
|
||||
};
|
||||
|
||||
const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto);
|
||||
if (consentite === 0) {
|
||||
risultato.fermato = "limite";
|
||||
return risultato;
|
||||
}
|
||||
|
||||
const lotto = await d.prendi(consentite);
|
||||
for (const [indice, notifica] of lotto.entries()) {
|
||||
const mail = componiMail(notifica, o);
|
||||
// Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e
|
||||
// non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene
|
||||
// recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita).
|
||||
let errore: unknown;
|
||||
let consegnata = false;
|
||||
try {
|
||||
await d.invia(mail);
|
||||
consegnata = true;
|
||||
} catch (e) {
|
||||
errore = e;
|
||||
}
|
||||
|
||||
if (consegnata) {
|
||||
await d.segna(notifica.id_notifica, "inviata");
|
||||
risultato.inviate += 1;
|
||||
d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tipo = classificaErroreSmtp(errore);
|
||||
const motivo = descriviErrore(errore);
|
||||
|
||||
if (tipo === "limite" || tipo === "configurazione") {
|
||||
// Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza
|
||||
// consumare tentativi, e il ciclo si ferma.
|
||||
const prossimo = dopoMinuti(
|
||||
d.adesso(),
|
||||
tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI,
|
||||
);
|
||||
for (const restante of lotto.slice(indice)) {
|
||||
await d.segna(restante.id_notifica, "differita", motivo, prossimo);
|
||||
risultato.differite += 1;
|
||||
}
|
||||
risultato.fermato = tipo;
|
||||
d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`);
|
||||
return risultato;
|
||||
}
|
||||
|
||||
const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null;
|
||||
if (ritardo === null) {
|
||||
await d.segna(notifica.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`);
|
||||
} else {
|
||||
await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
|
||||
risultato.riprovate += 1;
|
||||
d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`);
|
||||
}
|
||||
}
|
||||
return risultato;
|
||||
}
|
||||
|
||||
// --- Push dei promemoria (M24) ----------------------------------------------------------------
|
||||
|
||||
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
|
||||
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
|
||||
|
||||
/** Riga restituita da `prendi_push_promemoria()`. */
|
||||
export type PushDaInviare = {
|
||||
id_notifica: string;
|
||||
oggetto: string;
|
||||
testo: string;
|
||||
tentativi_fatti: number;
|
||||
iscrizioni: IscrizionePush[];
|
||||
};
|
||||
|
||||
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
|
||||
|
||||
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
|
||||
|
||||
/**
|
||||
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
|
||||
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
|
||||
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
|
||||
*/
|
||||
export function classificaStatoPush(stato: number): EsitoStatoPush {
|
||||
if (stato >= 200 && stato < 300) return "ok";
|
||||
if (stato === 404 || stato === 410) return "scaduta";
|
||||
if (stato === 429 || stato >= 500) return "temporaneo";
|
||||
return "permanente";
|
||||
}
|
||||
|
||||
export type DipendenzePush = {
|
||||
prendi: (max: number) => Promise<PushDaInviare[]>;
|
||||
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
|
||||
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
|
||||
elimina: (endpoint: string) => Promise<void>;
|
||||
adesso: () => Date;
|
||||
log: (messaggio: string) => void;
|
||||
};
|
||||
|
||||
export type RisultatoCicloPush = {
|
||||
inviate: number;
|
||||
riprovate: number;
|
||||
fallite: number;
|
||||
saltate: number;
|
||||
iscrizioniRimosse: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
|
||||
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
|
||||
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
|
||||
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
|
||||
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
|
||||
* dispositivo.
|
||||
*/
|
||||
export async function elaboraCicloPush(
|
||||
d: DipendenzePush,
|
||||
opzioni: { lotto: number },
|
||||
): Promise<RisultatoCicloPush> {
|
||||
const risultato: RisultatoCicloPush = {
|
||||
inviate: 0,
|
||||
riprovate: 0,
|
||||
fallite: 0,
|
||||
saltate: 0,
|
||||
iscrizioniRimosse: 0,
|
||||
};
|
||||
|
||||
for (const push of await d.prendi(opzioni.lotto)) {
|
||||
let accettate = 0;
|
||||
let temporanei = 0;
|
||||
let permanenti = 0;
|
||||
const codici: string[] = [];
|
||||
|
||||
for (const iscrizione of push.iscrizioni) {
|
||||
let stato: number | null = null;
|
||||
try {
|
||||
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
|
||||
} catch {
|
||||
temporanei += 1;
|
||||
codici.push("rete");
|
||||
continue;
|
||||
}
|
||||
const tipo = classificaStatoPush(stato);
|
||||
if (tipo === "ok") accettate += 1;
|
||||
else {
|
||||
codici.push(String(stato));
|
||||
if (tipo === "temporaneo") temporanei += 1;
|
||||
else if (tipo === "permanente") permanenti += 1;
|
||||
else {
|
||||
try {
|
||||
await d.elimina(iscrizione.endpoint);
|
||||
risultato.iscrizioniRimosse += 1;
|
||||
} catch {
|
||||
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const motivo = codici.join(",") || undefined;
|
||||
if (accettate > 0) {
|
||||
await d.segna(push.id_notifica, "inviata");
|
||||
risultato.inviate += 1;
|
||||
d.log(
|
||||
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
|
||||
);
|
||||
} else if (temporanei > 0) {
|
||||
const ritardo = ritardoRiprova(push.tentativi_fatti);
|
||||
if (ritardo === null) {
|
||||
await d.segna(push.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
|
||||
} else {
|
||||
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
|
||||
risultato.riprovate += 1;
|
||||
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
|
||||
}
|
||||
} else if (permanenti > 0) {
|
||||
await d.segna(push.id_notifica, "fallita", motivo);
|
||||
risultato.fallite += 1;
|
||||
d.log(`push fallita ${push.id_notifica} (${motivo})`);
|
||||
} else {
|
||||
await d.segna(push.id_notifica, "saltata", motivo);
|
||||
risultato.saltate += 1;
|
||||
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
|
||||
}
|
||||
}
|
||||
return risultato;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su
|
||||
// Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire.
|
||||
// Uso: bun mailer/mailer.mjs. La logica sta in mailer-core.ts; qui c'è solo la colla con rete,
|
||||
// SMTP e il modulo push dell'app (src/lib/webpush.server.ts, lo stesso usato dalle route).
|
||||
import nodemailer from "nodemailer";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { inviaPush } from "../src/lib/webpush.server.ts";
|
||||
import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts";
|
||||
|
||||
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
|
||||
|
||||
const lettura = leggiConfig(process.env);
|
||||
if ("errori" in lettura) {
|
||||
console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { config } = lettura;
|
||||
|
||||
const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`);
|
||||
|
||||
async function rpc(nome, corpo) {
|
||||
const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: config.serviceRoleKey,
|
||||
Authorization: `Bearer ${config.serviceRoleKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(corpo),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`);
|
||||
const testo = await risposta.text();
|
||||
return testo ? JSON.parse(testo) : null;
|
||||
}
|
||||
|
||||
/** Elimina un'iscrizione push scaduta (il servizio push ha risposto 404 o 410). */
|
||||
async function eliminaIscrizione(endpoint) {
|
||||
const risposta = await fetch(
|
||||
`${config.supabaseUrl}/rest/v1/push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
apikey: config.serviceRoleKey,
|
||||
Authorization: `Bearer ${config.serviceRoleKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
if (!risposta.ok) throw new Error(`delete push_subscriptions: HTTP ${risposta.status}`);
|
||||
}
|
||||
|
||||
const trasporto = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
...(config.smtp.auth ? { auth: config.smtp.auth } : {}),
|
||||
});
|
||||
|
||||
const dominioMessageId = config.from.split("@")[1] ?? "crapp.local";
|
||||
|
||||
const dipendenze = {
|
||||
prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }),
|
||||
segna: (id, esito, errore, prossimo) =>
|
||||
rpc("esito_notifica_email", {
|
||||
p_id: id,
|
||||
p_esito: esito,
|
||||
p_errore: errore ?? null,
|
||||
p_prossimo: prossimo ? prossimo.toISOString() : null,
|
||||
}),
|
||||
invia: (mail) =>
|
||||
trasporto.sendMail({
|
||||
from: { name: config.fromName, address: config.from },
|
||||
to: mail.to,
|
||||
subject: mail.subject,
|
||||
text: mail.text,
|
||||
html: mail.html,
|
||||
messageId: mail.messageId,
|
||||
}),
|
||||
inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}),
|
||||
adesso: () => new Date(),
|
||||
log,
|
||||
};
|
||||
|
||||
const dipendenzePush = {
|
||||
prendi: (max) => rpc("prendi_push_promemoria", { p_max: max }),
|
||||
segna: (id, esito, errore, prossimo) =>
|
||||
rpc("esito_push_promemoria", {
|
||||
p_id: id,
|
||||
p_esito: esito,
|
||||
p_errore: errore ?? null,
|
||||
p_prossimo: prossimo ? prossimo.toISOString() : null,
|
||||
}),
|
||||
invia: (iscrizione, titolo, testo) => inviaPush(iscrizione, titolo, testo),
|
||||
elimina: eliminaIscrizione,
|
||||
adesso: () => new Date(),
|
||||
log,
|
||||
};
|
||||
|
||||
const opzioni = {
|
||||
limiteGiorno: config.limiteGiorno,
|
||||
lotto: config.lotto,
|
||||
appUrl: config.appUrl,
|
||||
dominioMessageId,
|
||||
};
|
||||
|
||||
let fermo = false;
|
||||
for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true));
|
||||
const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
||||
|
||||
log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`);
|
||||
log(
|
||||
config.vapid
|
||||
? "push dei promemoria: attiva"
|
||||
: "push dei promemoria: DISATTIVATA (VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY assenti), solo email",
|
||||
);
|
||||
trasporto.verify().then(
|
||||
() => log("SMTP raggiungibile e credenziali accettate"),
|
||||
(errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`),
|
||||
);
|
||||
|
||||
while (!fermo) {
|
||||
try {
|
||||
const esito = await elaboraCiclo(dipendenze, opzioni);
|
||||
// Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche
|
||||
// minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni.
|
||||
if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now()));
|
||||
} catch (errore) {
|
||||
console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`);
|
||||
}
|
||||
if (config.vapid) {
|
||||
// Un errore delle push non deve fermare le email né il segnale di salute del worker.
|
||||
try {
|
||||
await elaboraCicloPush(dipendenzePush, { lotto: config.lotto });
|
||||
} catch (errore) {
|
||||
console.error(`${new Date().toISOString()} ciclo push fallito: ${errore?.message ?? errore}`);
|
||||
}
|
||||
}
|
||||
await attendi(config.pollSecondi * 1000);
|
||||
}
|
||||
log("arrestato");
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "crapp-mailer",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Worker delle notifiche email di CrAPP (M22, DD-036)",
|
||||
"scripts": {
|
||||
"start": "bun mailer.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"nodemailer": "^10.0.11"
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tanstack_start_ts",
|
||||
"version": "0.1.0",
|
||||
"name": "crapp",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
@@ -10,6 +10,7 @@
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "bun test/run.ts",
|
||||
"test:integration": "bun test/run.ts integration",
|
||||
@@ -17,7 +18,6 @@
|
||||
"test:all": "bun test/run.ts all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@lovable.dev/vite-tanstack-config": "2.8.5",
|
||||
"@tanstack/devtools-vite": "^0.8.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificati,
|
||||
formatDataBreve,
|
||||
testoScadenza,
|
||||
type AvvisoCertificato,
|
||||
} from "@/lib/profili-core";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
function Card({
|
||||
colore,
|
||||
titolo,
|
||||
righe,
|
||||
cliccabile,
|
||||
}: {
|
||||
colore: "warning" | "primary";
|
||||
titolo: string;
|
||||
righe: string[];
|
||||
cliccabile?: boolean;
|
||||
}) {
|
||||
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||
colore === "warning"
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
} ${cliccabile ? "premi" : ""}`;
|
||||
const contenuto = (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||
</p>
|
||||
{righe.map((r) => (
|
||||
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return cliccabile ? (
|
||||
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classi}>{contenuto}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
|
||||
export function AvvisoCertificati() {
|
||||
const admin = useIsAdmin();
|
||||
const ruoliPronti = useRuoliPronti();
|
||||
const base = useGiocatoreBase();
|
||||
const { righe: rosa } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
|
||||
if (!ruoliPronti || isPending) return null;
|
||||
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (admin) {
|
||||
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
|
||||
if (scaduti.length === 0 && inScadenza.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{scaduti.length > 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificati scaduti"
|
||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificati in scadenza"
|
||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!base) return null;
|
||||
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
|
||||
const mio = scaduti[0] ?? inScadenza[0];
|
||||
if (!mio) return null;
|
||||
|
||||
return mio.giorni < 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
righe={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
}
|
||||
@@ -1,46 +1,60 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { motion, type Transition } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
export type VoceSottosezione = {
|
||||
id: string;
|
||||
label: string;
|
||||
contenuto: ReactNode;
|
||||
/** Se true, il pannello è a tutta larghezza (niente padding), es. barra filtri Classifica. */
|
||||
aTuttoLarghezza?: boolean;
|
||||
};
|
||||
|
||||
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
|
||||
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
|
||||
const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
|
||||
|
||||
/**
|
||||
* Barra di sottosezioni in un'unica fila scorrevole (swipe orizzontale) e
|
||||
* pannello che mostra una sola sezione alla volta, cambiabile anche con
|
||||
* swipe sul contenuto.
|
||||
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
|
||||
* alla volta, cambiabile anche con swipe sul contenuto.
|
||||
*
|
||||
* Il pannello precedente si smonta subito (niente AnimatePresence/exit):
|
||||
* al cambio tab resta un solo albero da animare in ingresso.
|
||||
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
|
||||
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
|
||||
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
|
||||
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
|
||||
* la barra non viene mai mostrato: l’etichetta è già nella tab.
|
||||
*/
|
||||
export function BarraSottosezioni({
|
||||
voci,
|
||||
defaultId,
|
||||
variante = "pillole",
|
||||
riempiLarghezza = false,
|
||||
}: {
|
||||
voci: VoceSottosezione[];
|
||||
defaultId?: string;
|
||||
variante?: "pillole" | "sottolineatura";
|
||||
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
|
||||
riempiLarghezza?: boolean;
|
||||
}) {
|
||||
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
||||
const direzione = useRef(0);
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const ridotto = useReducedMotion();
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
|
||||
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
|
||||
// sottosezioni (Squadra, Campionato, Profilo).
|
||||
const ridotto = useMotoRidotto();
|
||||
const indice = Math.max(
|
||||
0,
|
||||
voci.findIndex((v) => v.id === attiva),
|
||||
);
|
||||
const voce = voci[indice] ?? voci[0];
|
||||
const sottolineatura = variante === "sottolineatura";
|
||||
|
||||
useEffect(() => {
|
||||
// `auto`: lo smooth competerebbe col tween del pannello sul main thread.
|
||||
tabRefs.current[attiva]?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
behavior: "smooth",
|
||||
inline: "center",
|
||||
block: "nearest",
|
||||
});
|
||||
@@ -57,55 +71,92 @@ export function BarraSottosezioni({
|
||||
if (!voce) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border bg-background/80 pt-3 backdrop-blur-md">
|
||||
<div className="min-w-0 max-w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full border-b border-border bg-background",
|
||||
!sottolineatura &&
|
||||
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
|
||||
)}
|
||||
>
|
||||
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sottosezioni"
|
||||
className="-mx-0 flex snap-x snap-mandatory gap-1.5 overflow-x-auto px-5 pb-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
className={cn(
|
||||
"w-full max-w-full min-w-0 overflow-x-auto overflow-y-hidden [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
!sottolineatura && "pb-3",
|
||||
)}
|
||||
>
|
||||
{voci.map((v) => {
|
||||
const selezionata = v.id === attiva;
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[v.id] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selezionata}
|
||||
onClick={() => {
|
||||
const i = voci.findIndex((x) => x.id === v.id);
|
||||
vaiA(i);
|
||||
}}
|
||||
className={cn(
|
||||
// grow+basis-0: se le voci ci stanno riempiono la riga in parti uguali,
|
||||
// altrimenti shrink-0 le tiene leggibili e la barra torna a scorrere.
|
||||
"snap-center shrink-0 grow basis-0 rounded-full px-3.5 py-2 text-xs font-bold uppercase tracking-wide transition-colors",
|
||||
"min-h-11 touch-manipulation whitespace-nowrap",
|
||||
selezionata
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sottosezioni"
|
||||
className={cn(
|
||||
riempiLarghezza
|
||||
? "flex w-full flex-nowrap"
|
||||
: sottolineatura
|
||||
? "flex w-max min-w-full flex-nowrap"
|
||||
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
|
||||
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
|
||||
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
|
||||
)}
|
||||
>
|
||||
{voci.map((v) => {
|
||||
const selezionata = v.id === attiva;
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[v.id] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selezionata}
|
||||
onClick={() => {
|
||||
const i = voci.findIndex((x) => x.id === v.id);
|
||||
vaiA(i);
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
|
||||
riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full",
|
||||
sottolineatura
|
||||
? cn(
|
||||
"rounded-xl px-1 py-2.5 text-center",
|
||||
selezionata
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "text-muted-foreground",
|
||||
)
|
||||
: cn(
|
||||
"rounded-full py-2 text-center",
|
||||
riempiLarghezza ? "px-2" : "px-3.5",
|
||||
selezionata
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="relative min-w-0 overflow-hidden">
|
||||
<motion.div
|
||||
key={voce.id}
|
||||
role="tabpanel"
|
||||
aria-label={voce.label}
|
||||
drag="x"
|
||||
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
|
||||
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.12}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, info) => {
|
||||
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
|
||||
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
|
||||
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
|
||||
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) vaiA(indice + 1);
|
||||
else if (arrivo > 60) vaiA(indice - 1);
|
||||
@@ -113,9 +164,8 @@ export function BarraSottosezioni({
|
||||
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={ridotto ? { duration: 0.1 } : transizioneTab}
|
||||
className="touch-pan-y px-5 py-4"
|
||||
className={cn("touch-pan-y", voce.aTuttoLarghezza ? "px-0 pt-0 pb-4" : "px-5 py-4")}
|
||||
>
|
||||
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
|
||||
{voce.contenuto}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import { molla } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
// Quattro voci e non cinque: il profilo sta in alto a destra
|
||||
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
|
||||
@@ -9,11 +10,14 @@ const items = [
|
||||
{ to: "/", label: "Home", icon: Home },
|
||||
{ to: "/calendario", label: "Calendario", icon: CalendarDays },
|
||||
{ to: "/squadra", label: "Squadra", icon: Users },
|
||||
{ to: "/classifica", label: "Classifica", icon: Trophy },
|
||||
{ to: "/classifica", label: "Campionato", icon: Trophy },
|
||||
] as const;
|
||||
|
||||
export function BottomNav() {
|
||||
const ridotto = useReducedMotion();
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
|
||||
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
|
||||
const ridotto = useMotoRidotto();
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -62,11 +66,21 @@ export function BottomNav() {
|
||||
*/}
|
||||
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
|
||||
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
|
||||
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
|
||||
<div
|
||||
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
|
||||
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
|
||||
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
|
||||
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
|
||||
{...(ridotto ? { "data-leggero": "" } : {})}
|
||||
>
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
// Il chunk della pagina parte già al tocco (touchstart), non al tap completo:
|
||||
// le 4 rotte sono piccole (8-12KB), nessun costo aggiuntivo di query (nessuna
|
||||
// rotta usa `loader`, quindi non prefetcha dati, solo codice).
|
||||
preload="intent"
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeProps={{ "aria-current": "page" }}
|
||||
// Lo stato attivo non è solo colore: è la capsula piena sotto la
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, ShieldAlert, Users2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { useCsiPartita } from "@/lib/csi-partita";
|
||||
import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core";
|
||||
|
||||
/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */
|
||||
export function LogoSquadra({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [errore, setErrore] = useState(false);
|
||||
if (!src || errore) return null;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setErrore(true)}
|
||||
className={cn("shrink-0 rounded-lg object-contain", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */
|
||||
export function MetaPartitaCsi({
|
||||
girone,
|
||||
numeroGara,
|
||||
arbitro,
|
||||
link,
|
||||
}: {
|
||||
girone: string;
|
||||
numeroGara: string;
|
||||
arbitro: string;
|
||||
link: string;
|
||||
}) {
|
||||
const voci = [
|
||||
girone,
|
||||
numeroGara ? `N° gara ${numeroGara}` : "",
|
||||
arbitro ? `Arbitro: ${arbitro}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (!voci && !link) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{voci ? <span>{voci}</span> : null}
|
||||
{link ? (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-semibold text-accent"
|
||||
>
|
||||
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{squadra.squadra}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{squadra.titolari.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
||||
{g.numero}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{squadra.panchina.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
|
||||
A disposizione
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{squadra.panchina.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{squadra.staff.length > 0 ? (
|
||||
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
||||
{squadra.staff.map((s, i) => (
|
||||
<div key={i}>
|
||||
{s.nome} · {s.ruolo}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarraPrecedenti({
|
||||
label,
|
||||
noi,
|
||||
avversario,
|
||||
}: {
|
||||
label: string;
|
||||
noi: number;
|
||||
avversario: number;
|
||||
}) {
|
||||
const totale = noi + avversario || 1;
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="font-bold text-foreground">{noi}</span>
|
||||
<span>{label}</span>
|
||||
<span className="font-bold text-foreground">{avversario}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NOME_NOI = "CRAP Volley";
|
||||
|
||||
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
|
||||
function TestataSquadre({ avversario }: { avversario: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarraProbabilita({
|
||||
avversario,
|
||||
noi,
|
||||
avversarioPct,
|
||||
}: {
|
||||
avversario: string;
|
||||
noi: number;
|
||||
avversarioPct: number;
|
||||
}) {
|
||||
const favoritaNoi = noi >= avversarioPct;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Probabilità di vittoria (calcolo CSI):{" "}
|
||||
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||
</p>
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
|
||||
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
|
||||
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
|
||||
{avversario}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-7 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
||||
style={{ width: `${noi}%` }}
|
||||
>
|
||||
{noi.toFixed(0)}%
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
||||
style={{ width: `${avversarioPct}%` }}
|
||||
>
|
||||
{avversarioPct.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BloccoPrecedenti({
|
||||
precedenti,
|
||||
avversario,
|
||||
}: {
|
||||
precedenti: PrecedentiCsi;
|
||||
avversario: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TestataSquadre avversario={avversario} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{precedenti.totale > 0
|
||||
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||
</p>
|
||||
{precedenti.totale > 0 ? (
|
||||
<>
|
||||
<BarraPrecedenti
|
||||
label="vittorie"
|
||||
noi={precedenti.vinteNoi}
|
||||
avversario={precedenti.vinteAvversario}
|
||||
/>
|
||||
<BarraPrecedenti
|
||||
label="in casa"
|
||||
noi={precedenti.casaNoi}
|
||||
avversario={precedenti.casaAvversario}
|
||||
/>
|
||||
<BarraPrecedenti
|
||||
label="fuori"
|
||||
noi={precedenti.fuoriNoi}
|
||||
avversario={precedenti.fuoriAvversario}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
|
||||
<BarraProbabilita
|
||||
avversario={avversario}
|
||||
noi={precedenti.probabilitaNoi}
|
||||
avversarioPct={precedenti.probabilitaAvversario}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato
|
||||
* server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una
|
||||
* lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve
|
||||
* solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI).
|
||||
*/
|
||||
export function DettaglioCsiEsteso({
|
||||
matchId,
|
||||
avversario,
|
||||
}: {
|
||||
matchId: string;
|
||||
avversario: string;
|
||||
}) {
|
||||
const { data, isLoading } = useCsiPartita(matchId);
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||
}
|
||||
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.giornata || data.nota ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{data.formazioni ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ListaFormazione squadra={data.formazioni.noi} />
|
||||
<ListaFormazione squadra={data.formazioni.avversario} />
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{data.precedenti ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||
</div>
|
||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,66 @@
|
||||
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
Cake,
|
||||
ChevronRight,
|
||||
Check,
|
||||
HelpCircle,
|
||||
X,
|
||||
Bandage,
|
||||
} from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
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";
|
||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const tipoMeta = {
|
||||
partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
|
||||
allenamento: { label: "Allenamento", className: "bg-training text-training-foreground" },
|
||||
evento: { label: "Eventi", className: "bg-warning text-warning-foreground" },
|
||||
evento: { label: "Evento", className: "bg-warning text-warning-foreground" },
|
||||
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
|
||||
} as const;
|
||||
|
||||
const stati: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
|
||||
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||
|
||||
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
|
||||
const iconeStato: Record<Stato, typeof Check> = {
|
||||
presente: Check,
|
||||
forse: HelpCircle,
|
||||
ritardo: Clock,
|
||||
assente: X,
|
||||
/** Cerotto singolo: leggibile a 16px e allineato all'emoji 🩹. */
|
||||
infortunato: Bandage,
|
||||
};
|
||||
|
||||
export function linkPerEvento(e: Evento) {
|
||||
if (e.tipo === "partita") {
|
||||
return { to: "/partita/$id", params: { id: e.id }, label: "Apri partita" };
|
||||
return { to: "/partita/$id" as const, params: { id: e.id }, label: "Dettaglio partita" };
|
||||
}
|
||||
if (e.tipo === "allenamento") {
|
||||
return { to: "/allenamento/$id", params: { id: e.id }, label: "Apri allenamento" };
|
||||
return {
|
||||
to: "/allenamento/$id" as const,
|
||||
params: { id: e.id },
|
||||
label: "Dettaglio allenamento",
|
||||
};
|
||||
}
|
||||
if (e.tipo === "evento") {
|
||||
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function etichettaData(iso: string) {
|
||||
const giorno = iso.slice(8, 10);
|
||||
const mese = formatData(iso).split(" ")[2]?.slice(0, 3)?.toUpperCase() ?? "";
|
||||
return `${giorno} ${mese}`;
|
||||
}
|
||||
|
||||
export function EventoCard({
|
||||
evento,
|
||||
linkTo,
|
||||
@@ -38,9 +70,12 @@ export function EventoCard({
|
||||
}) {
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
|
||||
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -50,9 +85,14 @@ export function EventoCard({
|
||||
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
|
||||
: tipoMeta[evento.tipo];
|
||||
const totale = rosa.length;
|
||||
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
|
||||
const isCompleanno = evento.tipo === "compleanno";
|
||||
const passato = evento.data < dataOggi();
|
||||
const cliccabile = Boolean(linkTo);
|
||||
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
|
||||
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
|
||||
const stati = includeInfortunato(evento.tipo, stato)
|
||||
? [...statiBase, "infortunato" as const]
|
||||
: statiBase;
|
||||
|
||||
if (isCompleanno) {
|
||||
return (
|
||||
@@ -64,20 +104,39 @@ export function EventoCard({
|
||||
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
|
||||
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
|
||||
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
|
||||
</p>
|
||||
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const sottotitoloPartita =
|
||||
evento.tipo === "partita"
|
||||
? `${evento.casa ? "Casa" : "Trasferta"}${evento.campionato ? " · Campionato" : " · Amichevole"}`
|
||||
: null;
|
||||
const metaStato = stato ? statoMeta[stato] : null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Card
|
||||
as="article"
|
||||
className={cn(
|
||||
"relative overflow-hidden p-0",
|
||||
cliccabile && "transition-transform active:scale-[0.99]",
|
||||
)}
|
||||
>
|
||||
{/* Link a tutta card: i controlli sopra (z-10) restano indipendenti. */}
|
||||
{linkTo ? (
|
||||
<Link
|
||||
to={linkTo.to}
|
||||
params={linkTo.params}
|
||||
aria-label={linkTo.label}
|
||||
className="absolute inset-0 z-0 rounded-3xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={cn("relative z-10 p-4", linkTo && "pointer-events-none")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
@@ -86,76 +145,89 @@ export function EventoCard({
|
||||
>
|
||||
{tipo.label}
|
||||
</span>
|
||||
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
|
||||
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkTo && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to={linkTo.to}
|
||||
params={linkTo.params}
|
||||
className="inline-flex min-h-11 items-center gap-1 rounded-full bg-primary px-4 text-xs font-bold uppercase tracking-wide text-primary-foreground transition-transform active:scale-95"
|
||||
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
||||
{sottotitoloPartita ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{evento.ora}</span>
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{evento.luogo}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{presentiVeri}/{totale}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{noteCard ? (
|
||||
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
|
||||
) : null}
|
||||
|
||||
{metaStato ? (
|
||||
<p
|
||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{linkTo.label} <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<span aria-hidden>{metaStato.emoji}</span>
|
||||
<span>{metaStato.label}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" /> {evento.ora}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{evento.luogo}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" /> {presentiVeri}/{totale}
|
||||
</span>
|
||||
{io ? (
|
||||
<div
|
||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||
role="group"
|
||||
aria-label="La tua presenza"
|
||||
>
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const Icon = iconeStato[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending || passato}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
});
|
||||
}}
|
||||
aria-pressed={attivo}
|
||||
aria-label={meta.label}
|
||||
title={meta.label}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 min-w-0 flex-1 items-center justify-center rounded-xl px-1 transition-all active:scale-95 disabled:opacity-50",
|
||||
attivo
|
||||
? cn(meta.className, "shadow-card")
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
|
||||
|
||||
{io ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending || passato}
|
||||
onClick={() =>
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
})
|
||||
}
|
||||
aria-pressed={attivo}
|
||||
className={cn(
|
||||
// min-h-11: sono i controlli più toccati dell'app, sotto i
|
||||
// 44px si sbaglia bersaglio.
|
||||
"min-h-11 rounded-full border border-border px-3 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||
attivo
|
||||
? cn(meta.className, "border-transparent shadow-card")
|
||||
: "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||
|
||||
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
@@ -20,7 +20,8 @@ export function Pagelle({
|
||||
convocati: Giocatore[];
|
||||
chiuse?: boolean;
|
||||
}) {
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { voti: tutti, isPending } = usePagelle();
|
||||
const vota = useVotaPagella();
|
||||
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
useSalvaProfilo,
|
||||
type SezioneFile,
|
||||
} from "@/lib/profili";
|
||||
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
type Profilo,
|
||||
@@ -244,6 +246,138 @@ export function CampiProfilo({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
|
||||
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
|
||||
*/
|
||||
export function CampiAllenatore({
|
||||
corrente,
|
||||
aggiorna,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
|
||||
* ridotti. Un solo pulsante salva entrambi.
|
||||
*/
|
||||
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
const { profili } = useProfili();
|
||||
const salvaProfilo = useSalvaProfilo();
|
||||
const salvaNome = useSalvaNomeAllenatore();
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
|
||||
|
||||
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
|
||||
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
|
||||
const sporco = bozza !== null || nome !== null;
|
||||
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
|
||||
const perc = completamentoAllenatore(corrente);
|
||||
|
||||
async function salva() {
|
||||
try {
|
||||
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
|
||||
if (bozza) await salvaProfilo.mutateAsync(bozza);
|
||||
setNome(null);
|
||||
setBozza(null);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Intestazione titolo="Dati personali" completa={perc === 100} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
value={nomeCorrente.nome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Cognome">
|
||||
<input
|
||||
value={nomeCorrente.cognome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void salva()}
|
||||
disabled={!sporco || inCorso}
|
||||
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"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -304,10 +438,6 @@ export function ProfiloAmministrativo({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
||||
</p>
|
||||
|
||||
<CampiProfilo
|
||||
corrente={corrente}
|
||||
aggiorna={aggiorna}
|
||||
@@ -384,13 +514,18 @@ export function ProfiloAmministrativo({
|
||||
*/
|
||||
export function CompletaProfilo({
|
||||
giocatoreId,
|
||||
allenatore = false,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
|
||||
allenatore?: boolean;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili, isPending } = useProfili();
|
||||
const perc = completamento(profili[giocatoreId]);
|
||||
const perc = allenatore
|
||||
? completamentoAllenatore(profili[giocatoreId])
|
||||
: completamento(profili[giocatoreId]);
|
||||
if (isPending || perc === 100) return null;
|
||||
|
||||
return (
|
||||
@@ -401,10 +536,10 @@ export function CompletaProfilo({
|
||||
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">
|
||||
<span className="font-display text-[15px] uppercase tracking-wide">
|
||||
Completa il tuo profilo
|
||||
</span>
|
||||
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
@@ -412,8 +547,10 @@ export function CompletaProfilo({
|
||||
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 className="mt-2 text-[13px] text-muted-foreground">
|
||||
{allenatore
|
||||
? "Data e luogo di nascita, telefono ed email."
|
||||
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { formatData } from "@/lib/crapp-data";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
import { useEventi } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
|
||||
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
|
||||
export function PromemoriaPalloni() {
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { eventi } = useEventi();
|
||||
if (!io) return null;
|
||||
|
||||
@@ -6,21 +6,35 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import type { EventoTipo } from "@/lib/eventi";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||
|
||||
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
|
||||
export function RosaPresenze({
|
||||
eventoId,
|
||||
data,
|
||||
tipo = "allenamento",
|
||||
}: {
|
||||
eventoId: string;
|
||||
data: string;
|
||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||
tipo?: EventoTipo;
|
||||
}) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const rosa = useRosa();
|
||||
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
||||
// niente statistiche di squadra.
|
||||
const io = useGiocatoreInCampo();
|
||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
|
||||
@@ -28,6 +42,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
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;
|
||||
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
|
||||
|
||||
async function sollecita() {
|
||||
setSollecito(true);
|
||||
@@ -35,7 +50,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
const res = await fetch("/api/public/sollecita-presenze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ eventoId, da: io?.nome }),
|
||||
body: JSON.stringify({ eventoId, da: mittente?.nome }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const dati = (await res.json()) as { inviate: number; destinatari: number };
|
||||
@@ -63,7 +78,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const n = rosa.filter((g) => risposte[g.id] === s).length;
|
||||
return (
|
||||
<span
|
||||
@@ -88,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
La tua risposta
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const attivo = risposte[io.id] === s;
|
||||
return (
|
||||
<button
|
||||
@@ -113,7 +128,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
{puoSollecitare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
@@ -136,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const lista = rosa.filter((g) => risposte[g.id] === s);
|
||||
if (lista.length === 0) return null;
|
||||
return (
|
||||
@@ -169,7 +184,7 @@ function Gruppo({
|
||||
}: {
|
||||
titolo: string;
|
||||
n: number;
|
||||
lista: Giocatore[];
|
||||
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
|
||||
attenzione?: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight, Lock, Radio } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
/**
|
||||
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
|
||||
@@ -17,7 +17,8 @@ export function ScoutEntry({
|
||||
}) {
|
||||
const { pronto, partita: diOggi } = usePartitaDiOggi();
|
||||
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||
|
||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||
|
||||
@@ -4,10 +4,16 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche";
|
||||
import {
|
||||
mediaPartita,
|
||||
sondaggioAperto,
|
||||
sondaggioTerminato,
|
||||
useCacche,
|
||||
useSalvaCacche,
|
||||
} from "@/lib/cacche";
|
||||
|
||||
const opzioni = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
@@ -15,11 +21,14 @@ const opzioni = [0, 1, 2, 3, 4, 5];
|
||||
export function SondaggioCacche({
|
||||
eventoId,
|
||||
dataEvento,
|
||||
oraEvento,
|
||||
}: {
|
||||
eventoId: string;
|
||||
dataEvento: string;
|
||||
oraEvento: string;
|
||||
}) {
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe } = useCacche();
|
||||
const salva = useSalvaCacche();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
@@ -60,7 +69,7 @@ export function SondaggioCacche({
|
||||
toast.success(
|
||||
dati.inviate > 0
|
||||
? `Notifica inviata a ${dati.inviate} dispositivi`
|
||||
: "Nessun dispositivo con le notifiche attive",
|
||||
: "Avviso inviato in app e per email (nessun dispositivo con le notifiche attive)",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a inviare la notifica");
|
||||
@@ -69,14 +78,16 @@ export function SondaggioCacche({
|
||||
}
|
||||
}
|
||||
|
||||
if (!sondaggioAperto(dataEvento)) {
|
||||
if (!sondaggioAperto(dataEvento, oraEvento)) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
💩 Sondaggio pre-partita
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Apre alle 8:00 del giorno della partita: riceverai una notifica.
|
||||
{sondaggioTerminato(dataEvento, oraEvento)
|
||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -4,20 +4,21 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [avviso, setAvviso] = useState(false);
|
||||
const { salvati, turni, isPending } = useTurniPalloni();
|
||||
const assegna = useAssegnaTurno();
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.nome` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
|
||||
const id = turni[eventoId];
|
||||
const proposto = !salvati[eventoId];
|
||||
|
||||
@@ -2,17 +2,37 @@ import { useState } from "react";
|
||||
import { Crown, Vote } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
conteggioPartita,
|
||||
mioVoto,
|
||||
ORE_ATTESA_MVP,
|
||||
useVotaMvp,
|
||||
useVotiMvp,
|
||||
votoMvpAperto,
|
||||
type VotoMvp,
|
||||
} from "@/lib/mvp-voti";
|
||||
|
||||
/** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */
|
||||
export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
/**
|
||||
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
|
||||
*
|
||||
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
|
||||
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
|
||||
* solo i presenti (o in ritardo) di quell'evento.
|
||||
*/
|
||||
export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
const matchId = evento.id;
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiMvp();
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
@@ -21,6 +41,20 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
|
||||
const testa = conteggio[0];
|
||||
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
|
||||
const puoVotare = !!io && presente(io.id);
|
||||
|
||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function invia(id: string, nome: string) {
|
||||
if (!io) return;
|
||||
@@ -47,7 +81,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!io}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{mio ? "Cambia voto" : "Vota"}
|
||||
@@ -67,26 +101,34 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
</p>
|
||||
{mio ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||
) : !puoVotare ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Vota chi era presente a questa partita.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{aperto ? (
|
||||
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
|
||||
{rosa.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
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
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
|
||||
anche dal vincolo `mvp_no_autovoto` (M12). */}
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
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
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : conteggio.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Check, Crown, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import {
|
||||
categorieSocial,
|
||||
conteggioCategoria,
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
|
||||
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
||||
export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiSocial();
|
||||
const vota = useVotaSocial();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const [aperta, setAperta] = useState<string | null>(null);
|
||||
|
||||
const tutti = voti.data ?? [];
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { useIo } from "@/lib/rosa";
|
||||
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import {
|
||||
pallinoNotifiche,
|
||||
useEliminaNotifica,
|
||||
useNotificheMie,
|
||||
useSegnaLette,
|
||||
type NotificaUtente,
|
||||
} from "@/lib/notifiche-utente";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
@@ -25,6 +43,7 @@ export function TeamLogo({
|
||||
alt="CRAP Volley"
|
||||
width={192}
|
||||
height={192}
|
||||
decoding="async"
|
||||
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
|
||||
/>
|
||||
);
|
||||
@@ -46,22 +65,176 @@ export function Card({
|
||||
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
|
||||
function tempoRelativo(dataIso: string): string {
|
||||
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
|
||||
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
|
||||
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
|
||||
const ore = Math.round(minuti / 60);
|
||||
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
|
||||
return rtf.format(Math.round(ore / 24), "day");
|
||||
}
|
||||
|
||||
/**
|
||||
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
|
||||
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
|
||||
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
|
||||
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
|
||||
* c'è "indietro", solo "via".
|
||||
*/
|
||||
function RigaNotifica({
|
||||
notifica,
|
||||
onElimina,
|
||||
}: {
|
||||
notifica: NotificaUtente;
|
||||
onElimina: (id: string) => void;
|
||||
}) {
|
||||
const ridotto = useMotoRidotto();
|
||||
return (
|
||||
<motion.li
|
||||
layout={!ridotto}
|
||||
initial={false}
|
||||
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
|
||||
transition={molla.ui}
|
||||
className="relative overflow-hidden rounded-xl"
|
||||
>
|
||||
<motion.div
|
||||
drag={ridotto ? false : "x"}
|
||||
dragConstraints={{ left: -80, right: 0 }}
|
||||
dragElastic={{ left: 0.2, right: 0 }}
|
||||
dragMomentum={false}
|
||||
onDragEnd={(_, info) => {
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) onElimina(notifica.id);
|
||||
}}
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<p className="font-semibold">{notifica.titolo}</p>
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onElimina(notifica.id)}
|
||||
aria-label="Rimuovi notifica"
|
||||
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
||||
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
||||
* `PageHeader` per rimetterci il logo — sarebbe un link a sé stessa.
|
||||
* `PageHeader` con il logo che porta alla home.
|
||||
*
|
||||
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
|
||||
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
|
||||
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
|
||||
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
|
||||
*
|
||||
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
|
||||
* porta sempre al profilo, quello sul pallino apre il pannello.
|
||||
*/
|
||||
export function LinkProfilo() {
|
||||
const g = useIo();
|
||||
if (!g) return <TeamLogo className="h-11 w-11" />;
|
||||
const g = useGiocatoreBase();
|
||||
if (!g) return <TeamLogo className="h-12 w-12" />;
|
||||
return (
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={g.iniziali} className="h-11 w-11 text-lg" />
|
||||
</Link>
|
||||
<div className="relative shrink-0">
|
||||
<Link
|
||||
to="/profilo"
|
||||
aria-label="Il tuo profilo"
|
||||
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
|
||||
>
|
||||
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||
</Link>
|
||||
<PallinoNotifiche />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
|
||||
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
|
||||
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
|
||||
* `src/lib/notifiche-utente.ts`.
|
||||
*
|
||||
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
|
||||
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
|
||||
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
|
||||
*/
|
||||
function PallinoNotifiche() {
|
||||
const { notifiche, nonLette } = useNotificheMie();
|
||||
const segnaLette = useSegnaLette();
|
||||
const eliminaNotifica = useEliminaNotifica();
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const riquadro = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aperto) return;
|
||||
const chiudiSeFuori = (e: MouseEvent) => {
|
||||
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
|
||||
};
|
||||
const chiudiConEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAperto(false);
|
||||
};
|
||||
document.addEventListener("mousedown", chiudiSeFuori);
|
||||
document.addEventListener("keydown", chiudiConEsc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", chiudiSeFuori);
|
||||
document.removeEventListener("keydown", chiudiConEsc);
|
||||
};
|
||||
}, [aperto]);
|
||||
|
||||
const pallino = pallinoNotifiche(notifiche.length, nonLette);
|
||||
if (!pallino) return null;
|
||||
|
||||
function alClick() {
|
||||
const stavaChiuso = !aperto;
|
||||
setAperto(stavaChiuso);
|
||||
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
|
||||
}
|
||||
|
||||
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
|
||||
// notifica non si riapre da solo.
|
||||
function elimina(id: string) {
|
||||
if (notifiche.length === 1) setAperto(false);
|
||||
eliminaNotifica.mutate(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={riquadro}>
|
||||
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={alClick}
|
||||
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
|
||||
aria-expanded={aperto}
|
||||
className={cn(
|
||||
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
|
||||
pallino.daLeggere
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{pallino.testo}
|
||||
</button>
|
||||
{aperto ? (
|
||||
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
|
||||
<ul className="max-h-80 overflow-y-auto">
|
||||
<AnimatePresence initial={false}>
|
||||
{notifiche.map((n) => (
|
||||
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,17 +269,19 @@ export function Section({
|
||||
children,
|
||||
indice = 0,
|
||||
}: {
|
||||
titolo: string;
|
||||
titolo?: string;
|
||||
azione?: ReactNode;
|
||||
children: ReactNode;
|
||||
indice?: number;
|
||||
}) {
|
||||
return (
|
||||
<Reveal as="section" indice={indice} className="px-5 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
|
||||
{azione}
|
||||
</div>
|
||||
{titolo || azione ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
{titolo ? <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2> : <span />}
|
||||
{azione}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</Reveal>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Reveal({
|
||||
{...(style ? { style } : {})}
|
||||
initial={ridotto ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||
whileInView={ridotto ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.15, margin: "0px 0px -10% 0px" }}
|
||||
viewport={{ once: true, amount: "some", margin: "0px 0px -10% 0px" }}
|
||||
transition={ridotto ? { duration: 0.2, delay: ritardo } : { ...molla.ui, delay: ritardo }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// This file is auto-generated by Lovable. Do not modify it.
|
||||
|
||||
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
|
||||
import { supabase } from "../supabase/client";
|
||||
const lovableAuth = createLovableAuth();
|
||||
|
||||
type SignInOptions = {
|
||||
redirect_uri?: string;
|
||||
extraParams?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const lovable = {
|
||||
auth: {
|
||||
signInWithOAuth: async (
|
||||
provider: "google" | "apple" | "microsoft" | "lovable",
|
||||
opts?: SignInOptions,
|
||||
) => {
|
||||
const result = await lovableAuth.signInWithOAuth(provider, {
|
||||
redirect_uri: opts?.redirect_uri ?? window.location.origin,
|
||||
extraParams: {
|
||||
...opts?.extraParams,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.redirected) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.auth.setSession(result.tokens);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e : new Error(String(e)) };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -637,7 +637,7 @@ export type Database = {
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
app_role: "admin" | "user";
|
||||
app_role: "admin" | "user" | "allenatore";
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
@@ -762,7 +762,7 @@ export const Constants = {
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
app_role: ["admin", "user"],
|
||||
app_role: ["admin", "user", "allenatore"],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -7,9 +7,9 @@
|
||||
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
|
||||
* può fare da credenziale.
|
||||
*
|
||||
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
|
||||
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
|
||||
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
|
||||
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
|
||||
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
|
||||
* verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
|
||||
* proseguire, altrimenti la `Response` di rifiuto già pronta.
|
||||
*/
|
||||
|
||||
@@ -18,15 +18,20 @@ function tokenDaRichiesta(request: Request): string | null {
|
||||
const intestazione = request.headers.get("authorization");
|
||||
if (!intestazione?.startsWith("Bearer ")) return null;
|
||||
const token = intestazione.slice("Bearer ".length).trim();
|
||||
// Un JWT ha tre segmenti: scartarlo qui evita una chiamata di rete per ogni rumore.
|
||||
return token && token.split(".").length === 3 ? token : null;
|
||||
// Un JWT ha tre segmenti non vuoti: scartarlo qui evita una chiamata di rete per ogni rumore.
|
||||
const segmenti = token.split(".");
|
||||
return segmenti.length === 3 && segmenti.every(Boolean) ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
|
||||
* `403` se il token è buono ma l'utente non è admin.
|
||||
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
|
||||
* vale il token, `403` se il token è buono ma il ruolo no.
|
||||
*/
|
||||
export async function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
async function richiediRuolo(
|
||||
request: Request,
|
||||
ruoli: Array<"admin" | "allenatore">,
|
||||
rifiuto: string,
|
||||
): Promise<Response | null> {
|
||||
const token = tokenDaRichiesta(request);
|
||||
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
|
||||
|
||||
@@ -36,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
|
||||
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
|
||||
|
||||
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
|
||||
const { data: ruolo } = await supabaseAdmin
|
||||
const { data: righe } = await supabaseAdmin
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utente.user.id)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ruoli);
|
||||
|
||||
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
|
||||
if (!righe?.length) return new Response(rifiuto, { status: 403 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
|
||||
export function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
|
||||
}
|
||||
|
||||
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
|
||||
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
|
||||
}
|
||||
@@ -29,9 +29,13 @@ export function useAvatarEsiste(id: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvalidaAvatarEsiste() {
|
||||
/**
|
||||
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
|
||||
* rileggere l'elenco del bucket (una richiesta in meno per ogni cambio foto).
|
||||
*/
|
||||
export function useImpostaAvatarEsiste() {
|
||||
const qc = useQueryClient();
|
||||
return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) });
|
||||
return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste);
|
||||
}
|
||||
|
||||
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
|
||||
|
||||
+11
-3
@@ -58,6 +58,12 @@ export type BadgeDef = {
|
||||
notificaPush?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
|
||||
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
|
||||
*/
|
||||
export const VOTI_MINIMI_PAGELLA = 5;
|
||||
|
||||
export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "mvp",
|
||||
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
|
||||
unita: "di media voto",
|
||||
icon: ClipboardCheck,
|
||||
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
|
||||
valore: (g) => g.mediaVoto,
|
||||
valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
|
||||
},
|
||||
{
|
||||
id: "palloni",
|
||||
@@ -130,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
icon: Ghost,
|
||||
segreto: true,
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
|
||||
// Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media
|
||||
// non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto.
|
||||
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
|
||||
celebrazione: "Nei momenti caldi ci sei sempre.",
|
||||
},
|
||||
{
|
||||
@@ -178,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
id: "s-cacche",
|
||||
nome: "Trono di ferro",
|
||||
descrizione:
|
||||
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
|
||||
"Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂",
|
||||
unita: "partite da record",
|
||||
icon: Toilet,
|
||||
segreto: true,
|
||||
|
||||
+59
-4
@@ -1,15 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { oggiISO } from "./palloni-core";
|
||||
|
||||
/**
|
||||
* Avviso «sondaggio pre-partita aperto» (push, centro notifiche ed email, DD-038): stesso testo su
|
||||
* tutti e tre i canali.
|
||||
*/
|
||||
export function avvisoSondaggio(partita: { titolo: string; ora: string }): {
|
||||
titolo: string;
|
||||
testo: string;
|
||||
} {
|
||||
return {
|
||||
titolo: "💩 Sondaggio pre-partita aperto",
|
||||
testo: `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi riceve l'avviso in-app e per email: i giocatori attivi, mai gli allenatori, che il sondaggio
|
||||
* non riguarda (DD-034). Non dipende dai dispositivi iscritti alla push: l'avviso in-app e la mail
|
||||
* arrivano anche a chi non ha attivato le notifiche.
|
||||
*/
|
||||
export function destinatariSondaggio(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
): string[] {
|
||||
return squadra.filter(inRosa).map((g) => g.id);
|
||||
}
|
||||
|
||||
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
|
||||
export const ORA_APERTURA_SONDAGGIO = 8;
|
||||
|
||||
/** Il sondaggio apre alle 8:00 del giorno della partita e da lì resta aperto. */
|
||||
export function sondaggioAperto(dataEvento: string, adesso = new Date()): boolean {
|
||||
/** Istante del fischio d'inizio: `oraEvento` mancante equivale a fine giornata (non chiude mai prima). */
|
||||
function inizioPartita(dataEvento: string, oraEvento: string): number {
|
||||
return new Date(`${dataEvento}T${oraEvento || "23:59"}:00`).getTime();
|
||||
}
|
||||
|
||||
/** Il sondaggio apre alle 8:00 del giorno della partita e chiude al fischio d'inizio. */
|
||||
export function sondaggioAperto(
|
||||
dataEvento: string,
|
||||
oraEvento: string,
|
||||
adesso = new Date(),
|
||||
): boolean {
|
||||
if (dataEvento !== oggiISO(adesso)) return false;
|
||||
const apertura = new Date(`${dataEvento}T00:00:00`);
|
||||
apertura.setHours(ORA_APERTURA_SONDAGGIO, 0, 0, 0);
|
||||
return (
|
||||
adesso.getTime() >= apertura.getTime() &&
|
||||
adesso.getTime() < inizioPartita(dataEvento, oraEvento)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True se il fischio d'inizio è già passato (oggi o in un giorno precedente): serve a
|
||||
* distinguere, nel messaggio mostrato quando il sondaggio è chiuso, "non ancora aperto"
|
||||
* da "già chiuso" — altrimenti dopo la partita si continuerebbe a dire "apre alle 8:00".
|
||||
*/
|
||||
export function sondaggioTerminato(
|
||||
dataEvento: string,
|
||||
oraEvento: string,
|
||||
adesso = new Date(),
|
||||
): boolean {
|
||||
const oggi = oggiISO(adesso);
|
||||
if (dataEvento !== oggi) return dataEvento < oggi;
|
||||
return adesso.getHours() >= ORA_APERTURA_SONDAGGIO;
|
||||
if (dataEvento < oggi) return true;
|
||||
if (dataEvento > oggi) return false;
|
||||
return adesso.getTime() >= inizioPartita(dataEvento, oraEvento);
|
||||
}
|
||||
|
||||
/** Sondaggio goliardico pre-partita: quante cacche prima del match di campionato. */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
|
||||
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
|
||||
|
||||
export const mesiIT = [
|
||||
"Gennaio",
|
||||
"Febbraio",
|
||||
"Marzo",
|
||||
"Aprile",
|
||||
"Maggio",
|
||||
"Giugno",
|
||||
"Luglio",
|
||||
"Agosto",
|
||||
"Settembre",
|
||||
"Ottobre",
|
||||
"Novembre",
|
||||
"Dicembre",
|
||||
] as const;
|
||||
|
||||
export function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
|
||||
export function giorniDelMese(anno: number, mese: number) {
|
||||
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
|
||||
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
|
||||
const offsetLunedi = (primoGiorno + 6) % 7;
|
||||
return { giorni, offsetLunedi };
|
||||
}
|
||||
|
||||
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
|
||||
export function useMeseNav(initial?: { anno: number; mese: number }) {
|
||||
const oggi = new Date();
|
||||
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
|
||||
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
|
||||
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
|
||||
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
|
||||
const [direzione, setDirezione] = useState(0);
|
||||
|
||||
const precedente = () => {
|
||||
setDirezione(-1);
|
||||
if (mese === 0) {
|
||||
setMese(11);
|
||||
setAnno((a) => a - 1);
|
||||
} else {
|
||||
setMese((m) => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const successivo = () => {
|
||||
setDirezione(1);
|
||||
if (mese === 11) {
|
||||
setMese(0);
|
||||
setAnno((a) => a + 1);
|
||||
} else {
|
||||
setMese((m) => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return { anno, mese, direzione, precedente, successivo };
|
||||
}
|
||||
+17
-9
@@ -28,6 +28,8 @@ export type Giocatore = {
|
||||
nascita: string;
|
||||
presenze: number;
|
||||
totaliEventi: number;
|
||||
/** Solo partite (non allenamenti) a cui era presente o in ritardo. */
|
||||
partiteGiocate: number;
|
||||
streak: number;
|
||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||
serieAllenamenti: number;
|
||||
@@ -37,8 +39,12 @@ export type Giocatore = {
|
||||
mvp: number;
|
||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||
mediaVoto: number;
|
||||
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
|
||||
votiPagella: number;
|
||||
/** Quante volte ha portato i palloni. */
|
||||
palloni: number;
|
||||
/** Volte consecutive (fino a oggi) in cui ha portato i palloni. */
|
||||
seriePalloni: number;
|
||||
/** Partite di campionato con almeno 3 cacche dichiarate. */
|
||||
cacche: number;
|
||||
/** Media di cacche dichiarate per partita. */
|
||||
@@ -73,7 +79,8 @@ const rosaCSI: Rosa[] = [
|
||||
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
|
||||
];
|
||||
|
||||
function inizialiDa(nome: string) {
|
||||
/** Iniziali da un nome completo (max 2 lettere), es. per il fallback di `Avatar`. */
|
||||
export function inizialiDa(nome: string) {
|
||||
return nome
|
||||
.split(" ")
|
||||
.map((p) => p[0] ?? "")
|
||||
@@ -98,28 +105,23 @@ export const giocatori: Giocatore[] = rosaCSI
|
||||
iniziali: inizialiDa(r.nome),
|
||||
presenze: 0,
|
||||
totaliEventi: 0,
|
||||
partiteGiocate: 0,
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
votiPagella: 0,
|
||||
infortuni: 0,
|
||||
ritardi: 0,
|
||||
palloni: 0,
|
||||
seriePalloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
}))
|
||||
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
|
||||
|
||||
/**
|
||||
* 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;
|
||||
squadra: string;
|
||||
@@ -131,6 +133,12 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
|
||||
export function formatDataNumerica(iso: string): string {
|
||||
const [anno, mese, giorno] = iso.split("-");
|
||||
return `${giorno}/${mese}/${anno}`;
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
@@ -10,21 +10,75 @@ import type { RigaClassifica } from "./crapp-data";
|
||||
export const CSI_BASE = "https://livescore.csibologna.it";
|
||||
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
|
||||
export const CSI_PROJECT_ID = 767;
|
||||
/**
|
||||
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
|
||||
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
|
||||
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
|
||||
*/
|
||||
export const CSI_COPPA_PROJECT_ID = 848;
|
||||
/** C.R.A.P. Volley sul portale CSI. */
|
||||
export const CSI_TEAM_ID = 3359;
|
||||
export const CSI_GIRONE = "Girone B";
|
||||
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
|
||||
|
||||
/**
|
||||
* Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non
|
||||
* espone il nome della stagione (né nella classifica né nel calendario della squadra):
|
||||
* si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di
|
||||
* settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono.
|
||||
*/
|
||||
export function stagioneDi(dataIso: string): string {
|
||||
const anno = Number(dataIso.slice(0, 4));
|
||||
const mese = Number(dataIso.slice(5, 7));
|
||||
const inizio = mese >= 8 ? anno : anno - 1;
|
||||
return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */
|
||||
export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null {
|
||||
const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d));
|
||||
if (date.length === 0) return null;
|
||||
return stagioneDi(date.reduce((a, b) => (a < b ? a : b)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in
|
||||
* locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato.
|
||||
*/
|
||||
export function raggruppaPerStagione<T extends { data: string }>(
|
||||
match: readonly T[],
|
||||
): Array<{ stagione: string; match: T[] }> {
|
||||
const gruppi: Array<{ stagione: string; match: T[] }> = [];
|
||||
for (const m of match) {
|
||||
const stagione = stagioneDi(m.data);
|
||||
const gruppo = gruppi.find((g) => g.stagione === stagione);
|
||||
if (gruppo) gruppo.match.push(m);
|
||||
else gruppi.push({ stagione, match: [m] });
|
||||
}
|
||||
return gruppi;
|
||||
}
|
||||
|
||||
export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
|
||||
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
|
||||
export const urlPartite = (teamId = CSI_TEAM_ID) =>
|
||||
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
|
||||
/** Info generali della gara (giornata, pubblico ammesso). */
|
||||
export const urlPartitaInfo = (matchId: string) =>
|
||||
`${CSI_BASE}/components/match-main.php?match_id=${matchId}`;
|
||||
/** Formazioni titolari/panchina/staff di entrambe le squadre. */
|
||||
export const urlPartitaFormazioni = (matchId: string) =>
|
||||
`${CSI_BASE}/components/match-players.php?match_id=${matchId}`;
|
||||
/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */
|
||||
export const urlPartitaPrecedenti = (matchId: string) =>
|
||||
`${CSI_BASE}/components/match-stats.php?match_id=${matchId}`;
|
||||
|
||||
export type PartitaCsi = {
|
||||
id: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
avversario: string;
|
||||
/** URL del logo avversario sul portale CSI, vuoto se non presente. */
|
||||
logoAvversario: string;
|
||||
casa: boolean;
|
||||
/** null finché la gara non è stata giocata. */
|
||||
setNostri: number | null;
|
||||
@@ -32,13 +86,60 @@ export type PartitaCsi = {
|
||||
parziali: Array<[number, number]>;
|
||||
campo: string;
|
||||
competizione: string;
|
||||
/** Es. "Girone B". */
|
||||
girone: string;
|
||||
/** Es. "5/XEB". */
|
||||
numeroGara: string;
|
||||
arbitro: string;
|
||||
/** Referto ufficiale su livescore.csibologna.it. */
|
||||
link: string;
|
||||
};
|
||||
|
||||
export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string };
|
||||
export type StaffFormazione = { nome: string; ruolo: string };
|
||||
export type FormazioneSquadra = {
|
||||
squadra: string;
|
||||
titolari: GiocatoreFormazione[];
|
||||
panchina: GiocatoreFormazione[];
|
||||
staff: StaffFormazione[];
|
||||
};
|
||||
|
||||
export type PrecedentiCsi = {
|
||||
totale: number;
|
||||
vinteNoi: number;
|
||||
vinteAvversario: number;
|
||||
casaNoi: number;
|
||||
casaAvversario: number;
|
||||
fuoriNoi: number;
|
||||
fuoriAvversario: number;
|
||||
/** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */
|
||||
probabilitaNoi: number | null;
|
||||
probabilitaAvversario: number | null;
|
||||
};
|
||||
|
||||
export type DettaglioPartitaCsi = {
|
||||
/** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */
|
||||
giornata: string;
|
||||
/**
|
||||
* Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome
|
||||
* della palestra o altro — non ha un formato fisso, va mostrato così com'è. Vuoto se assente.
|
||||
*/
|
||||
nota: string;
|
||||
/** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */
|
||||
formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null;
|
||||
/** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */
|
||||
precedenti: PrecedentiCsi | null;
|
||||
};
|
||||
|
||||
export type DatiCsi = {
|
||||
classifica: RigaClassifica[];
|
||||
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
|
||||
classificaCoppa: RigaClassifica[];
|
||||
partite: PartitaCsi[];
|
||||
girone: string;
|
||||
aggiornato: string;
|
||||
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
|
||||
formatoSospetto: boolean;
|
||||
};
|
||||
|
||||
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
|
||||
@@ -107,11 +208,17 @@ type EventoCsi = {
|
||||
id?: number | string;
|
||||
start?: string;
|
||||
team1?: string;
|
||||
team1_logo?: string;
|
||||
team2?: string;
|
||||
team2_logo?: string;
|
||||
result?: string;
|
||||
partials?: string;
|
||||
field?: string;
|
||||
project?: string;
|
||||
group?: string;
|
||||
match_number?: string;
|
||||
referees?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
function punteggio(result: string | undefined): [number, number] | null {
|
||||
@@ -146,12 +253,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
|
||||
data: dataIso,
|
||||
ora: (oraIso ?? "").slice(0, 5),
|
||||
avversario: casa ? team2 : team1,
|
||||
logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "",
|
||||
casa,
|
||||
setNostri: set ? (casa ? set[0] : set[1]) : null,
|
||||
setLoro: set ? (casa ? set[1] : set[0]) : null,
|
||||
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
|
||||
campo: testo(evento.field ?? ""),
|
||||
competizione: testo(evento.project ?? ""),
|
||||
girone: testo(evento.group ?? ""),
|
||||
numeroGara: testo(evento.match_number ?? ""),
|
||||
arbitro: testo(evento.referees ?? ""),
|
||||
link: evento.link?.trim() ?? "",
|
||||
});
|
||||
}
|
||||
return partite.sort((a, b) => b.data.localeCompare(a.data));
|
||||
@@ -162,15 +274,165 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
|
||||
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
|
||||
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
|
||||
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
|
||||
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
|
||||
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
|
||||
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
|
||||
* silenzioso — vedi "Limiti noti" in docs/modules/collegamento-csi.md.
|
||||
*/
|
||||
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
|
||||
if (!Array.isArray(eventiGrezzi)) return true;
|
||||
return eventiGrezzi.length > 0 && partite.length === 0;
|
||||
}
|
||||
|
||||
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
|
||||
export function matchDaPartitaCsi(p: PartitaCsi) {
|
||||
return {
|
||||
id: p.id,
|
||||
data: p.data,
|
||||
avversario: p.avversario,
|
||||
logoAvversario: p.logoAvversario,
|
||||
casa: p.casa,
|
||||
setNostri: p.setNostri ?? 0,
|
||||
setLoro: p.setLoro ?? 0,
|
||||
parziali: p.parziali,
|
||||
campo: p.campo,
|
||||
girone: p.girone,
|
||||
numeroGara: p.numeroGara,
|
||||
arbitro: p.arbitro,
|
||||
link: p.link,
|
||||
};
|
||||
}
|
||||
|
||||
/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */
|
||||
export function parseInfoPartita(html: string): { giornata: string; nota: string } {
|
||||
const giornataM = /(\d+)\s*<sup>a<\/sup>\s*Giornata/.exec(html);
|
||||
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
|
||||
return {
|
||||
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
|
||||
nota: notaM ? testo(notaM[1] ?? "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, in
|
||||
* ordine titolari → divisore "A DISPOSIZIONE" → panchina → divisore "STAFF" → staff. I
|
||||
* membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia.
|
||||
*/
|
||||
function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null {
|
||||
const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco);
|
||||
const squadra = nomeM ? testo(nomeM[1] ?? "") : "";
|
||||
if (!squadra) return null;
|
||||
|
||||
const titolari: GiocatoreFormazione[] = [];
|
||||
const panchina: GiocatoreFormazione[] = [];
|
||||
const staff: StaffFormazione[] = [];
|
||||
let sezione: "titolari" | "panchina" | "staff" = "titolari";
|
||||
for (const voce of blocco.match(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
|
||||
if (/A DISPOSIZIONE/.test(voce)) {
|
||||
sezione = "panchina";
|
||||
continue;
|
||||
}
|
||||
if (/STAFF/.test(voce)) {
|
||||
sezione = "staff";
|
||||
continue;
|
||||
}
|
||||
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
|
||||
if (!nomePersonaM) continue;
|
||||
const nome = testo(nomePersonaM[1] ?? "");
|
||||
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
|
||||
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
|
||||
if (sezione === "staff") {
|
||||
staff.push({ nome, ruolo });
|
||||
continue;
|
||||
}
|
||||
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
|
||||
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
|
||||
(sezione === "titolari" ? titolari : panchina).push(giocatore);
|
||||
}
|
||||
return { squadra, titolari, panchina, staff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
|
||||
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
|
||||
* `<!-- SQUADRA OSPITE -->`; qui si riconosce la nostra tramite `isNostraSquadra()`
|
||||
* invece di assumere un ordine fisso.
|
||||
*/
|
||||
export function parseFormazioni(
|
||||
html: string,
|
||||
): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null {
|
||||
const idx = html.indexOf("SQUADRA OSPITE");
|
||||
if (idx === -1) return null;
|
||||
const primo = parseBloccoSquadraFormazione(html.slice(0, idx));
|
||||
const secondo = parseBloccoSquadraFormazione(html.slice(idx));
|
||||
if (!primo || !secondo) return null;
|
||||
const noi = isNostraSquadra(primo.squadra)
|
||||
? primo
|
||||
: isNostraSquadra(secondo.squadra)
|
||||
? secondo
|
||||
: null;
|
||||
if (!noi) return null;
|
||||
return { noi, avversario: noi === primo ? secondo : primo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di
|
||||
* riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare)
|
||||
* riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si
|
||||
* riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in
|
||||
* `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe
|
||||
* vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente.
|
||||
*/
|
||||
export function parsePrecedenti(html: string): PrecedentiCsi | null {
|
||||
const idxCasa = html.indexOf("Squadra casa");
|
||||
const idxOspite = html.indexOf("Squadra ospite");
|
||||
if (idxCasa === -1 || idxOspite === -1) return null;
|
||||
const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
|
||||
html.slice(idxCasa, idxOspite),
|
||||
);
|
||||
const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
|
||||
html.slice(idxOspite),
|
||||
);
|
||||
const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : "";
|
||||
const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : "";
|
||||
const noiECasa = isNostraSquadra(nomeCasa);
|
||||
if (!noiECasa && !isNostraSquadra(nomeOspite)) return null;
|
||||
|
||||
const totaleM = /Precedenti:<\/span>\s*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
|
||||
const vittorieM =
|
||||
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||
const inCasaM =
|
||||
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(
|
||||
html,
|
||||
);
|
||||
// Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso)
|
||||
// segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la
|
||||
// classe CSS per capire quale percentuale è "nostra".
|
||||
const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) =>
|
||||
Number(m[1]),
|
||||
);
|
||||
const [probCasa, probOspite] = percentuali;
|
||||
|
||||
// I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara:
|
||||
// `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`.
|
||||
const casaIndice = noiECasa ? 1 : 2;
|
||||
const ospiteIndice = noiECasa ? 2 : 1;
|
||||
const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0);
|
||||
|
||||
return {
|
||||
totale: totaleM ? Number(totaleM[1]) : 0,
|
||||
vinteNoi: numero(vittorieM, casaIndice),
|
||||
vinteAvversario: numero(vittorieM, ospiteIndice),
|
||||
casaNoi: numero(inCasaM, casaIndice),
|
||||
casaAvversario: numero(inCasaM, ospiteIndice),
|
||||
fuoriNoi: numero(fuoriM, casaIndice),
|
||||
fuoriAvversario: numero(fuoriM, ospiteIndice),
|
||||
probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null,
|
||||
probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { DettaglioPartitaCsi } from "./csi-core";
|
||||
|
||||
/**
|
||||
* Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in
|
||||
* più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il
|
||||
* dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md.
|
||||
*/
|
||||
export function useCsiPartita(matchId: string | undefined) {
|
||||
return useQuery<DettaglioPartitaCsi>({
|
||||
queryKey: ["csi-partita", matchId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/public/csi-partita/${matchId}`);
|
||||
if (!res.ok) throw new Error("csi-partita non disponibile");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!matchId,
|
||||
staleTime: 6 * 60 * 60_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
+13
-3
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
|
||||
id: nuovoIdEvento(),
|
||||
tipo: "allenamento",
|
||||
titolo: "",
|
||||
luogo: "Palestra Comunale",
|
||||
luogo: "",
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
ora: "20:30",
|
||||
note: "",
|
||||
@@ -162,8 +162,18 @@ export function useEliminaEvento() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
|
||||
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
|
||||
/**
|
||||
* Compleanni della rosa, come eventi di calendario dell'anno indicato.
|
||||
*
|
||||
* Prende solo i tre campi che usa (non l'intero `Giocatore`): il Calendario li
|
||||
* legge da un'anagrafica leggera per non tirarsi dietro tutte le statistiche
|
||||
* (MVP, pagelle, cacche, palloni, infortuni) di `useRosa` solo per le date di
|
||||
* nascita.
|
||||
*/
|
||||
export function compleanniEventi(
|
||||
rosa: Array<Pick<Giocatore, "id" | "nome" | "nascita">>,
|
||||
anno = new Date().getFullYear(),
|
||||
): Evento[] {
|
||||
return rosa
|
||||
.filter((g) => g.nascita)
|
||||
.map((g) => {
|
||||
|
||||
+106
-25
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
|
||||
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
|
||||
* la migrazione non è completa (DD-016 regola 1).
|
||||
*/
|
||||
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
|
||||
export type TipoMembro = "giocatore" | "allenatore";
|
||||
|
||||
export type GiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
authUserId: string | null;
|
||||
@@ -18,19 +22,24 @@ export type GiocatoreSquadra = {
|
||||
email: string | null;
|
||||
numeroTessera: string | null;
|
||||
dataTessera: string | null;
|
||||
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
export type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
numero: number | null;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
attivo: boolean;
|
||||
email: string | null;
|
||||
numero_tessera: string | null;
|
||||
data_tessera: string | null;
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||
@@ -50,6 +59,8 @@ export function rosaFallback(): GiocatoreSquadra[] {
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: g.nascita,
|
||||
tipo: "giocatore" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -57,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
|
||||
return `${g.nome} ${g.cognome}`.trim();
|
||||
}
|
||||
|
||||
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
|
||||
return g?.tipo === "allenatore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
|
||||
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
|
||||
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
|
||||
*/
|
||||
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
|
||||
return g.attivo && g.tipo === "giocatore";
|
||||
}
|
||||
|
||||
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
|
||||
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
|
||||
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
|
||||
}
|
||||
|
||||
/** Lo slot già collegato a questo account, se esiste. */
|
||||
export function slotDi(
|
||||
righe: GiocatoreSquadra[],
|
||||
@@ -77,7 +106,7 @@ export function slotPerEmail(
|
||||
}
|
||||
|
||||
export const COLONNE_SQUADRA =
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
@@ -85,13 +114,15 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
id: r.id,
|
||||
nome: r.nome,
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
attivo: r.attivo,
|
||||
email: r.email,
|
||||
numeroTessera: r.numero_tessera,
|
||||
dataTessera: r.data_tessera,
|
||||
nascita: r.nascita,
|
||||
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +146,10 @@ export function useGiocatoriSquadra() {
|
||||
|
||||
/** 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">;
|
||||
export type DatiSquadra = Pick<
|
||||
GiocatoreSquadra,
|
||||
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
@@ -125,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
|
||||
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.";
|
||||
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
|
||||
if (dati.tipo === "allenatore") {
|
||||
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
|
||||
return null;
|
||||
}
|
||||
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.";
|
||||
@@ -147,7 +186,19 @@ export function numeroGiaUsato(
|
||||
giocatoreId: string,
|
||||
numero: number,
|
||||
): boolean {
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
|
||||
function rigaDatiSquadra(dati: DatiSquadra) {
|
||||
const allenatore = dati.tipo === "allenatore";
|
||||
return {
|
||||
nome: dati.nome.trim(),
|
||||
cognome: dati.cognome.trim(),
|
||||
numero: allenatore ? null : dati.numero,
|
||||
ruolo: allenatore ? "" : dati.ruolo.trim(),
|
||||
email: dati.email?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
@@ -155,19 +206,13 @@ 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 riga = rigaDatiSquadra(input.dati);
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.update(riga)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
@@ -219,19 +264,55 @@ export function useSalvaTesseramento() {
|
||||
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,
|
||||
});
|
||||
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
|
||||
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
|
||||
if (error) throw error;
|
||||
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
|
||||
return {
|
||||
...riga,
|
||||
numero: riga.numero ?? 0,
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY });
|
||||
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
|
||||
// (`.order("cognome").order("nome")`).
|
||||
onSuccess: (nuovo) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
[...(prec ?? []), nuovo].sort(
|
||||
(a, b) => a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
|
||||
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
|
||||
*/
|
||||
export function useSalvaNomeAllenatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
|
||||
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
|
||||
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
|
||||
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
|
||||
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)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+45
-13
@@ -1,13 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { useEventi } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
import { useRispostePresenze, type MappaPresenze } from "./presenze";
|
||||
|
||||
/** giocatoreId -> numero di eventi (allenamenti + partite) con stato "infortunato". */
|
||||
export type ContoInfortuni = Record<string, number>;
|
||||
|
||||
function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
|
||||
/**
|
||||
* Un giocatore può segnarsi infortunato o in ritardo anche su un evento futuro
|
||||
* (l'UI lo permette finché l'evento non è passato): finché quell'evento non è
|
||||
* avvenuto davvero non deve contare per i badge, altrimenti si sbloccherebbero
|
||||
* in anticipo. Un evento non più in `eventi` (es. cancellato) non viene contato:
|
||||
* non potendo verificarne la data, si esclude per prudenza.
|
||||
*/
|
||||
function contaStato(
|
||||
presenze: MappaPresenze,
|
||||
stato: string,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): ContoInfortuni {
|
||||
const dataPerEvento = new Map(eventi.map((e) => [e.id, e.data]));
|
||||
const out: ContoInfortuni = {};
|
||||
for (const eventoId of Object.keys(presenze)) {
|
||||
const dataEvento = dataPerEvento.get(eventoId);
|
||||
if (dataEvento === undefined || dataEvento >= oggi) continue;
|
||||
const evento = presenze[eventoId] ?? {};
|
||||
for (const giocatoreId of Object.keys(evento)) {
|
||||
if (evento[giocatoreId] === stato) out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
|
||||
@@ -16,14 +34,22 @@ function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Conta gli infortuni dalla mappa presenze già in cache: ogni evento vale una volta sola. */
|
||||
export function contaInfortuni(presenze: MappaPresenze): ContoInfortuni {
|
||||
return contaStato(presenze, "infortunato");
|
||||
/** Conta gli infortuni dalla mappa presenze già in cache: ogni evento passato vale una volta sola. */
|
||||
export function contaInfortuni(
|
||||
presenze: MappaPresenze,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): ContoInfortuni {
|
||||
return contaStato(presenze, "infortunato", eventi, oggi);
|
||||
}
|
||||
|
||||
/** Conta i ritardi dalla stessa mappa presenze: ogni evento vale una volta sola. */
|
||||
export function contaRitardi(presenze: MappaPresenze): ContoInfortuni {
|
||||
return contaStato(presenze, "ritardo");
|
||||
/** Conta i ritardi dalla stessa mappa presenze: ogni evento passato vale una volta sola. */
|
||||
export function contaRitardi(
|
||||
presenze: MappaPresenze,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): ContoInfortuni {
|
||||
return contaStato(presenze, "ritardo", eventi, oggi);
|
||||
}
|
||||
|
||||
export function conInfortuni<T extends Giocatore>(
|
||||
@@ -38,23 +64,29 @@ export function conInfortuni<T extends Giocatore>(
|
||||
};
|
||||
}
|
||||
|
||||
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
|
||||
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
|
||||
export function useInfortuni(): ContoInfortuni {
|
||||
const { presenze } = useRispostePresenze();
|
||||
return useMemo(() => contaInfortuni(presenze), [presenze]);
|
||||
const { eventi } = useEventi();
|
||||
return useMemo(() => contaInfortuni(presenze, eventi), [presenze, eventi]);
|
||||
}
|
||||
|
||||
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
|
||||
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
|
||||
export function useRitardi(): ContoInfortuni {
|
||||
const { presenze } = useRispostePresenze();
|
||||
return useMemo(() => contaRitardi(presenze), [presenze]);
|
||||
const { eventi } = useEventi();
|
||||
return useMemo(() => contaRitardi(presenze, eventi), [presenze, eventi]);
|
||||
}
|
||||
|
||||
/** Un solo hook per entrambi i conteggi: evita hook extra nei componenti. */
|
||||
export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: ContoInfortuni } {
|
||||
const { presenze } = useRispostePresenze();
|
||||
const { eventi } = useEventi();
|
||||
return useMemo(
|
||||
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
|
||||
[presenze],
|
||||
() => ({
|
||||
infortuni: contaInfortuni(presenze, eventi),
|
||||
ritardi: contaRitardi(presenze, eventi),
|
||||
}),
|
||||
[presenze, eventi],
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
type LovableErrorOptions = {
|
||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
||||
handled?: boolean;
|
||||
severity?: "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
type LovableEvents = {
|
||||
captureException?: (
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
options?: LovableErrorOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lovableEvents?: LovableEvents;
|
||||
__lovableReportRuntimeError?: (payload: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
filename?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.__lovableEvents?.captureException?.(
|
||||
error,
|
||||
{
|
||||
source: "react_error_boundary",
|
||||
route: window.location.pathname,
|
||||
...context,
|
||||
},
|
||||
{
|
||||
mechanism: "react_error_boundary",
|
||||
handled: false,
|
||||
severity: "error",
|
||||
},
|
||||
);
|
||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
||||
// which is present only inside the editor preview.
|
||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
||||
// opaque "[object Response]", so pull out the status and URL instead.
|
||||
const message =
|
||||
error instanceof Response
|
||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
window.__lovableReportRuntimeError?.({
|
||||
message,
|
||||
...(stack !== undefined && { stack }),
|
||||
filename: window.location.pathname,
|
||||
});
|
||||
}
|
||||
+31
-4
@@ -10,6 +10,16 @@ export type VotoMvp = {
|
||||
|
||||
const CHIAVE = ["mvp-voti"] as const;
|
||||
|
||||
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||
export const ORE_ATTESA_MVP = 2;
|
||||
|
||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
|
||||
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
|
||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||
if (Number.isNaN(inizio.getTime())) return false;
|
||||
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
|
||||
}
|
||||
|
||||
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
|
||||
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
||||
export function useVotiMvp() {
|
||||
@@ -50,6 +60,9 @@ export function useVotaMvp() {
|
||||
|
||||
export type ConteggioMvp = { id: string; nome: string; voti: number };
|
||||
|
||||
/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */
|
||||
export const VOTI_MINIMI_MVP = 2;
|
||||
|
||||
/** Conteggio voti di una partita, dal più votato. */
|
||||
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
||||
const map = new Map<string, ConteggioMvp>();
|
||||
@@ -73,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [matchId] of perMatch) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
|
||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
out[matchId] = top[0]!.nome;
|
||||
}
|
||||
}
|
||||
@@ -85,13 +103,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
||||
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
|
||||
}
|
||||
|
||||
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
|
||||
/**
|
||||
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
|
||||
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
|
||||
* restituisce una mappa vuota.
|
||||
*/
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
||||
for (const matchId of matchIds) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
const id = top[0]!.id;
|
||||
out[id] = (out[id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { intestazioniAutenticate } from "./auth";
|
||||
|
||||
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
|
||||
|
||||
async function fetchNotificheAttive(): Promise<Set<string>> {
|
||||
const res = await fetch("/api/public/notifiche-attive", {
|
||||
headers: await intestazioniAutenticate(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
|
||||
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
|
||||
return new Set(giocatoreIds);
|
||||
}
|
||||
|
||||
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
|
||||
export function useNotificheAttive() {
|
||||
return useQuery({
|
||||
queryKey: NOTIFICHE_ATTIVE_KEY,
|
||||
queryFn: fetchNotificheAttive,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
|
||||
export function useInviaNotifica() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
|
||||
const res = await fetch("/api/public/notifica-personalizzata", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ messaggio, giocatoreId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Impossibile inviare la notifica");
|
||||
return (await res.json()) as { inviate: number; destinatari: number };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
|
||||
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
|
||||
return [...new Set(righe.map((r) => r.giocatore_id))];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { useGiocatoreId } from "./user-store";
|
||||
|
||||
/**
|
||||
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
|
||||
* lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
|
||||
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
|
||||
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
|
||||
* parallelo alla push esistente). Qui si legge e si segna come letto, con
|
||||
* `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
|
||||
* client-nuove-tabelle.ts).
|
||||
*/
|
||||
export type TipoNotifica =
|
||||
| "admin"
|
||||
| "evento_promemoria_24h"
|
||||
| "evento_promemoria_3h"
|
||||
| "turno_palloni"
|
||||
| "turno_palloni_12h"
|
||||
| "turno_palloni_6h"
|
||||
| "turno_palloni_3h"
|
||||
| "turno_palloni_revocato"
|
||||
| "sollecita_presenze"
|
||||
| "sollecita_presenze_24h"
|
||||
| "sollecita_presenze_12h"
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche";
|
||||
|
||||
export type NotificaUtente = {
|
||||
id: string;
|
||||
tipo: TipoNotifica;
|
||||
titolo: string;
|
||||
corpo: string;
|
||||
eventoId: string | null;
|
||||
letta: boolean;
|
||||
creataIl: string;
|
||||
};
|
||||
|
||||
export type RigaNotifica = {
|
||||
id: string;
|
||||
tipo: TipoNotifica;
|
||||
titolo: string;
|
||||
corpo: string;
|
||||
evento_id: string | null;
|
||||
letta: boolean;
|
||||
creato_il: string;
|
||||
};
|
||||
|
||||
const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il";
|
||||
|
||||
/** Conversione riga database -> modello applicativo. */
|
||||
export function daRiga(r: RigaNotifica): NotificaUtente {
|
||||
return {
|
||||
id: r.id,
|
||||
tipo: r.tipo,
|
||||
titolo: r.titolo,
|
||||
corpo: r.corpo,
|
||||
eventoId: r.evento_id,
|
||||
letta: r.letta,
|
||||
creataIl: r.creato_il,
|
||||
};
|
||||
}
|
||||
|
||||
function chiaveNotifiche(giocatoreId: string | null) {
|
||||
return ["notifiche-utente", giocatoreId] as const;
|
||||
}
|
||||
|
||||
/** Ultime notifiche del giocatore selezionato, più recenti per prime. */
|
||||
export function useNotificheMie() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const query = useQuery({
|
||||
queryKey: chiaveNotifiche(giocatoreId),
|
||||
queryFn: async (): Promise<NotificaUtente[]> => {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("notifiche_utente")
|
||||
.select(COLONNE)
|
||||
.eq("giocatore_id", giocatoreId)
|
||||
.order("creato_il", { ascending: false })
|
||||
.limit(30);
|
||||
if (error) throw error;
|
||||
return ((data ?? []) as RigaNotifica[]).map(daRiga);
|
||||
},
|
||||
enabled: !!giocatoreId,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const notifiche = query.data ?? [];
|
||||
return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length };
|
||||
}
|
||||
|
||||
/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */
|
||||
export function useSegnaLette() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!giocatoreId) return;
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("notifiche_utente")
|
||||
.update({ letta: true })
|
||||
.eq("giocatore_id", giocatoreId)
|
||||
.eq("letta", false);
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
|
||||
(prec ?? []).map((n) => ({ ...n, letta: true })),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A
|
||||
* differenza di "segna come letta" qui la riga sparisce anche dal database, non solo
|
||||
* dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è
|
||||
* l'unico modo per un giocatore di toglierla di mezzo per sempre.
|
||||
*/
|
||||
export function useEliminaNotifica() {
|
||||
const giocatoreId = useGiocatoreId();
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
|
||||
(prec ?? []).filter((n) => n.id !== id),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */
|
||||
export function contatoreBadge(nonLette: number): string {
|
||||
return nonLette > 9 ? "9+" : String(nonLette);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette
|
||||
* resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più
|
||||
* raggiungibili per eliminarle. Senza notifiche non c'è (`null`).
|
||||
*/
|
||||
export function pallinoNotifiche(
|
||||
totale: number,
|
||||
nonLette: number,
|
||||
): { testo: string; daLeggere: boolean } | null {
|
||||
if (totale === 0) return null;
|
||||
if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true };
|
||||
return { testo: contatoreBadge(totale), daLeggere: false };
|
||||
}
|
||||
+34
-11
@@ -26,11 +26,32 @@ export type ContestoObiettivi = {
|
||||
|
||||
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
|
||||
|
||||
const MESE = "2026-08";
|
||||
/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */
|
||||
function meseCorrente(oggi: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
||||
}
|
||||
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
|
||||
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
||||
function aMese(oggi: Date): string {
|
||||
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
||||
oggi,
|
||||
);
|
||||
const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a";
|
||||
return `${preposizione} ${nome}`;
|
||||
}
|
||||
|
||||
/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */
|
||||
function fineMese(oggi: Date): string {
|
||||
const mese = meseCorrente(oggi);
|
||||
const anno = Number(mese.slice(0, 4));
|
||||
const numeroMese = Number(mese.slice(5, 7));
|
||||
const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate();
|
||||
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
|
||||
const delMese = ctx.eventi.filter(
|
||||
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||
);
|
||||
if (delMese.length === 0 || rosaSize === 0) return 0;
|
||||
const posti = delMese.length * rosaSize;
|
||||
@@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
|
||||
export function obiettiviSquadra(
|
||||
rosa: Giocatore[] = giocatori,
|
||||
ctx: ContestoObiettivi = contestoVuoto,
|
||||
oggi: Date = new Date(),
|
||||
): ObiettivoSquadra[] {
|
||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||
const vittorie = ctx.vittorie ?? 0;
|
||||
const mese = meseCorrente(oggi);
|
||||
return [
|
||||
{
|
||||
id: "o1",
|
||||
titolo: "90% di presenze ad agosto",
|
||||
titolo: `90% di presenze ${aMese(oggi)}`,
|
||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||
valore: percentualePresenzeMese(ctx, rosa.length),
|
||||
valore: percentualePresenzeMese(ctx, rosa.length, mese),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-08-31",
|
||||
scadenza: fineMese(oggi),
|
||||
emoji: "📣",
|
||||
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
||||
},
|
||||
@@ -79,7 +102,7 @@ export function obiettiviSquadra(
|
||||
valore: percentualeRisposte(ctx, rosa.length),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-09-30",
|
||||
scadenza: fineMese(oggi),
|
||||
emoji: "⚡",
|
||||
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
||||
},
|
||||
@@ -147,7 +170,7 @@ export function obiettiviSquadra(
|
||||
id: "o5",
|
||||
titolo: "10 vittorie in campionato",
|
||||
descrizione: "Obiettivo stagionale per il podio",
|
||||
valore: vittorie,
|
||||
valore: Math.min(vittorie, 10),
|
||||
target: 10,
|
||||
unita: "vittorie",
|
||||
emoji: "🏆",
|
||||
@@ -157,7 +180,7 @@ export function obiettiviSquadra(
|
||||
id: "o6",
|
||||
titolo: "1 evento di squadra al mese",
|
||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
|
||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
||||
target: 1,
|
||||
unita: "eventi",
|
||||
emoji: "🍕",
|
||||
@@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
|
||||
return Math.min(100, Math.round((o.valore / o.target) * 100));
|
||||
}
|
||||
|
||||
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
|
||||
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
|
||||
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) {
|
||||
return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => {
|
||||
const pa = progressoObiettivo(a);
|
||||
const pb = progressoObiettivo(b);
|
||||
const ca = pa >= 100 ? 1 : 0;
|
||||
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { VOTI_MINIMI_PAGELLA } from "./badges";
|
||||
|
||||
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
||||
export type VotoPagella = {
|
||||
@@ -60,7 +61,11 @@ function arrotonda(n: number) {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
|
||||
/**
|
||||
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
|
||||
* di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti
|
||||
* non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback.
|
||||
*/
|
||||
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
|
||||
const somma: Record<string, { tot: number; n: number }> = {};
|
||||
for (const v of voti) {
|
||||
@@ -90,6 +95,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto
|
||||
* `VOTI_MINIMI_PAGELLA` voti ricevuti, `—` invece della media grezza — stessa soglia del
|
||||
* badge Pagellone, non applicata invece dalle StatTile di profilo e squadra.
|
||||
*/
|
||||
export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" {
|
||||
return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—";
|
||||
}
|
||||
|
||||
/** Media pagelle di tutta la squadra su tutte le partite. */
|
||||
export function mediaSquadra(voti: VotoPagella[]) {
|
||||
if (voti.length === 0) return 0;
|
||||
|
||||
+78
-31
@@ -1,17 +1,26 @@
|
||||
import { formatData } from "./crapp-data";
|
||||
import { formatDataNumerica } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { dataOggi } from "./scout-live";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
|
||||
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
||||
|
||||
/** 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. */
|
||||
/**
|
||||
* Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data e, a parità,
|
||||
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
|
||||
*/
|
||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo !== "compleanno")
|
||||
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||
.slice()
|
||||
.sort((a, b) => a.data.localeCompare(b.data));
|
||||
.sort((a, b) => {
|
||||
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
|
||||
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +28,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
* meno volte (a parità, chi non lo fa da più tempo).
|
||||
*
|
||||
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
|
||||
* non conferma un incaricato a mano. Su partite ed eventi extra la rotazione resta.
|
||||
* non conferma un incaricato a mano. Sulle partite la rotazione resta.
|
||||
*/
|
||||
export function completaTurni(
|
||||
turni: Record<string, string>,
|
||||
@@ -60,13 +69,43 @@ export function completaTurni(
|
||||
return risultato;
|
||||
}
|
||||
|
||||
/** Quante volte ciascun giocatore è incaricato dei palloni. */
|
||||
export function conteggioTurni(turni: Record<string, string>): Record<string, number> {
|
||||
/**
|
||||
* Quante volte ciascun giocatore è incaricato dei palloni, solo per eventi già passati:
|
||||
* un turno assegnato in anticipo per un allenamento futuro non è ancora "portato", quindi
|
||||
* non deve contare finché quell'allenamento non è terminato (stesso criterio `e.data < oggi`
|
||||
* usato per le presenze, così la conta non cambia da sola col passare della giornata).
|
||||
*/
|
||||
export function conteggioTurni(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): Record<string, number> {
|
||||
const passati = new Set(eventi.filter((e) => e.data < oggi).map((e) => e.id));
|
||||
const out: Record<string, number> = {};
|
||||
for (const id of Object.values(turni)) out[id] = (out[id] ?? 0) + 1;
|
||||
for (const [eventoId, giocatoreId] of Object.entries(turni)) {
|
||||
if (!passati.has(eventoId)) continue;
|
||||
out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già
|
||||
* passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza
|
||||
* turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato
|
||||
* i palloni quella volta.
|
||||
*/
|
||||
export function serieConsecutivaPalloni(
|
||||
giocatoreId: string,
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return eventiPalloni(eventi)
|
||||
.filter((e) => e.data < oggi && turni[e.id])
|
||||
.reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0);
|
||||
}
|
||||
|
||||
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
|
||||
return eventiPalloni(eventi).filter((e) => e.data === isoData);
|
||||
}
|
||||
@@ -84,10 +123,9 @@ export function eventoSuccessivo(eventi: Evento[], eventoId: string): Evento | u
|
||||
return i >= 0 ? lista[i + 1] : undefined;
|
||||
}
|
||||
|
||||
/** Alias di `dataOggi()`, nel fuso di Roma: qui per non toccare gli import esistenti. */
|
||||
export function oggiISO(d = new Date()): string {
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
return dataOggi(d);
|
||||
}
|
||||
|
||||
/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */
|
||||
@@ -108,11 +146,12 @@ export function destinatariPromemoriaPalloni(
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025).
|
||||
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
|
||||
*
|
||||
* Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin
|
||||
* sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi".
|
||||
* Il testo viaggia cifrato dentro la push, quindi il service worker lo mostra senza rete.
|
||||
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
|
||||
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
|
||||
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
|
||||
* dentro la push, quindi il service worker lo mostra senza rete.
|
||||
*/
|
||||
export function avvisiPalloniEvento(
|
||||
turni: Record<string, string>,
|
||||
@@ -122,29 +161,37 @@ export function avvisiPalloniEvento(
|
||||
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
|
||||
if (!evento) return [];
|
||||
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`;
|
||||
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Tocca a te prendere i palloni",
|
||||
testo: dopo
|
||||
? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.`
|
||||
: `${quando}: a fine evento porti a casa i palloni.`,
|
||||
});
|
||||
}
|
||||
if (!incaricato) return [];
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di portarli.
|
||||
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
|
||||
const intestazione = [
|
||||
`Evento: ${evento.titolo}`,
|
||||
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
|
||||
];
|
||||
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
avvisi.push({
|
||||
giocatoreId: incaricato,
|
||||
titolo: "Turno palloni: incarico assegnato",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: custodia dei palloni al termine dell'evento",
|
||||
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
const precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente && precedente !== incaricato) {
|
||||
avvisi.push({
|
||||
giocatoreId: precedente,
|
||||
titolo: "Porta i palloni",
|
||||
testo: `${quando}: i palloni li hai tu dalla volta scorsa.`,
|
||||
titolo: "Turno palloni: riconsegna",
|
||||
testo: [
|
||||
...intestazione,
|
||||
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +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";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
|
||||
const { eventi } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salvati = query.data ?? {};
|
||||
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
/**
|
||||
* Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore
|
||||
* «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire
|
||||
* «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e
|
||||
* scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API.
|
||||
*/
|
||||
export type RigaPreferenze = { email_notifiche: boolean };
|
||||
|
||||
/** Assenza di riga = valori di default: le email sono attive. */
|
||||
export function emailAttive(riga: RigaPreferenze | null | undefined): boolean {
|
||||
return riga?.email_notifiche ?? true;
|
||||
}
|
||||
|
||||
const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const;
|
||||
|
||||
/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */
|
||||
export function useEmailNotifiche() {
|
||||
const { utenteId } = useSessione();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: chiavePreferenze(utenteId),
|
||||
queryFn: async (): Promise<RigaPreferenze | null> => {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("preferenze_utente")
|
||||
.select("email_notifiche")
|
||||
.eq("auth_user_id", utenteId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return (data as RigaPreferenze | null) ?? null;
|
||||
},
|
||||
enabled: !!utenteId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const imposta = useMutation({
|
||||
mutationFn: async (attive: boolean) => {
|
||||
const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert(
|
||||
{
|
||||
auth_user_id: utenteId,
|
||||
email_notifiche: attive,
|
||||
aggiornata_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "auth_user_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return attive;
|
||||
},
|
||||
onSuccess: (attive) => {
|
||||
const riga: RigaPreferenze = { email_notifiche: attive };
|
||||
qc.setQueryData(chiavePreferenze(utenteId), riga);
|
||||
},
|
||||
});
|
||||
|
||||
return { attive: emailAttive(query.data), pronta: !query.isPending, imposta };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import {
|
||||
presenzeUltimoMese,
|
||||
presenzeUltimoMeseTutti,
|
||||
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
return useMemo(() => {
|
||||
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
|
||||
const idRosa = squadra.filter(inRosa).map((g) => g.id);
|
||||
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
|
||||
}, [eventi, presenze, squadra]);
|
||||
}
|
||||
+145
-37
@@ -1,7 +1,8 @@
|
||||
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 { formatDataNumerica, type Stato } from "./crapp-data";
|
||||
import type { Evento, EventoTipo } from "./eventi";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
|
||||
@@ -14,49 +15,144 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
|
||||
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||
|
||||
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
||||
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
|
||||
const oggi = dataOggi();
|
||||
function eventiContanoPresenze(
|
||||
eventi: Evento[],
|
||||
giocatoreId?: string,
|
||||
oggi = dataOggi(),
|
||||
tipo?: "partita" | "allenamento",
|
||||
) {
|
||||
return eventi.filter(
|
||||
(e) =>
|
||||
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
||||
(tipo === undefined || e.tipo === tipo) &&
|
||||
e.data < oggi &&
|
||||
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
|
||||
/**
|
||||
* Presenze effettive (presente o in ritardo) su eventi CrAPP. Senza eventi rilevanti
|
||||
* restituisce 0. `tipo` filtra a un solo tipo di evento (es. solo partite); di default
|
||||
* conta partite e allenamenti insieme, come il resto delle statistiche di presenza.
|
||||
*/
|
||||
export function contaPresenzeGiocatore(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
oggi: string = dataOggi(),
|
||||
tipo?: "partita" | "allenamento",
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).filter((e) => {
|
||||
return eventiContanoPresenze(eventi, giocatoreId, oggi, tipo).filter((e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
}).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partite (non allenamenti) a cui il giocatore era presente o in ritardo: il dato giusto
|
||||
* per contesti legati alle prestazioni in campo (es. MVP), a differenza di
|
||||
* `totaliEventiGiocatore()` che è il denominatore delle presenze e include gli allenamenti.
|
||||
*/
|
||||
export function contaPartiteGiocate(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return contaPresenzeGiocatore(giocatoreId, eventi, presenze, oggi, "partita");
|
||||
}
|
||||
|
||||
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
|
||||
export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).length;
|
||||
export function totaliEventiGiocatore(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Se "infortunato" va offerto come risposta: sì per allenamenti e partite, no per gli
|
||||
* eventi extra-campo (`"evento"`, es. cena di squadra), dove l'infortunio non è una
|
||||
* risposta pertinente. Resta comunque visibile a chi ce l'ha già (dati storici, o un
|
||||
* evento cambiato tipo dopo la risposta), così può ancora toglierla.
|
||||
*/
|
||||
export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null): boolean {
|
||||
return tipo !== "evento" || statoAttuale === "infortunato";
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento (DD-040): i destinatari dell'evento — i convocati, o tutta la
|
||||
* rosa se `convocati` è vuoto — che non hanno ancora risposto o hanno risposto «forse». Mai gli
|
||||
* allenatori (DD-034). Stessa regola del job automatico del database (`giocatori_destinatari_evento`
|
||||
* più `genera_solleciti_presenze`, migration M26): funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni, chiamata dalla route `/api/public/sollecita-presenze` con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
convocati: string[] = [],
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter(inRosa)
|
||||
.filter((g) => convocati.length === 0 || convocati.includes(g.id))
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
})
|
||||
.map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Titolo e corpo del sollecito, gli stessi del job automatico (migration M26). `da` è il nome di
|
||||
* chi preme il pulsante: compare solo nel sollecito manuale, come riga «Richiesta di».
|
||||
*/
|
||||
export function testoSollecito(
|
||||
evento: Pick<Evento, "titolo" | "data" | "ora" | "luogo">,
|
||||
stato: string | undefined,
|
||||
da?: string,
|
||||
): { titolo: string; testo: string } {
|
||||
const luogo = evento.luogo.trim();
|
||||
return {
|
||||
titolo: `Conferma di partecipazione richiesta: ${evento.titolo}`,
|
||||
testo: [
|
||||
`Data: ${formatDataNumerica(evento.data)}`,
|
||||
`Ora: ${evento.ora}`,
|
||||
...(luogo ? [`Luogo: ${luogo}`] : []),
|
||||
...(da ? [`Richiesta di: ${da}`] : []),
|
||||
`Risposta attuale: ${stato === "forse" ? "forse" : "nessuna"}`,
|
||||
"Azione: indicare presente, assente o in ritardo",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Chi risulta infortunato non ci ha rinunciato: quell'evento è saltato, non conta
|
||||
* né come presenza né come assenza, e la serie resta congelata al valore di prima.
|
||||
*/
|
||||
export function serieConsecutiva(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
tipo?: "partita" | "allenamento",
|
||||
oggi: string = oggiIso(),
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return serieSu(giocatoreId, eventi, oggi, tipo, (e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
});
|
||||
return serieSu(
|
||||
giocatoreId,
|
||||
eventi.filter((e) => presenze[e.id]?.[giocatoreId] !== "infortunato"),
|
||||
oggi,
|
||||
tipo,
|
||||
(e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ORE_24 = 24 * 60 * 60 * 1000;
|
||||
@@ -70,7 +166,7 @@ export function serieConferme(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
tempi: MappaTempiRisposta,
|
||||
oggi: string = oggiIso(),
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return serieSu(
|
||||
giocatoreId,
|
||||
@@ -84,10 +180,6 @@ export function serieConferme(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -96,8 +188,8 @@ function serieSu(
|
||||
tipo: "partita" | "allenamento" | undefined,
|
||||
onorato: (e: Evento) => boolean,
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId)
|
||||
.filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi)
|
||||
return eventiContanoPresenze(eventi, giocatoreId, oggi)
|
||||
.filter((e) => tipo === undefined || e.tipo === tipo)
|
||||
.sort((a, b) => a.data.localeCompare(b.data))
|
||||
.reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0);
|
||||
}
|
||||
@@ -129,6 +221,38 @@ export function usePresenzeEvento(eventoId: string) {
|
||||
return { ...resto, risposte: presenze[eventoId] ?? {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* La cache delle presenze dopo una risposta salvata, senza rileggere il database.
|
||||
*
|
||||
* Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`):
|
||||
*
|
||||
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
||||
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
|
||||
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
|
||||
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||
*/
|
||||
export function conRisposta(
|
||||
prec: LetturaPresenze | undefined,
|
||||
input: { eventoId: string; giocatoreId: string; stato: Stato | null },
|
||||
adesso: string = new Date().toISOString(),
|
||||
): LetturaPresenze {
|
||||
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;
|
||||
istanti[input.giocatoreId] ??= adesso;
|
||||
}
|
||||
presenze[input.eventoId] = stati;
|
||||
tempi[input.eventoId] = istanti;
|
||||
return { presenze, tempi };
|
||||
}
|
||||
|
||||
export function useSalvaPresenza() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -156,23 +280,7 @@ export function useSalvaPresenza() {
|
||||
},
|
||||
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
|
||||
onSuccess: (input) => {
|
||||
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 };
|
||||
});
|
||||
queryClient.setQueryData<LetturaPresenze>(PRESENZE_KEY, (prec) => conRisposta(prec, input));
|
||||
},
|
||||
});
|
||||
}
|
||||
+83
-1
@@ -1,5 +1,5 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
@@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
|
||||
* certificato né foto tessera, che servono al tesseramento dei giocatori.
|
||||
*/
|
||||
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
|
||||
|
||||
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
|
||||
export function completamentoAllenatore(p: Profilo | null | undefined): number {
|
||||
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
|
||||
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
|
||||
}
|
||||
|
||||
export type StatoScadenza = "mancante" | "scaduto" | "valido";
|
||||
|
||||
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
|
||||
@@ -203,3 +215,73 @@ export function csvTesseramento(
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
|
||||
/** Soglia dell'avviso certificati in Home (DD-035). */
|
||||
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
||||
|
||||
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
||||
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
||||
const a = new Date(`${scadenza}T00:00:00`);
|
||||
const b = new Date(`${oggi}T00:00:00`);
|
||||
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
||||
export function formatDataBreve(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
||||
export function testoScadenza(giorni: number): string {
|
||||
if (giorni === 0) return "scade oggi";
|
||||
if (giorni === 1) return "scade domani";
|
||||
return `scade tra ${giorni} giorni`;
|
||||
}
|
||||
|
||||
export type AvvisoCertificato = {
|
||||
giocatoreId: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
scadenza: string;
|
||||
giorni: number;
|
||||
};
|
||||
|
||||
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
||||
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
||||
*/
|
||||
export function avvisiCertificati(
|
||||
rosa: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
oggi: string,
|
||||
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
||||
const scaduti: AvvisoCertificato[] = [];
|
||||
const inScadenza: AvvisoCertificato[] = [];
|
||||
for (const g of rosa) {
|
||||
if (!inRosa(g)) continue;
|
||||
const p = profili[g.id];
|
||||
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
||||
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
|
||||
const avviso: AvvisoCertificato = {
|
||||
giocatoreId: g.id,
|
||||
nome: g.nome,
|
||||
cognome: g.cognome,
|
||||
scadenza: p.certificatoScadenza,
|
||||
giorni,
|
||||
};
|
||||
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
||||
}
|
||||
scaduti.sort(confrontaAvvisi);
|
||||
inScadenza.sort(confrontaAvvisi);
|
||||
return { scaduti, inScadenza };
|
||||
}
|
||||
+125
-32
@@ -1,15 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { nascitaPerId, type Giocatore } from "./crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
import { statisticheCacche, useCacche } from "./cacche";
|
||||
import { conteggioTurni } from "./palloni-core";
|
||||
import { conteggioTurni, serieConsecutivaPalloni } from "./palloni-core";
|
||||
import { useTurniPalloni } from "./palloni";
|
||||
import { useInfortuniERitardi } from "./infortuni";
|
||||
import { useGiocatoreId } from "./user-store";
|
||||
import { useEventi } from "./eventi";
|
||||
import {
|
||||
contaPartiteGiocate,
|
||||
contaPresenzeGiocatore,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
@@ -24,11 +25,39 @@ function iniziali(nome: string, cognome: string): string {
|
||||
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
|
||||
* per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
|
||||
* cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
|
||||
* solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi
|
||||
* elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034).
|
||||
*/
|
||||
export function useAnagraficaRosa({
|
||||
conAllenatori = false,
|
||||
}: { conAllenatori?: boolean } = {}): Array<
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||
> {
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
return useMemo(
|
||||
() =>
|
||||
squadra
|
||||
.filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
ruolo: g.ruolo,
|
||||
numero: g.numero,
|
||||
nascita: g.nascita ?? "",
|
||||
})),
|
||||
[squadra, conAllenatori],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
* 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
|
||||
* dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
@@ -36,43 +65,107 @@ export function useRosa(): Giocatore[] {
|
||||
const voti = useVotiMvp();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
const { righe: cacche } = useCacche();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { salvati: turniSalvati } = useTurniPalloni();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||
|
||||
const votiMvp = voti.data ?? [];
|
||||
const votiMvp = voti.data;
|
||||
|
||||
return useMemo(() => {
|
||||
const medie = mediePagelle(pagelle);
|
||||
const statCacche = statisticheCacche(cacche);
|
||||
const palloni = conteggioTurni(turni);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
|
||||
// Solo i turni confermati, non le proposte automatiche di completaTurni(): il badge deve
|
||||
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
|
||||
// scelto per un evento passato senza che nessuno confermasse nulla.
|
||||
const palloni = conteggioTurni(turniSalvati, eventi);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
|
||||
|
||||
return squadra
|
||||
.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]);
|
||||
return squadra.filter(inRosa).map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: g.nascita ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [
|
||||
squadra,
|
||||
votiMvp,
|
||||
pagelle,
|
||||
cacche,
|
||||
turniSalvati,
|
||||
infortuni,
|
||||
ritardi,
|
||||
eventi,
|
||||
mappaPresenze,
|
||||
tempi,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Criteri di ordinamento della classifica interna di Squadra. */
|
||||
export type CriterioClassifica = "presenze" | "mediaVoto" | "mvp" | "palloni" | "cacchePartita";
|
||||
|
||||
/**
|
||||
* Dettaglio mostrato sotto il nome nella classifica interna, coerente col criterio
|
||||
* selezionato: mostrare sempre le "presenze consecutive" aveva senso solo per Presenze,
|
||||
* per gli altri criteri era un dato fuorviante perché scollegato dal valore in classifica.
|
||||
*/
|
||||
export function dettaglioClassifica(
|
||||
g: {
|
||||
streak: number;
|
||||
votiPagella: number;
|
||||
partiteGiocate: number;
|
||||
cacche: number;
|
||||
seriePalloni: number;
|
||||
},
|
||||
criterio: CriterioClassifica,
|
||||
): string {
|
||||
switch (criterio) {
|
||||
case "mediaVoto":
|
||||
return `${g.votiPagella} voti pagella`;
|
||||
case "mvp":
|
||||
return `${g.partiteGiocate} partite giocate`;
|
||||
case "cacchePartita":
|
||||
return `${g.cacche} giornate top`;
|
||||
case "palloni":
|
||||
return `${g.seriePalloni} volte consecutive`;
|
||||
default:
|
||||
return `${g.streak} presenze consecutive`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Posizione in classifica ("dense rank"): a parità di valore i giocatori condividono la
|
||||
* stessa posizione e il numero successivo non salta (1, 1, 2 — non 1, 1, 3). `valori` deve
|
||||
* essere già ordinato in modo decrescente, coerente con l'ordine visualizzato.
|
||||
*/
|
||||
export function classificaRank(valori: number[]): number[] {
|
||||
const rank: number[] = [];
|
||||
for (let i = 0; i < valori.length; i++) {
|
||||
if (i > 0 && valori[i] === valori[i - 1]) {
|
||||
rank.push(rank[i - 1] ?? 1);
|
||||
} else {
|
||||
rank.push((rank[i - 1] ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||
|
||||
+39
-15
@@ -2,33 +2,57 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
||||
export const RUOLI_KEY = ["ruoli-utente"] as const;
|
||||
|
||||
export type Ruolo = "admin" | "allenatore";
|
||||
|
||||
/**
|
||||
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
|
||||
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
|
||||
* Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
|
||||
* altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore`
|
||||
* lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034).
|
||||
*/
|
||||
|
||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
||||
async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
||||
if (!utenteId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utenteId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ["admin", "allenatore"]);
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
return (data ?? []).map((r) => r.role as Ruolo);
|
||||
}
|
||||
|
||||
function useRuoliQuery() {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
return useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoli(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
return useRuoliQuery().data ?? [];
|
||||
}
|
||||
|
||||
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
|
||||
export function useRuoliPronti(): boolean {
|
||||
return !useRuoliQuery().isPending;
|
||||
}
|
||||
|
||||
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;
|
||||
return useRuoli().includes("admin");
|
||||
}
|
||||
|
||||
export function useIsAllenatore(): boolean {
|
||||
return useRuoli().includes("allenatore");
|
||||
}
|
||||
|
||||
/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */
|
||||
export function usePuoGestireEventi(): boolean {
|
||||
const ruoli = useRuoli();
|
||||
return ruoli.includes("admin") || ruoli.includes("allenatore");
|
||||
}
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user