Compare commits
8
Commits
fb6bde279e
...
ea9f17e8d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea9f17e8d2 | ||
|
|
7e65df1d21 | ||
|
|
f149d02e9a | ||
|
|
da669db1eb | ||
|
|
7c1f08d943 | ||
|
|
f687322c3f | ||
|
|
752b300474 | ||
|
|
3d8cb22235 |
@@ -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
|
||||
|
||||
@@ -83,8 +83,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
|
||||
|
||||
+15
-6
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 04/09/2026
|
||||
Ultimo aggiornamento: 06/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -21,7 +21,7 @@ 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`
|
||||
- 23 migration in `supabase/migrations/`, fino a `m12_niente_autovoto`
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
@@ -36,7 +36,8 @@ reali (M9).
|
||||
|
||||
## Database
|
||||
|
||||
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase
|
||||
- Schema v1.0 e migration da M1 a M12 applicate al nuovo Supabase (`m12_niente_autovoto`
|
||||
in produzione dal 06/09/2026, verificata con `npx supabase migration list`)
|
||||
- `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,6 +49,14 @@ 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
|
||||
@@ -125,9 +134,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: tutto quello che era in lavorazione è chiuso, 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), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tanstack_start_ts",
|
||||
"name": "crapp",
|
||||
"dependencies": {
|
||||
"@lovable.dev/cloud-auth-js": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
+75
-200
@@ -1,209 +1,84 @@
|
||||
# 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).
|
||||
Tutte le modifiche significative del progetto, dalla più recente. Il formato segue
|
||||
[Keep a Changelog](https://keepachangelog.com/it/1.1.0/) e la numerazione
|
||||
[Semantic Versioning](https://semver.org/lang/it/): il numero di versione è quello di
|
||||
`package.json`.
|
||||
|
||||
## Versione attuale — agosto 2026
|
||||
L'elenco delle funzionalità disponibili e previste non si ripete qui: sta in
|
||||
[ROADMAP.md](ROADMAP.md), che elenca il _cosa_ senza numeri di versione — quelli stanno solo
|
||||
qui.
|
||||
|
||||
### Le notifiche push arrivano anche ad app chiusa
|
||||
## [Non rilasciato] — 0.9.0
|
||||
|
||||
- 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.
|
||||
Prima versione, pre-release.
|
||||
|
||||
### Lo Scout Live si apre dalla pagina della partita
|
||||
### Aggiunto
|
||||
|
||||
- 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».
|
||||
- Login con Google tramite Supabase Auth e collegamento automatico dell'account al proprio
|
||||
giocatore confrontando l'email (DD-011, DD-018; migration `m5_email_giocatori_squadra`):
|
||||
senza sessione non si entra in nessuna schermata.
|
||||
- Dashboard amministratore `/admin`: stato dei profili, download di documento, certificato e
|
||||
foto tessera, export CSV per il tesseramento, aggiunta e disattivazione dei giocatori
|
||||
(la riga non viene mai eliminata, così presenze, voti e badge restano agganciati al suo id).
|
||||
- Profilo giocatore: dati anagrafici, documento, certificato medico e foto tessera con le
|
||||
relative scadenze (migration `m2_profili_giocatore`, bucket privato), divisi nelle tab
|
||||
Stagione, Documenti e Opzioni — [modules/profilo-giocatore.md](modules/profilo-giocatore.md).
|
||||
- Tracciamento del tesseramento CSI: numero e data di tessera in `giocatori_squadra`, badge e
|
||||
contatore in dashboard (migration `m8_tesseramento_csi`).
|
||||
- Foto profilo condivise tra dispositivi tramite il bucket pubblico `avatar-giocatori`
|
||||
(migration `m6_avatar_giocatori`).
|
||||
- Serie di presenze calcolate sui dati reali (`serieConsecutiva()`), con la colonna
|
||||
`risposto_il` che congela l'istante della **prima** risposta tramite trigger (migration
|
||||
`m9_risposte_presenze_risposto_il`): sblocca la serie "Conferme 24h" e i badge "Risposta
|
||||
lampo" e "Mai un forfait" — [modules/serie-presenze.md](modules/serie-presenze.md).
|
||||
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" passa dalla tabella
|
||||
`scout_sessioni` e le partite concluse vengono archiviate in `scout_partite` (migration
|
||||
`m7_scout_partite`). Si apre dalla sezione «Scout live» di `/partita/$id`, solo il giorno
|
||||
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
|
||||
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
|
||||
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
|
||||
- Sondaggio pre-partita con apertura programmata alle 8:00 del giorno della partita e
|
||||
pulsante «Avvisa tutti del sondaggio» per gli amministratori
|
||||
(`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
|
||||
- Turni palloni con rotazione automatica sulle partite e assegnazione manuale per gli
|
||||
allenamenti, che restano «da assegnare» finché non si sceglie (migration M10).
|
||||
- Notifiche push con il testo cifrato **dentro** la push (`aes128gcm`, RFC 8291), così
|
||||
arrivano anche ad app chiusa e a schermo bloccato (DD-026).
|
||||
- Collegamento CSI: classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
|
||||
(stagione 2025/26, Open Misto Eccellenza, Girone B) —
|
||||
[modules/collegamento-csi.md](modules/collegamento-csi.md).
|
||||
- Note dell'evento visibili in `/allenamento/$id` e `/partita/$id`, con gli a capo mantenuti.
|
||||
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo`: due link che aprono
|
||||
una issue GitHub sul template giusto, senza tabelle né schermate di gestione.
|
||||
- Interfaccia accessibile: contrasto dei token colore sopra 4.5:1, `viewport-fit=cover` e
|
||||
`theme-color` coerenti con un'app chiara, `lang="it"`, `:focus-visible` globale, tocchi da
|
||||
44px, `aria-current`/`aria-pressed`/`aria-controls`/`aria-busy`, niente testo sotto i 12px.
|
||||
- Movimento con molle interrompibili di `motion` al posto delle `@keyframes` a durata fissa,
|
||||
swipe fra i mesi del calendario e barre di progresso che misurano l'avanzamento tra un
|
||||
traguardo e il successivo (DD-021).
|
||||
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun e senza nuove
|
||||
dipendenze: `npm run test` e `npm run test:all`.
|
||||
- Convenzioni interne: primitive condivise (`Card`, `Campo`, `classiInput` in `ui-bits`),
|
||||
cache aggiornata con `setQueryData` invece di rileggere il database dopo ogni scrittura, e
|
||||
logica pura estratta in `src/lib/` con i suoi test.
|
||||
- Infrastruttura di sviluppo: migrazione da Lovable a sviluppo locale, repository GitHub
|
||||
indipendente, deploy automatico su Vercel.
|
||||
|
||||
### Il sondaggio pre-partita apre alle 8:00 del giorno della partita
|
||||
### Sicurezza
|
||||
|
||||
- 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.
|
||||
|
||||
### Le note dell'evento si leggono aprendolo
|
||||
|
||||
- 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.
|
||||
|
||||
### Form eventi: data e ora non sfondano più la card su iOS
|
||||
|
||||
- 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`.
|
||||
|
||||
### Profilo: tab Documenti e Opzioni, barra senza scroll
|
||||
|
||||
- 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.
|
||||
|
||||
### Palloni: allenamenti senza proposta automatica
|
||||
|
||||
- `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.
|
||||
|
||||
### Profilo: etichetta notifiche allineata al comportamento
|
||||
|
||||
- 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`).
|
||||
|
||||
### Revisione dell'interfaccia: accessibilità, movimento, peso
|
||||
|
||||
- **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.
|
||||
|
||||
### Serie di presenze calcolate sui dati reali
|
||||
|
||||
- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e
|
||||
`risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze»
|
||||
del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra»
|
||||
che ne dipendono.
|
||||
- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante
|
||||
della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo
|
||||
l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento).
|
||||
Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge
|
||||
"Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui
|
||||
in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)).
|
||||
- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il
|
||||
successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%,
|
||||
poi 3/6 = 50%).
|
||||
|
||||
### Segnalazioni dal profilo
|
||||
|
||||
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo` → Impostazioni: due
|
||||
link che aprono una issue GitHub sul template giusto
|
||||
(`.github/ISSUE_TEMPLATE/bug_report.yml`, `feature_request.yml`). Nessuna tabella e nessuna
|
||||
schermata di gestione: la segnalazione vive su GitHub
|
||||
(vedi [modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
|
||||
|
||||
### Autenticazione e dashboard amministratore (in produzione)
|
||||
|
||||
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
|
||||
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
|
||||
giocatore stesso (DD-016 regola 2).
|
||||
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
|
||||
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
|
||||
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
|
||||
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
|
||||
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
|
||||
per amministrare.
|
||||
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
|
||||
Applicata in produzione il 03/09/2026, dopo aver impostato l'email di tutta la rosa
|
||||
attiva — il login era già l'unica via d'accesso lato app, quindi il collegamento dei
|
||||
singoli account (che resta un processo continuo a ogni login) non era comunque
|
||||
condizionato da questa migration.
|
||||
- Collegamento automatico al proprio giocatore per email (DD-018, migration
|
||||
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
|
||||
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
|
||||
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
|
||||
riprovare con un altro account.
|
||||
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
|
||||
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
|
||||
riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni
|
||||
giocatore già in rosa.
|
||||
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
|
||||
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
|
||||
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
|
||||
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
|
||||
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
||||
documento, certificato medico e foto tessera con le relative scadenze
|
||||
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
|
||||
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
|
||||
certificato e foto tessera, export CSV per il tesseramento CSI.
|
||||
- Migration `m2_profili_giocatore` (tabella dei profili e bucket privato), additiva.
|
||||
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
|
||||
compila i dati personali al posto di un giocatore e scollega un account da un profilo
|
||||
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
|
||||
policy di M1 e M2 lo consentivano già.
|
||||
- Foto profilo sincronizzate tra dispositivi: da `/profilo` la foto caricata finisce nel
|
||||
bucket pubblico `avatar-giocatori` (migration `m6_avatar_giocatori`) invece che in
|
||||
`localStorage`, così compare per tutta la squadra e non solo su chi l'ha caricata.
|
||||
L'Avatar mostra numero/iniziali finché la foto non è presente.
|
||||
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" ora passa dalla
|
||||
tabella `scout_sessioni` invece che da `localStorage`, quindi due telefoni non possono più
|
||||
prendere il controllo insieme sovrascrivendosi a vicenda. Le partite scoutate concluse
|
||||
vengono archiviate nella nuova tabella `scout_partite` (migration `m7_scout_partite`):
|
||||
prima restavano visibili solo sul telefono di chi aveva chiuso la partita.
|
||||
|
||||
### Test
|
||||
|
||||
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
|
||||
senza nuove dipendenze: `npm run test` e `npm run test:all`.
|
||||
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
|
||||
illeggibile restava bloccata per sempre invece di scadere.
|
||||
|
||||
### Collegamento CSI
|
||||
|
||||
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
|
||||
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
|
||||
- La pagina Campionato non usa più dati dimostrativi.
|
||||
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
|
||||
|
||||
### Infrastruttura
|
||||
|
||||
- Migrazione completa da Lovable a sviluppo locale.
|
||||
- Configurazione Git.
|
||||
- Repository GitHub indipendente.
|
||||
- Deploy automatico tramite Vercel.
|
||||
- Branch main e develop.
|
||||
|
||||
## Versione 1.0 — luglio 2026
|
||||
|
||||
Prima versione usata dalla squadra. Funzionalità incluse: vedi
|
||||
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
|
||||
- Migration `m4_solo_autenticati`: tolto al ruolo `anon` l'accesso alle tabelle dell'app
|
||||
(applicata in produzione il 03/09/2026).
|
||||
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): senza,
|
||||
basterebbe scegliere il nome giusto per amministrare.
|
||||
- Migration `m11_scritture_per_ruolo`: ogni voto è firmato con lo slot collegato all'account
|
||||
(DD-023); il collegamento account → giocatore non è modificabile dal giocatore stesso
|
||||
(DD-016).
|
||||
- Migration `m12_niente_autovoto`: i vincoli `mvp_no_autovoto` e `badge_social_no_autovoto`
|
||||
rifiutano l'auto-voto anche a chi scrive direttamente su PostgREST, come già faceva
|
||||
`pagelle_voti`.
|
||||
- Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è
|
||||
applicativo, non RLS ([modules/mvp.md](modules/mvp.md)).
|
||||
- La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di
|
||||
`badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva
|
||||
`risposto_il` (trigger di `m9`).
|
||||
|
||||
+5
-5
@@ -61,11 +61,11 @@ 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`). |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’e
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- “AI Allenamenti” è in roadmap v1.2, non v1.1.
|
||||
- “AI Allenamenti” è tra le voci ancora da fare in roadmap.
|
||||
- Ogni proposta AI va valutata con la domanda: _chi risparmia tempo e quanto?_
|
||||
|
||||
**Riesame**
|
||||
@@ -887,7 +887,7 @@ come «da verificare lato hosting»: nei fatti quel promemoria non è mai partit
|
||||
funziona e che nessuno chiama.
|
||||
|
||||
Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio
|
||||
(«Nessun cron: l'invio è manuale», CHANGELOG v1.0.6).
|
||||
(«Nessun cron: l'invio è manuale», CHANGELOG).
|
||||
|
||||
**Decisione**
|
||||
Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il
|
||||
|
||||
@@ -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à
|
||||
|
||||
+7
-8
@@ -8,14 +8,12 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
@@ -24,17 +22,18 @@ 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
|
||||
|
||||
+13
-20
@@ -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 è in `main` e finirà nella prima release.
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
@@ -16,32 +18,23 @@ sta facendo adesso.
|
||||
- [x] Pagelle
|
||||
- [x] Obiettivi di squadra
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
|
||||
## Versione 1.1
|
||||
|
||||
Le voci spuntate sono in produzione su `main`. I passaggi di attivazione ancora aperti (per
|
||||
esempio il collegamento dei singoli account) stanno in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
|
||||
certificati resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
|
||||
## Versione 1.2
|
||||
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
## Versione 2.0
|
||||
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica
|
||||
- [x] Risultati campionato
|
||||
|
||||
## 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
|
||||
|
||||
## Idee future
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -33,7 +33,8 @@ badge assegnati per voto dai compagni.
|
||||
- **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.
|
||||
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
||||
@@ -63,8 +64,9 @@ 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.
|
||||
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
|
||||
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
|
||||
applicativo.
|
||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||
browser o dispositivo.
|
||||
|
||||
|
||||
+18
-10
@@ -17,14 +17,24 @@ 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.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
@@ -35,18 +45,16 @@ 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.
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||
vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero
|
||||
presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo
|
||||
applicativi — chi scrive su PostgREST li aggira.
|
||||
- In caso di parità, nessun MVP viene assegnato per quella partita.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -53,7 +53,8 @@ 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"
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
@@ -70,24 +71,25 @@ 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.
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- 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.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
**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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -58,6 +59,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 +115,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**.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tanstack_start_ts",
|
||||
"version": "0.1.0",
|
||||
"name": "crapp",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -7,6 +7,8 @@ export type VoceSottosezione = {
|
||||
id: string;
|
||||
label: string;
|
||||
contenuto: ReactNode;
|
||||
/** Se true, non ripete il titolo della tab sopra il contenuto (es. Classifica già nella barra). */
|
||||
nascondiTitolo?: boolean;
|
||||
};
|
||||
|
||||
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
|
||||
@@ -17,15 +19,18 @@ const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.
|
||||
* 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"`: tab a testo con sottolineatura rossa e senza titolo
|
||||
* ripetuto sotto la barra (Squadra e Classifica CSI). Default `pillole`: restano le
|
||||
* pill e i titoli usati dal Profilo.
|
||||
*/
|
||||
export function BarraSottosezioni({
|
||||
voci,
|
||||
defaultId,
|
||||
variante = "pillole",
|
||||
}: {
|
||||
voci: VoceSottosezione[];
|
||||
defaultId?: string;
|
||||
variante?: "pillole" | "sottolineatura";
|
||||
}) {
|
||||
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
||||
const direzione = useRef(0);
|
||||
@@ -36,6 +41,7 @@ export function BarraSottosezioni({
|
||||
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.
|
||||
@@ -58,11 +64,19 @@ export function BarraSottosezioni({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border bg-background/80 pt-3 backdrop-blur-md">
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-border bg-background",
|
||||
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<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(
|
||||
"flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
sottolineatura ? "gap-0 px-2" : "-mx-0 gap-1.5 px-5 pb-3",
|
||||
)}
|
||||
>
|
||||
{voci.map((v) => {
|
||||
const selezionata = v.id === attiva;
|
||||
@@ -80,13 +94,21 @@ export function BarraSottosezioni({
|
||||
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",
|
||||
"snap-center shrink-0 grow basis-0 touch-manipulation whitespace-nowrap text-xs font-bold uppercase tracking-wide transition-colors",
|
||||
"min-h-11",
|
||||
sottolineatura
|
||||
? cn(
|
||||
"rounded-none border-b-2 px-3 py-3",
|
||||
selezionata
|
||||
? "border-accent text-foreground"
|
||||
: "border-transparent text-muted-foreground",
|
||||
)
|
||||
: cn(
|
||||
"rounded-full px-3.5 py-2",
|
||||
selezionata
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
@@ -113,9 +135,11 @@ 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.nascondiTitolo ? "px-0 pt-0 pb-4" : "px-5 py-4")}
|
||||
>
|
||||
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
|
||||
{voce.nascondiTitolo || sottolineatura ? null : (
|
||||
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
|
||||
)}
|
||||
{voce.contenuto}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,34 @@ 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 { 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 }) {
|
||||
/**
|
||||
* 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;
|
||||
const io = useGiocatoreCorrente();
|
||||
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) => g.attivo && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
@@ -21,6 +40,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 +80,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 +100,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">
|
||||
|
||||
@@ -18,8 +18,9 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -219,19 +219,28 @@ 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({
|
||||
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
|
||||
const riga = {
|
||||
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,
|
||||
});
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
|
||||
if (error) throw error;
|
||||
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
|
||||
return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null };
|
||||
},
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+63
-24
@@ -14,8 +14,7 @@ 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()) {
|
||||
return eventi.filter(
|
||||
(e) =>
|
||||
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
||||
@@ -29,16 +28,40 @@ export function contaPresenzeGiocatore(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).filter((e) => {
|
||||
return eventiContanoPresenze(eventi, giocatoreId, oggi).filter((e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
}).length;
|
||||
}
|
||||
|
||||
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
|
||||
export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).length;
|
||||
export function totaliEventiGiocatore(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
oggi: string = dataOggi(),
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
})
|
||||
.map((g) => g.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +119,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 +152,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 +211,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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+10
-2
@@ -135,7 +135,12 @@ export function useSalvaScoutMatch() {
|
||||
if (error) throw error;
|
||||
return input.match;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
|
||||
// La query ordina per `creato_il` decrescente: la partita appena salvata è la più recente.
|
||||
onSuccess: (match) =>
|
||||
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) => [
|
||||
match,
|
||||
...(prec ?? []).filter((m) => m.id !== match.id),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,7 +152,10 @@ export function useEliminaScoutMatch() {
|
||||
if (error) throw error;
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
|
||||
onSuccess: (id) =>
|
||||
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) =>
|
||||
(prec ?? []).filter((m) => m.id !== id),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { destinatariSollecito } from "@/lib/presenze";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -33,14 +34,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
const destinatari = squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
})
|
||||
.map((g) => g.id);
|
||||
const destinatari = destinatariSollecito(squadra, righe ?? []);
|
||||
|
||||
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
|
||||
|
||||
|
||||
@@ -56,12 +56,13 @@ function Classifica() {
|
||||
const eventoIdPerData = new Map(
|
||||
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
|
||||
);
|
||||
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
|
||||
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
|
||||
const tuttiMatch = csiGiocate.length
|
||||
? csiGiocate.map((p) => ({
|
||||
...matchDaPartitaCsi(p),
|
||||
mvp: mvpPerMatch[p.id] ?? "",
|
||||
scout: false,
|
||||
}))
|
||||
? csiGiocate.map((p) => {
|
||||
const m = matchDaPartitaCsi(p);
|
||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||
})
|
||||
: scoutMatches.map((m) => ({
|
||||
id: m.id,
|
||||
data: m.data,
|
||||
@@ -70,7 +71,7 @@ function Classifica() {
|
||||
setNostri: m.setNostri,
|
||||
setLoro: m.setLoro,
|
||||
parziali: m.parziali,
|
||||
mvp: mvpPerMatch[m.id] ?? "",
|
||||
mvp: mvpPerData(m.data),
|
||||
scout: true,
|
||||
}));
|
||||
|
||||
@@ -83,6 +84,7 @@ function Classifica() {
|
||||
|
||||
<BarraSottosezioni
|
||||
defaultId={tab ?? "classifica"}
|
||||
variante="sottolineatura"
|
||||
voci={[
|
||||
{
|
||||
id: "classifica",
|
||||
|
||||
@@ -49,15 +49,15 @@ function Index() {
|
||||
const votiMvp = useVotiMvp();
|
||||
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
|
||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||
const ultima = csiGiocate[0]
|
||||
? { ...matchDaPartitaCsi(csiGiocate[0]), mvp: mvpPerMatch[csiGiocate[0].id] ?? "" }
|
||||
: scoutMatches[0]
|
||||
? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? "" }
|
||||
: null;
|
||||
const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null);
|
||||
const obiettivi = useObiettivi();
|
||||
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
||||
const eventoUltima = ultima
|
||||
? (eventi.find((e) => e.tipo === "partita" && e.data === ultima.data) ?? null)
|
||||
const eventoUltima = ultimaBase
|
||||
? (eventi.find((e) => e.tipo === "partita" && e.data === ultimaBase.data) ?? null)
|
||||
: null;
|
||||
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
|
||||
const ultima = ultimaBase
|
||||
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
|
||||
: null;
|
||||
|
||||
if (!giocatore) return null;
|
||||
|
||||
@@ -169,14 +169,16 @@ function PartitaDetail() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs font-semibold text-muted-foreground">
|
||||
MVP eletto dalla squadra
|
||||
</p>
|
||||
<VotazioneMvp matchId={match.id} />
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section titolo="MVP eletto dalla squadra">
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<VotazioneMvp evento={evento} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
caricaAvatar,
|
||||
rimuoviAvatar,
|
||||
useAvatarEsiste,
|
||||
useInvalidaAvatarEsiste,
|
||||
useImpostaAvatarEsiste,
|
||||
} from "@/lib/avatar-store";
|
||||
import { SerieGriglia } from "@/components/crapp/SerieCard";
|
||||
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
|
||||
@@ -68,7 +68,7 @@ function Profilo() {
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(g?.id);
|
||||
const invalidaAvatarEsiste = useInvalidaAvatarEsiste();
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
@@ -121,7 +121,7 @@ function Profilo() {
|
||||
try {
|
||||
await caricaAvatar(g.id, file);
|
||||
setBust(Date.now());
|
||||
invalidaAvatarEsiste(g.id);
|
||||
impostaAvatarEsiste(g.id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
@@ -175,7 +175,7 @@ function Profilo() {
|
||||
try {
|
||||
await rimuoviAvatar(g.id);
|
||||
setBust(Date.now());
|
||||
invalidaAvatarEsiste(g.id);
|
||||
impostaAvatarEsiste(g.id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
|
||||
+65
-21
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Cake, ChevronDown, Crown } from "lucide-react";
|
||||
import { Cake, ChevronDown, Crown, SlidersHorizontal, Trophy } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/crapp/ui-bits";
|
||||
import { PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
@@ -13,11 +13,17 @@ import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { partiteGiocate } from "@/lib/csi-core";
|
||||
import { mediaSquadra, usePagelle } from "@/lib/pagelle";
|
||||
import { StatTile } from "@/components/crapp/ui-bits";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
import { BadgeDrawer } from "@/components/crapp/BadgeDrawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
badgeDefs,
|
||||
badgeGiocatore,
|
||||
@@ -80,6 +86,7 @@ function Squadra() {
|
||||
const { voti: pagelle } = usePagelle();
|
||||
const [aperto, setAperto] = useState<string | null>(null);
|
||||
const [criterio, setCriterio] = useState<Criterio>("presenze");
|
||||
const [filtroAperto, setFiltroAperto] = useState(false);
|
||||
const obiettivi = useObiettivi();
|
||||
const team = totaliSquadra(scoutMatches);
|
||||
const mediaPresenze = rosa.length
|
||||
@@ -102,6 +109,7 @@ function Squadra() {
|
||||
|
||||
<BarraSottosezioni
|
||||
defaultId="rosa"
|
||||
variante="sottolineatura"
|
||||
voci={[
|
||||
{
|
||||
id: "rosa",
|
||||
@@ -262,32 +270,68 @@ function Squadra() {
|
||||
{
|
||||
id: "classifica-giocatori",
|
||||
label: "Classifica",
|
||||
nascondiTitolo: true,
|
||||
contenuto: (
|
||||
<>
|
||||
<div className="mb-3 flex flex-nowrap gap-1.5">
|
||||
{criteri.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setCriterio(c.id)}
|
||||
aria-pressed={criterio === c.id}
|
||||
className={cn(
|
||||
"min-h-11 min-w-0 flex-1 truncate rounded-full px-1.5 text-center text-xs font-bold uppercase transition-colors",
|
||||
criterio === c.id
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5">
|
||||
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
|
||||
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<Select
|
||||
value={criterio}
|
||||
onChange={(e) => setCriterio(e.target.value as Criterio)}
|
||||
aria-label="Criterio della classifica interna"
|
||||
className="h-9 rounded-lg border-border bg-background font-semibold shadow-none"
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
{criteri.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltroAperto(true)}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground"
|
||||
aria-label="Scegli criterio classifica"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Classifica per</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6">
|
||||
{criteri.map((c) => (
|
||||
<DrawerClose key={c.id} asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCriterio(c.id)}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
|
||||
criterio === c.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
</DrawerClose>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div className="space-y-2 px-5 pt-3">
|
||||
{ordinati.map((g, i) => (
|
||||
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary font-display text-base">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- M12 — Nessuno si vota da solo, nemmeno parlando con PostgREST.
|
||||
--
|
||||
-- `pagelle_voti` ha il vincolo `pagelle_no_autovoto` fin dalla v1.0. Le altre due votazioni
|
||||
-- no: l'MVP non escludeva l'auto-voto nemmeno in interfaccia (bastava toccare il proprio
|
||||
-- nome nell'elenco), i badge social lo escludevano solo lì. M11 (DD-023) garantisce che il
|
||||
-- voto sia firmato con il proprio `votante_id`, ma non dice niente su chi viene votato:
|
||||
-- eleggersi MVP da soli restava a un POST di distanza, e il titolo finiva in
|
||||
-- `mvpVintiPerGiocatore()` come qualsiasi altro.
|
||||
--
|
||||
-- Le righe già esistenti che violano la regola vanno cancellate prima del vincolo,
|
||||
-- altrimenti l'ALTER fallisce: sono voti che non sarebbero mai dovuti esistere, non dati da
|
||||
-- conservare. In locale non ce n'era nessuna.
|
||||
|
||||
DELETE FROM public.mvp_voti WHERE votante_id = votato_id;
|
||||
DELETE FROM public.badge_social_voti WHERE votante_id = votato_id;
|
||||
|
||||
ALTER TABLE public.mvp_voti
|
||||
ADD CONSTRAINT mvp_no_autovoto CHECK (votante_id <> votato_id);
|
||||
|
||||
ALTER TABLE public.badge_social_voti
|
||||
ADD CONSTRAINT badge_social_no_autovoto CHECK (votante_id <> votato_id);
|
||||
+14
-7
@@ -23,12 +23,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
||||
|
||||
## Struttura
|
||||
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo (`permessi`), accesso alle route di notifica (`permessi-route`) e semantica degli upsert (`scritture`) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`) e le letture lato server delle route push (`lettori-server`) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
## Database locale in Docker
|
||||
|
||||
@@ -44,7 +44,8 @@ npx supabase stop # spegne tutto
|
||||
|
||||
bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle
|
||||
bun test/integration/permessi-route.test.ts # chi può far partire le notifiche
|
||||
bun test/integration/scritture.test.ts # semantica degli upsert
|
||||
bun test/integration/scritture.test.ts # semantica degli upsert e vincoli
|
||||
bun test/integration/lettori-server.test.ts # le letture server delle route push
|
||||
```
|
||||
|
||||
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
|
||||
@@ -55,6 +56,12 @@ Il primo `start` scarica le immagini (qualche minuto), i successivi partono in
|
||||
una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324),
|
||||
non escono dalla macchina.
|
||||
|
||||
`lettori-server` fa la stessa cosa in un altro modo: mette le credenziali locali in
|
||||
`process.env` prima della prima chiamata, perché `client.server.ts` costruisce
|
||||
`supabaseAdmin` pigramente leggendo l'ambiente. Serve a eseguire per davvero le query di
|
||||
`leggiEventi()`/`leggiGiocatoriSquadra()`: nessuna delle due controlla l'errore di PostgREST,
|
||||
quindi una colonna rinominata darebbe zero righe e zero destinatari, in silenzio.
|
||||
|
||||
I test che scrivono **non leggono `.env`**: prendono URL e chiavi da
|
||||
`supabase status` (helper `test/helpers/locale.ts`) e si fermano se l'URL non è
|
||||
`127.0.0.1`. È una cintura di sicurezza, non una comodità: così un `.env`
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* I due lettori lato server contro il database locale:
|
||||
* `bun test/integration/lettori-server.test.ts`.
|
||||
*
|
||||
* `leggiEventi()` e `leggiGiocatoriSquadra()` sono le uniche letture delle tre route che
|
||||
* mandano push: se una colonna elencata nelle costanti `COLONNE`/`COLONNE_SQUADRA` sparisse
|
||||
* o cambiasse nome, PostgREST risponderebbe con un errore, `data` sarebbe `null` e le route
|
||||
* manderebbero le notifiche a zero destinatari — senza che niente si accorga di niente,
|
||||
* perché nessuna delle due controlla l'errore. Qui si esegue la query vera e si verifica che
|
||||
* le righe arrivino mappate.
|
||||
*
|
||||
* Gira solo sullo stack locale (`npx supabase start`): le credenziali arrivano da
|
||||
* `supabase status` e vengono messe in `process.env` **prima** della prima chiamata, perché
|
||||
* `client.server.ts` costruisce il client pigramente leggendo l'ambiente. Così il test non
|
||||
* può parlare con il progetto cloud nemmeno per sbaglio.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
|
||||
if (!locale) {
|
||||
salta("lettori server", "stack locale non attivo (npx supabase start)");
|
||||
riepilogo("lettori server");
|
||||
} else {
|
||||
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||
console.log(`lettori server su ${URL_BASE}`);
|
||||
|
||||
// Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`.
|
||||
process.env["SUPABASE_URL"] = URL_BASE;
|
||||
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||
|
||||
const { leggiEventi } = await import("@/lib/eventi.server");
|
||||
const { leggiGiocatoriSquadra } = await import("@/lib/giocatori-squadra.server");
|
||||
|
||||
const PREFISSO = "test-lettori";
|
||||
const EVENTO = `${PREFISSO}-e1`;
|
||||
|
||||
const rest = (percorso: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: {
|
||||
apikey: SERVIZIO,
|
||||
Authorization: `Bearer ${SERVIZIO}`,
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const inserito = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: EVENTO,
|
||||
tipo: "partita",
|
||||
titolo: "Lettori server",
|
||||
luogo: "Palestra",
|
||||
data: "2026-01-02",
|
||||
ora: "21:00",
|
||||
note: "",
|
||||
convocati: ["g1", "g2"],
|
||||
campionato: true,
|
||||
casa: false,
|
||||
}),
|
||||
});
|
||||
if (!inserito.ok) throw new Error(`preparazione fallita: ${await inserito.text()}`);
|
||||
|
||||
await prova("leggiEventi() mappa le colonne che le route si aspettano", async () => {
|
||||
const eventi = await leggiEventi();
|
||||
const evento = eventi.find((e) => e.id === EVENTO);
|
||||
assert.ok(evento, "l'evento appena inserito arriva fino al chiamante");
|
||||
assert.equal(evento.titolo, "Lettori server");
|
||||
assert.equal(evento.tipo, "partita");
|
||||
assert.equal(evento.ora, "21:00");
|
||||
assert.equal(evento.casa, false, "la trasferta resta una trasferta");
|
||||
assert.equal(evento.campionato, true);
|
||||
assert.deepEqual(evento.convocati, ["g1", "g2"], "i convocati servono al sollecito");
|
||||
assert.equal(evento.note, "", "le note vuote restano stringa vuota");
|
||||
assert.equal(evento.pagelleChiuse, false);
|
||||
});
|
||||
|
||||
await prova("leggiGiocatoriSquadra() torna la rosa con i campi usati dalle route", async () => {
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
assert.ok(squadra.length > 0, "la rosa del seed non è vuota");
|
||||
const g1 = squadra.find((g) => g.id === "g1");
|
||||
assert.ok(g1, "g1 esiste nel seed locale");
|
||||
assert.equal(typeof g1.nome, "string");
|
||||
assert.equal(typeof g1.cognome, "string");
|
||||
assert.equal(typeof g1.numero, "number");
|
||||
assert.equal(typeof g1.attivo, "boolean", "`attivo` filtra i destinatari delle push");
|
||||
// Le colonne di M8: fanno parte di COLONNE_SQUADRA, quindi se mancassero la query
|
||||
// fallirebbe per tutti, non solo per la dashboard tesseramenti.
|
||||
assert.ok("numeroTessera" in g1 && "dataTessera" in g1, "le colonne di M8 sono lette");
|
||||
});
|
||||
|
||||
await prova("l'ordine è quello dichiarato: cognome, poi nome", async () => {
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const chiavi = squadra.map((g) => `${g.cognome} ${g.nome}`);
|
||||
assert.deepEqual(
|
||||
chiavi,
|
||||
[...chiavi].sort((a, b) => a.localeCompare(b)),
|
||||
"gli elenchi mostrati alla squadra dipendono da questo ordinamento",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||
riepilogo("lettori server");
|
||||
}
|
||||
}
|
||||
@@ -340,8 +340,66 @@ if (!locale) {
|
||||
assert.ok(!altrui.ok, `quelle di un altro no (${altrui.status})`);
|
||||
});
|
||||
|
||||
// I turni palloni restano aperti di proposito: nell'interfaccia il turno se lo passa
|
||||
// chiunque, senza gate. Se un giorno arriva il gate, questo test va cambiato.
|
||||
await prova("anche i badge social si firmano con il proprio nome", async () => {
|
||||
const mio = await rest("badge_social_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
categoria: "sorriso",
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(mio), 1, "il proprio voto social si registra");
|
||||
|
||||
const falso = await rest("badge_social_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
categoria: "sorriso",
|
||||
votante_id: "g5",
|
||||
votato_id: "g1",
|
||||
votato_nome: "Uno",
|
||||
}),
|
||||
});
|
||||
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
|
||||
});
|
||||
|
||||
// L'altra metà di M11: le policy `Gli admin gestiscono tutti/e …`. Senza queste
|
||||
// l'amministratore non potrebbe correggere una risposta sbagliata né ripulire i voti
|
||||
// di una partita, e la rotta /eventi sarebbe monca.
|
||||
await prova("l'amministratore corregge i dati degli altri", async () => {
|
||||
const presenza = await rest("risposte_presenze", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", stato: "assente" }),
|
||||
});
|
||||
assert.equal(await righeToccate(presenza), 1, "l'admin risponde anche per un altro");
|
||||
|
||||
const cacca = await rest("cacche_partita", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", quantita: 1 }),
|
||||
});
|
||||
assert.equal(await righeToccate(cacca), 1, "vale anche per le cacche");
|
||||
|
||||
const pagella = await rest(
|
||||
`pagelle_voti?match_id=eq.${EVENTO}&votante_id=eq.g1`,
|
||||
tokenAdmin,
|
||||
{ method: "DELETE", headers: { Prefer: "return=representation" } },
|
||||
);
|
||||
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
|
||||
});
|
||||
|
||||
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
|
||||
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
|
||||
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
|
||||
// Questi casi non dicono che sono sicure: dicono che sono aperte per scelta. Se un
|
||||
// giorno una di loro prende un gate nell'interfaccia, le policy devono seguirlo e
|
||||
// questi test vanno cambiati insieme.
|
||||
await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => {
|
||||
const res = await rest("turni_palloni", tokenGiocatore, {
|
||||
method: "POST",
|
||||
@@ -350,6 +408,79 @@ if (!locale) {
|
||||
});
|
||||
assert.equal(await righeToccate(res), 1, "DD-023 lascia questa tabella invariata");
|
||||
});
|
||||
|
||||
await prova("lo Scout Live resta aperto a chiunque sia autenticato", async () => {
|
||||
const sessione = await rest("scout_sessioni", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
evento_id: EVENTO,
|
||||
giocatore_id: "g5",
|
||||
giocatore_nome: "Cinque",
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(sessione), 1, "il lock lo prende chiunque");
|
||||
|
||||
const stato = await rest("scout_live", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, stato: { set: 1 } }),
|
||||
});
|
||||
assert.equal(await righeToccate(stato), 1, "e lo stato in corso lo scrive chiunque");
|
||||
|
||||
const archivio = await rest("scout_partite", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
id: `${PREFISSO}-match`,
|
||||
evento_id: EVENTO,
|
||||
data: "2026-01-01",
|
||||
avversario: "Prova",
|
||||
casa: true,
|
||||
set_nostri: 3,
|
||||
set_loro: 0,
|
||||
parziali: [],
|
||||
azioni: [],
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(archivio), 1, "come l'archivio di fine partita");
|
||||
|
||||
const cancella = await rest(`scout_partite?id=eq.${PREFISSO}-match`, tokenGiocatore, {
|
||||
method: "DELETE",
|
||||
headers: { Prefer: "return=representation" },
|
||||
});
|
||||
assert.equal(await righeToccate(cancella), 1, "e chiunque può anche cancellarlo");
|
||||
});
|
||||
|
||||
// Le iscrizioni push non passano dalla RLS per identificare il dispositivo: la chiave è
|
||||
// l'endpoint, che il browser conosce solo per sé. Restano scrivibili da chiunque sia
|
||||
// autenticato, ed è il motivo per cui `push_subscriptions` non contiene dati personali
|
||||
// oltre all'endpoint e alle sue chiavi.
|
||||
await prova("l'iscrizione alle notifiche la registra qualsiasi autenticato", async () => {
|
||||
const endpoint = `https://esempio.test/${PREFISSO}-push`;
|
||||
const res = await rest("push_subscriptions", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
giocatore_id: "g1",
|
||||
endpoint,
|
||||
p256dh: "chiave-di-prova",
|
||||
auth: "auth-di-prova",
|
||||
}),
|
||||
});
|
||||
assert.equal(await righeToccate(res), 1, "il dispositivo si registra da solo");
|
||||
|
||||
const via = await rest(
|
||||
`push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
|
||||
tokenGiocatore,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Prefer: "return=representation" },
|
||||
},
|
||||
);
|
||||
assert.equal(await righeToccate(via), 1, "e si cancella quando le notifiche si spengono");
|
||||
});
|
||||
} finally {
|
||||
// Ripristino: prima le righe create (la service role passa sopra alle policy di M11),
|
||||
// poi gli slot (serve il JWT admin, il trigger rifiuta la service key), il telefono e
|
||||
@@ -357,9 +488,14 @@ if (!locale) {
|
||||
for (const tabella of ["risposte_presenze", "cacche_partita", "turni_palloni"]) {
|
||||
await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
for (const tabella of ["pagelle_voti", "mvp_voti"]) {
|
||||
for (const tabella of ["pagelle_voti", "mvp_voti", "badge_social_voti"]) {
|
||||
await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
for (const tabella of ["scout_sessioni", "scout_live"]) {
|
||||
await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
}
|
||||
await rest(`scout_partite?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`push_subscriptions?endpoint=like.*${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
|
||||
|
||||
for (const id of ["g1", "g2"]) {
|
||||
|
||||
@@ -121,6 +121,32 @@ if (!locale) {
|
||||
});
|
||||
|
||||
// --- MVP: qui la regola è l'opposta -------------------------------------------
|
||||
await prova("l'MVP e i badge social rifiutano l'autovoto", async () => {
|
||||
// M12: `pagelle_no_autovoto` esisteva dalla v1.0, queste due tabelle no. Il vincolo sta
|
||||
// a database perché l'interfaccia non è l'unica strada per scrivere una riga.
|
||||
await assert.rejects(
|
||||
() =>
|
||||
upsert("mvp_voti", "match_id,votante_id", {
|
||||
match_id: `${PREFISSO}-m3`,
|
||||
votante_id: "g1",
|
||||
votato_id: "g1",
|
||||
votato_nome: "Uno",
|
||||
}),
|
||||
"nessuno si elegge MVP da solo (mvp_no_autovoto)",
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
upsert("badge_social_voti", "match_id,categoria,votante_id", {
|
||||
match_id: `${PREFISSO}-m3`,
|
||||
categoria: "cuore",
|
||||
votante_id: "g1",
|
||||
votato_id: "g1",
|
||||
votato_nome: "Uno",
|
||||
}),
|
||||
"né si assegna un badge social (badge_social_no_autovoto)",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("l'MVP tiene un solo voto per votante e partita", async () => {
|
||||
const match = `${PREFISSO}-m3`;
|
||||
const chiave = "match_id,votante_id";
|
||||
@@ -226,34 +252,49 @@ if (!locale) {
|
||||
assert.equal(righe[0]?.["aggiornato_da"], "g9", "e si sa chi l'ha fatta");
|
||||
});
|
||||
|
||||
// --- presenze: la risposta si cambia fino all'ultimo ---------------------------
|
||||
await prova("la risposta di presenza si aggiorna, non si duplica", async () => {
|
||||
const evento = `${PREFISSO}-e3`;
|
||||
const chiave = "evento_id,giocatore_id";
|
||||
const prima = await upsert("risposte_presenze", chiave, {
|
||||
evento_id: evento,
|
||||
giocatore_id: "g1",
|
||||
stato: "presente",
|
||||
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
|
||||
});
|
||||
await upsert("risposte_presenze", chiave, {
|
||||
evento_id: evento,
|
||||
giocatore_id: "g1",
|
||||
stato: "assente",
|
||||
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
|
||||
});
|
||||
const righe = await leggi(
|
||||
"risposte_presenze",
|
||||
`evento_id=eq.${evento}&select=stato,aggiornato_il`,
|
||||
);
|
||||
assert.equal(righe.length, 1, "una risposta per giocatore");
|
||||
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta");
|
||||
assert.notEqual(
|
||||
righe[0]?.["aggiornato_il"],
|
||||
prima[0]?.["aggiornato_il"],
|
||||
"l'istante della risposta si muove: è quello che alimenta la serie di conferme",
|
||||
);
|
||||
});
|
||||
// --- presenze: la risposta si cambia, il cronometro no --------------------------
|
||||
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
|
||||
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non
|
||||
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
|
||||
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
|
||||
await prova(
|
||||
"la risposta di presenza si aggiorna senza far ripartire il cronometro",
|
||||
async () => {
|
||||
const evento = `${PREFISSO}-e3`;
|
||||
const chiave = "evento_id,giocatore_id";
|
||||
const prima = await upsert("risposte_presenze", chiave, {
|
||||
evento_id: evento,
|
||||
giocatore_id: "g1",
|
||||
stato: "presente",
|
||||
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
|
||||
});
|
||||
await upsert("risposte_presenze", chiave, {
|
||||
evento_id: evento,
|
||||
giocatore_id: "g1",
|
||||
stato: "assente",
|
||||
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
|
||||
// Il ripensamento prova anche a riscrivere l'istante della prima risposta: è
|
||||
// esattamente la mossa che il trigger deve annullare.
|
||||
risposto_il: new Date("2026-01-08T19:00:00Z").toISOString(),
|
||||
});
|
||||
const righe = await leggi(
|
||||
"risposte_presenze",
|
||||
`evento_id=eq.${evento}&select=stato,aggiornato_il,risposto_il`,
|
||||
);
|
||||
assert.equal(righe.length, 1, "una risposta per giocatore");
|
||||
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta");
|
||||
assert.notEqual(
|
||||
righe[0]?.["aggiornato_il"],
|
||||
prima[0]?.["aggiornato_il"],
|
||||
"l'ultima modifica si muove",
|
||||
);
|
||||
assert.equal(
|
||||
righe[0]?.["risposto_il"],
|
||||
prima[0]?.["risposto_il"],
|
||||
"la prima risposta resta quella: il trigger di M9 la congela",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// --- scout live: lo stato viene sostituito, non fuso ----------------------------
|
||||
await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Check della guardia delle route che mandano notifiche: `bun test/unit/auth-route.test.ts`.
|
||||
*
|
||||
* `richiediAdmin` (DD-024) è l'unica barriera davanti a route che usano la service role e
|
||||
* saltano la RLS: qui si verificano i rifiuti che non richiedono rete, cioè tutti quelli
|
||||
* decisi prima di chiedere l'utente a Supabase. Il caso «token buono ma non admin» sta in
|
||||
* `test/integration/permessi-route.test.ts`, che ha un database vero.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
|
||||
const con = (intestazioni: Record<string, string>) =>
|
||||
richiediAdmin(new Request("http://localhost/api/public/qualcosa", { headers: intestazioni }));
|
||||
|
||||
const rifiuti: Array<[string, Record<string, string>]> = [
|
||||
["nessuna intestazione", {}],
|
||||
["schema sbagliato", { authorization: "Basic abc" }],
|
||||
["Bearer senza token", { authorization: "Bearer " }],
|
||||
["token non JWT", { authorization: "Bearer non-un-jwt" }],
|
||||
["JWT a due segmenti", { authorization: "Bearer aaa.bbb" }],
|
||||
["JWT con segmenti vuoti", { authorization: "Bearer .." }],
|
||||
];
|
||||
|
||||
for (const [caso, intestazioni] of rifiuti) {
|
||||
const res = await con(intestazioni);
|
||||
assert.ok(res, `${caso}: la richiesta va fermata`);
|
||||
assert.equal(res.status, 401, `${caso}: risponde 401`);
|
||||
}
|
||||
|
||||
console.log("auth-route: ok");
|
||||
@@ -1,6 +1,15 @@
|
||||
/** Check della conversione eventi: `bun test/unit/eventi.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { categoriaEvento, daCategoria, daRiga, type RigaEvento } from "@/lib/eventi";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import {
|
||||
categoriaEvento,
|
||||
compleanniEventi,
|
||||
convocatiEvento,
|
||||
daCategoria,
|
||||
daRiga,
|
||||
eventoVuoto,
|
||||
type RigaEvento,
|
||||
} from "@/lib/eventi";
|
||||
|
||||
const riga: RigaEvento = {
|
||||
id: "e1",
|
||||
@@ -63,4 +72,54 @@ for (const c of ["partita", "amichevole", "allenamento", "evento"] as const) {
|
||||
assert.equal(categoriaEvento(daCategoria(c)), c, `andata e ritorno stabile per ${c}`);
|
||||
}
|
||||
|
||||
// --- compleanniEventi: l'anagrafica diventa calendario -----------------------
|
||||
const rosa: Giocatore[] = [
|
||||
{ ...giocatori[0]!, id: "g1", nome: "Bruno", nascita: "1990-12-31" },
|
||||
{ ...giocatori[0]!, id: "g2", nome: "Anna", nascita: "2001-03-08" },
|
||||
{ ...giocatori[0]!, id: "g3", nome: "Senza data", nascita: "" },
|
||||
];
|
||||
|
||||
const compleanni = compleanniEventi(rosa, 2026);
|
||||
assert.deepEqual(
|
||||
compleanni.map((e) => [e.id, e.data, e.luogo]),
|
||||
[
|
||||
["c-g2", "2026-03-08", "Compie 25 anni"],
|
||||
["c-g1", "2026-12-31", "Compie 36 anni"],
|
||||
],
|
||||
"chi non ha data di nascita resta fuori, gli altri sono in ordine di data",
|
||||
);
|
||||
assert.equal(compleanni[0]!.titolo, "Compleanno di Anna");
|
||||
assert.equal(compleanni[0]!.tipo, "compleanno");
|
||||
assert.deepEqual(compleanni[0]!.convocati, [], "un compleanno non convoca nessuno");
|
||||
assert.deepEqual(compleanniEventi([], 2026), []);
|
||||
|
||||
// --- convocatiEvento: elenco vuoto = tutta la rosa ---------------------------
|
||||
const evento = daRiga({ ...riga, convocati: ["g2"] });
|
||||
assert.deepEqual(
|
||||
convocatiEvento(evento, rosa).map((g) => g.id),
|
||||
["g2"],
|
||||
"con i convocati indicati si filtra",
|
||||
);
|
||||
assert.deepEqual(
|
||||
convocatiEvento(daRiga({ ...riga, convocati: null }), rosa),
|
||||
rosa,
|
||||
"vuoto = tutti",
|
||||
);
|
||||
assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti");
|
||||
assert.deepEqual(
|
||||
convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa),
|
||||
[],
|
||||
"un convocato che non è in rosa non inventa giocatori",
|
||||
);
|
||||
|
||||
// --- eventoVuoto: la bozza parte allenamento, oggi, senza convocati ----------
|
||||
const bozza = eventoVuoto();
|
||||
assert.equal(bozza.tipo, "allenamento");
|
||||
assert.equal(bozza.campionato, false);
|
||||
assert.equal(bozza.pagelleChiuse, false);
|
||||
assert.deepEqual(bozza.convocati, []);
|
||||
assert.match(bozza.data, /^\d{4}-\d{2}-\d{2}$/, "la data è di oggi in formato ISO");
|
||||
assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
|
||||
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
||||
|
||||
console.log("eventi: ok");
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { conteggioPartita, mioVoto, vincitoriMvp, type VotoMvp } from "@/lib/mvp-voti";
|
||||
import {
|
||||
conteggioPartita,
|
||||
mioVoto,
|
||||
mvpVintiPerGiocatore,
|
||||
vincitoriMvp,
|
||||
votoMvpAperto,
|
||||
type VotoMvp,
|
||||
} from "@/lib/mvp-voti";
|
||||
|
||||
const v = (
|
||||
match_id: string,
|
||||
@@ -50,4 +57,32 @@ assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
|
||||
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
|
||||
assert.equal(mioVoto(partita, "m9", "g1"), null);
|
||||
|
||||
// --- mvpVintiPerGiocatore: una vittoria per partita, la parità non conta -----
|
||||
assert.deepEqual(
|
||||
mvpVintiPerGiocatore(partita),
|
||||
{ g2: 1, g5: 1 },
|
||||
"m1 la vince g2, m2 g5: un titolo a testa",
|
||||
);
|
||||
assert.deepEqual(
|
||||
mvpVintiPerGiocatore([...partita, ...pari]),
|
||||
{ g2: 1, g5: 1 },
|
||||
"la parità non assegna",
|
||||
);
|
||||
assert.deepEqual(
|
||||
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]),
|
||||
{ g2: 2, g5: 1 },
|
||||
"i titoli si sommano su partite diverse",
|
||||
);
|
||||
assert.deepEqual(mvpVintiPerGiocatore([]), {});
|
||||
|
||||
// --- votoMvpAperto: due ore dopo il fischio d'inizio -------------------------
|
||||
const alle = (o: number, m = 0) => new Date(2026, 8, 5, o, m);
|
||||
assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(22, 29)), false, "un minuto prima");
|
||||
assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(22, 30)), true, "due ore in punto");
|
||||
assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(23)), true);
|
||||
assert.equal(votoMvpAperto("2026-09-06", "10:00", alle(23)), false, "partita di domani");
|
||||
assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita passata: aperto");
|
||||
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
|
||||
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
|
||||
|
||||
console.log("mvp-voti: ok");
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
conRisposta,
|
||||
contaPresenzeGiocatore,
|
||||
destinatariSollecito,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
totaliEventiGiocatore,
|
||||
type MappaPresenze,
|
||||
type MappaTempiRisposta,
|
||||
} from "@/lib/presenze";
|
||||
@@ -58,6 +62,64 @@ assert.equal(serieConsecutiva("g2", eventi, presenze, "allenamento", OGGI), 0);
|
||||
const conConvocati = [...eventi, ev("a6", "allenamento", "2026-08-30", ["g9"])];
|
||||
assert.equal(serieConsecutiva("g1", conConvocati, presenze, "allenamento", OGGI), 2);
|
||||
|
||||
// --- conteggio presenze: numeratore e denominatore delle statistiche ----------
|
||||
// a1 presente, a2 assente, a3 ritardo, a4 presente, p1 presente: 4 su 5 passati.
|
||||
assert.equal(contaPresenzeGiocatore("g1", eventi, presenze, OGGI), 4, "il ritardo conta presente");
|
||||
assert.equal(totaliEventiGiocatore("g1", eventi, OGGI), 5, "gli eventi futuri non contano");
|
||||
assert.equal(contaPresenzeGiocatore("g2", eventi, presenze, OGGI), 0, "chi non risponde è a zero");
|
||||
assert.equal(totaliEventiGiocatore("g2", eventi, OGGI), 5, "il denominatore è uguale per tutti");
|
||||
|
||||
// Solo partite e allenamenti: compleanni e altri eventi restano fuori.
|
||||
const conAltri: Evento[] = [
|
||||
...eventi,
|
||||
ev("x1", "evento", "2026-08-15"),
|
||||
ev("c1", "compleanno", "2026-08-16"),
|
||||
];
|
||||
assert.equal(totaliEventiGiocatore("g1", conAltri, OGGI), 5, "solo partite e allenamenti");
|
||||
|
||||
// Un evento con convocati espliciti conta solo per i convocati.
|
||||
const conRistretti: Evento[] = [...eventi, ev("a7", "allenamento", "2026-08-29", ["g9"])];
|
||||
assert.equal(totaliEventiGiocatore("g9", conRistretti, OGGI), 6, "il convocato ce l'ha in più");
|
||||
assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è convocato no");
|
||||
|
||||
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
|
||||
const squadra = [
|
||||
{ id: "g1", attivo: true },
|
||||
{ id: "g2", attivo: true },
|
||||
{ id: "g3", attivo: true },
|
||||
{ id: "g4", attivo: true },
|
||||
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più
|
||||
];
|
||||
|
||||
const risposte = [
|
||||
{ giocatore_id: "g1", stato: "presente" },
|
||||
{ giocatore_id: "g2", stato: "forse" },
|
||||
{ giocatore_id: "g4", stato: "assente" },
|
||||
{ giocatore_id: "g5", stato: "forse" },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, risposte),
|
||||
["g2", "g3"],
|
||||
"il forse va sollecitato come chi non ha risposto; presente e assente no",
|
||||
);
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, []),
|
||||
["g1", "g2", "g3", "g4"],
|
||||
"senza nessuna risposta si avvisano tutti gli attivi",
|
||||
);
|
||||
assert.deepEqual(
|
||||
destinatariSollecito(squadra, [
|
||||
{ giocatore_id: "g1", stato: "presente" },
|
||||
{ giocatore_id: "g2", stato: "ritardo" },
|
||||
{ giocatore_id: "g3", stato: "infortunato" },
|
||||
{ giocatore_id: "g4", stato: "assente" },
|
||||
]),
|
||||
[],
|
||||
"quando hanno risposto tutti non parte nessuna push",
|
||||
);
|
||||
assert.deepEqual(destinatariSollecito([], risposte), [], "rosa vuota, nessun destinatario");
|
||||
|
||||
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
|
||||
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
|
||||
...ev(id, "allenamento", data),
|
||||
@@ -92,4 +154,60 @@ assert.equal(
|
||||
);
|
||||
assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero");
|
||||
|
||||
// --- cache locale dopo una risposta: il cronometro non riparte ----------------
|
||||
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
|
||||
// Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh.
|
||||
const PRIMA = "2026-08-01T10:00:00Z";
|
||||
const POI = "2026-08-08T10:00:00Z";
|
||||
|
||||
const dopoPrimaRisposta = conRisposta(
|
||||
undefined,
|
||||
{ eventoId: "e1", giocatoreId: "g1", stato: "presente" },
|
||||
PRIMA,
|
||||
);
|
||||
assert.deepEqual(dopoPrimaRisposta, {
|
||||
presenze: { e1: { g1: "presente" } },
|
||||
tempi: { e1: { g1: PRIMA } },
|
||||
});
|
||||
|
||||
const dopoRipensamento = conRisposta(
|
||||
dopoPrimaRisposta,
|
||||
{ eventoId: "e1", giocatoreId: "g1", stato: "assente" },
|
||||
POI,
|
||||
);
|
||||
assert.equal(dopoRipensamento.presenze["e1"]?.["g1"], "assente", "vale l'ultima risposta");
|
||||
assert.equal(
|
||||
dopoRipensamento.tempi["e1"]?.["g1"],
|
||||
PRIMA,
|
||||
"l'istante resta quello della prima risposta: il ripensamento non fa ripartire il cronometro",
|
||||
);
|
||||
|
||||
const dopoRitiro = conRisposta(
|
||||
dopoRipensamento,
|
||||
{ eventoId: "e1", giocatoreId: "g1", stato: null },
|
||||
POI,
|
||||
);
|
||||
assert.deepEqual(dopoRitiro.presenze["e1"], {}, "ritirare la risposta la toglie");
|
||||
assert.deepEqual(
|
||||
dopoRitiro.tempi["e1"],
|
||||
{},
|
||||
"e toglie anche l'istante: chi risponde di nuovo riparte da capo",
|
||||
);
|
||||
assert.equal(
|
||||
conRisposta(dopoRitiro, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, POI).tempi[
|
||||
"e1"
|
||||
]?.["g1"],
|
||||
POI,
|
||||
"dopo un ritiro il cronometro riparte davvero",
|
||||
);
|
||||
|
||||
// Gli altri giocatori e gli altri eventi non vengono toccati.
|
||||
const conAltri2 = conRisposta(
|
||||
conRisposta(undefined, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, PRIMA),
|
||||
{ eventoId: "e2", giocatoreId: "g2", stato: "forse" },
|
||||
POI,
|
||||
);
|
||||
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
|
||||
assert.equal(conAltri2.tempi["e2"]?.["g2"], POI);
|
||||
|
||||
console.log("presenze: ok");
|
||||
|
||||
Reference in New Issue
Block a user