Vieta l'autovoto, allinea la doc al codice e toglie tre riletture.

Rilettura completa della documentazione confrontata con il codice. Dove la doc
diceva il falso l'ho corretta; dove aveva ragione lei ho corretto il codice.

Autovoto (la doc aveva ragione)

- migration m12_niente_autovoto: vincoli mvp_no_autovoto e badge_social_no_autovoto,
  gli stessi che pagelle_voti ha dalla v1.0. Le righe che li violano vengono
  cancellate prima dell'ALTER, altrimenti fallisce; in locale non ce n'erano.
  M11 garantisce solo che il voto sia firmato con il proprio votante_id, non che il
  votato sia un altro: eleggersi MVP restava a un POST di distanza.
- VotazioneMvp non mostra più il votante nell'elenco, come già faceva VotoSocial.

Test che guardavano la colonna sbagliata

- scritture.test.ts verificava che aggiornato_il si muovesse, chiamandolo "quello che
  alimenta la serie di conferme". È l'opposto: la serie usa risposto_il, che il trigger
  di M9 deve tenere fermo. Ora il test prova a riscriverlo e controlla che il database
  abbia tenuto la prima risposta; prima passava anche senza trigger.
- destinatariSollecito() esce dalla route sollecita-presenze e diventa una funzione pura
  in presenze.ts, con i suoi test — stesso trattamento di avvisiPalloniEvento.

Tre riletture in meno

- giocatori-squadra, scout-store e avatar-store usavano invalidateQueries dove il dato
  scritto era già noto: ora setQueryData, come il resto dell'app. Resta scout-live, dove
  il lock può averlo vinto un altro dispositivo.

Documentazione riallineata

- presenze.md, badge.md, mvp.md: i limiti su RLS aperta e route non autenticata erano
  superati da M11 e DD-024;
- serie-presenze.md: il filtro è e.data < oggi, non <=, e l'evento di oggi non conta
  (conterebbe come assenza per tutti); aggiunta la tabella risposto_il/aggiornato_il;
- ARCHITECTURE.md ed EFFICIENZA_CLOUD.md: una sola eccezione a setQueryData;
- DATABASE.md: i vincoli delle tre tabelle di voto;
- PROJECT_STATE.md: fermo a M9, ora arriva a M12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:30:49 +02:00
co-authored by Claude Opus 5
parent 752b300474
commit f687322c3f
19 changed files with 295 additions and 98 deletions
+12 -3
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 04/09/2026 Ultimo aggiornamento: 06/09/2026
## Stato generale ## Stato generale
@@ -21,7 +21,7 @@ reali (M9).
- Cursor e Claude Code come ambienti di sviluppo - Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) - Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` - 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 - Sviluppo locale verificato con il nuovo Supabase
--- ---
@@ -36,7 +36,8 @@ reali (M9).
## Database ## Database
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase - Schema v1.0 e migration da M1 a M11 applicate al nuovo Supabase; `m12_niente_autovoto`
applicata in locale, **da applicare in produzione** (`npx supabase db push`)
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`) - `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 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 attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
@@ -48,6 +49,14 @@ reali (M9).
ancora presente su `giocatori_squadra`) ancora presente su `giocatori_squadra`)
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo, - 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`) 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 - Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello 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 schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
+3 -2
View File
@@ -60,8 +60,9 @@ ricorrente:
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` `src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`); disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con - dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica `invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
eccezione oggi: `scout-live.ts`); 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 - le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`); `palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa - `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
+27
View File
@@ -6,6 +6,33 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026 ## Versione attuale — agosto 2026
### Tre riletture in meno dopo ogni salvataggio
- «Aggiungi giocatore», salvataggio ed eliminazione di una partita scoutata e cambio della
foto profilo aggiornavano la cache con `invalidateQueries`, cioè rileggendo tutto dal
database. Ora usano `setQueryData` come il resto dell'app: il dato scritto è noto, non
serve richiederlo indietro.
- Resta una sola eccezione, `scout-live.ts`: lì il lock può essere stato preso da un altro
dispositivo, quindi rileggere è l'unico modo per sapere chi ha vinto.
### Il cronometro delle conferme è verificato dai test
- `test/integration/scritture.test.ts` controlla ora che un ripensamento non riscriva
`risposto_il`, cioè che il trigger di `m9` sia davvero al suo posto: prima il test guardava
`aggiornato_il`, che è l'ultima modifica e non alimenta nessuna statistica, quindi restava
verde anche senza trigger.
- Il calcolo dei destinatari del sollecito presenze esce dalla route ed è ora
`destinatariSollecito()` in `src/lib/presenze.ts`, con i suoi test — stesso trattamento già
dato ad `avvisiPalloniEvento()` per i palloni.
### Nessuno si vota da solo
- L'elenco della votazione MVP non mostra più il votante: prima bastava toccare il proprio
nome per eleggersi MVP, e il titolo finiva tra le statistiche come qualsiasi altro.
- I vincoli `mvp_no_autovoto` e `badge_social_no_autovoto` (migration `m12_niente_autovoto`)
rifiutano l'auto-voto anche a chi scrive direttamente su PostgREST, come `pagelle_voti`
faceva già dalla v1.0. Le righe che violavano la regola vengono cancellate dalla migration.
### La suite copre i conteggi presenze, il calendario e la guardia delle notifiche ### La suite copre i conteggi presenze, il calendario e la guardia delle notifiche
- Nuovi check unitari su `contaPresenzeGiocatore`/`totaliEventiGiocatore` (numeratore e - Nuovi check unitari su `contaPresenzeGiocatore`/`totaliEventiGiocatore` (numeratore e
+5 -5
View File
@@ -61,11 +61,11 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Votazioni ## Votazioni
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------ | | ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | | | `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. | | `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. | | | `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 ## Turni e notifiche
+3 -2
View File
@@ -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, (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo. `retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con 3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi: `invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
`src/lib/scout-live.ts`. `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. 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 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à si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
+5 -3
View File
@@ -33,7 +33,8 @@ badge assegnati per voto dai compagni.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per - **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 ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita 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: differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili. `votante_id`/`votato_id` sono entrambi visibili.
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di - `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. 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à - Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente. "ottenuto" può sparire o apparire retroattivamente.
- RLS permissiva su `badge_social_voti` (stesso schema di `mvp_voti`): nessun controllo - La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
server-side che `votante_id` coincida con l'utente autenticato. 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 - Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo. browser o dispositivo.
+7 -7
View File
@@ -25,6 +25,9 @@ partita, sovrascrivibile.
per la partita, altrimenti mostra "la partita non è ancora stata disputata". per la partita, altrimenti mostra "la partita non è ancora stata disputata".
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza - `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico. 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à, - `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il - `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
@@ -35,18 +38,15 @@ partita, sovrascrivibile.
## Limiti noti ## 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. - Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
- RLS permissiva: `votante_id`/`votato_id` sono testo libero inviato dal client (l'id - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
giocatore proviene da `localStorage`, non da un claim di sessione verificato server-side); `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
nessun trigger lega il voto all'utente autenticato. vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che sia un convocato di
quella partita resta un filtro solo applicativo.
- In caso di parità, nessun MVP viene assegnato per quella partita. - In caso di parità, nessun MVP viene assegnato per quella partita.
--- ---
## Evoluzioni possibili ## 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). - Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
+10 -8
View File
@@ -53,7 +53,8 @@ Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
src/routes/api/public/sollecita-presenze.ts src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date ├─ 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 ├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts) │ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410) └─ 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 - Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito. server, la UI risponde subito.
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte - 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 ## Limiti noti
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato. - 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 - La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
`risposte_presenze` permettono a qualunque utente autenticato di scrivere qualunque riga finestra di chiusura, né in UI né in RLS.
(`USING(true) WITH CHECK(true)`).
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per - `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
una singola squadra, da rivedere se il volume cresce molto. 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 ## 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. - Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
+19 -3
View File
@@ -4,7 +4,8 @@
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`, **File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx` `src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il` **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 lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
per le conferme. 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 Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta. 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 - 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 rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
quell'evento e la sua serie non si spezza. 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 2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data). 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`). 3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato, 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**. `0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
+20 -16
View File
@@ -71,22 +71,26 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
{aperto ? ( {aperto ? (
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto"> <div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
{rosa.map((g) => ( {/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
<button anche dal vincolo `mvp_no_autovoto` (M12). */}
key={g.id} {rosa
type="button" .filter((g) => g.id !== io?.id)
disabled={vota.isPending} .map((g) => (
onClick={() => invia(g.id, nomeCompleto(g))} <button
className={cn( key={g.id}
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors", type="button"
mio?.votato_id === g.id disabled={vota.isPending}
? "bg-accent text-accent-foreground" onClick={() => invia(g.id, nomeCompleto(g))}
: "bg-card text-foreground", className={cn(
)} "truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
> mio?.votato_id === g.id
{nomeCompleto(g)} ? "bg-accent text-accent-foreground"
</button> : "bg-card text-foreground",
))} )}
>
{nomeCompleto(g)}
</button>
))}
</div> </div>
) : conteggio.length > 0 ? ( ) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
+6 -2
View File
@@ -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(); 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. */ /** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
+14 -5
View File
@@ -219,19 +219,28 @@ export function useSalvaTesseramento() {
export function useAggiungiGiocatore() { export function useAggiungiGiocatore() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }) => { mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({ const riga = {
id: input.id, id: input.id,
nome: input.dati.nome.trim(), nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(), cognome: input.dati.cognome.trim(),
numero: input.dati.numero, numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(), ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null, email: input.dati.email?.trim() || null,
}); };
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error; 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: () => { // Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY }); // (`.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),
),
);
}, },
}); });
} }
+19
View File
@@ -45,6 +45,25 @@ export function totaliEventiGiocatore(
return eventiContanoPresenze(eventi, giocatoreId, oggi).length; 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);
}
/** /**
* Serie di presenze consecutive su eventi già passati, in ordine di data: * Serie di presenze consecutive su eventi già passati, in ordine di data:
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna * ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
+10 -2
View File
@@ -135,7 +135,12 @@ export function useSalvaScoutMatch() {
if (error) throw error; if (error) throw error;
return input.match; 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; if (error) throw error;
return id; return id;
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }), onSuccess: (id) =>
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) =>
(prec ?? []).filter((m) => m.id !== id),
),
}); });
} }
+2 -8
View File
@@ -4,6 +4,7 @@ import { richiediAdmin } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { destinatariSollecito } from "@/lib/presenze";
import { inviaPush } from "@/lib/webpush.server"; import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({ const schema = z.object({
@@ -33,14 +34,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
.eq("evento_id", evento.id); .eq("evento_id", evento.id);
const squadra = await leggiGiocatoriSquadra(); const squadra = await leggiGiocatoriSquadra();
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato])); const destinatari = destinatariSollecito(squadra, righe ?? []);
const destinatari = squadra
.filter((g) => g.attivo)
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
})
.map((g) => g.id);
if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 }); if (destinatari.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
+4 -4
View File
@@ -10,7 +10,7 @@ import {
caricaAvatar, caricaAvatar,
rimuoviAvatar, rimuoviAvatar,
useAvatarEsiste, useAvatarEsiste,
useInvalidaAvatarEsiste, useImpostaAvatarEsiste,
} from "@/lib/avatar-store"; } from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard"; import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
@@ -68,7 +68,7 @@ function Profilo() {
const ultimoMese = usePresenzeUltimoMese(g?.id); const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(g?.id); const fotoEsiste = useAvatarEsiste(g?.id);
const invalidaAvatarEsiste = useInvalidaAvatarEsiste(); const impostaAvatarEsiste = useImpostaAvatarEsiste();
const [bust, setBust] = useState(0); const [bust, setBust] = useState(0);
const [notifiche, setNotifiche] = useState(false); const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false); const [inCorso, setInCorso] = useState(false);
@@ -121,7 +121,7 @@ function Profilo() {
try { try {
await caricaAvatar(g.id, file); await caricaAvatar(g.id, file);
setBust(Date.now()); setBust(Date.now());
invalidaAvatarEsiste(g.id); impostaAvatarEsiste(g.id, true);
toast.success("Immagine profilo aggiornata"); toast.success("Immagine profilo aggiornata");
} catch { } catch {
toast.error("Non sono riuscito a caricare l'immagine"); toast.error("Non sono riuscito a caricare l'immagine");
@@ -175,7 +175,7 @@ function Profilo() {
try { try {
await rimuoviAvatar(g.id); await rimuoviAvatar(g.id);
setBust(Date.now()); setBust(Date.now());
invalidaAvatarEsiste(g.id); impostaAvatarEsiste(g.id, false);
toast.success("Immagine rimossa"); toast.success("Immagine rimossa");
} catch { } catch {
toast.error("Non sono riuscito a rimuovere l'immagine"); toast.error("Non sono riuscito a rimuovere l'immagine");
@@ -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);
+69 -28
View File
@@ -121,6 +121,32 @@ if (!locale) {
}); });
// --- MVP: qui la regola è l'opposta ------------------------------------------- // --- 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 () => { await prova("l'MVP tiene un solo voto per votante e partita", async () => {
const match = `${PREFISSO}-m3`; const match = `${PREFISSO}-m3`;
const chiave = "match_id,votante_id"; 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"); assert.equal(righe[0]?.["aggiornato_da"], "g9", "e si sa chi l'ha fatta");
}); });
// --- presenze: la risposta si cambia fino all'ultimo --------------------------- // --- presenze: la risposta si cambia, il cronometro no --------------------------
await prova("la risposta di presenza si aggiorna, non si duplica", async () => { // Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
const evento = `${PREFISSO}-e3`; // risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non
const chiave = "evento_id,giocatore_id"; // alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
const prima = await upsert("risposte_presenze", chiave, { // la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
evento_id: evento, await prova(
giocatore_id: "g1", "la risposta di presenza si aggiorna senza far ripartire il cronometro",
stato: "presente", async () => {
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(), const evento = `${PREFISSO}-e3`;
}); const chiave = "evento_id,giocatore_id";
await upsert("risposte_presenze", chiave, { const prima = await upsert("risposte_presenze", chiave, {
evento_id: evento, evento_id: evento,
giocatore_id: "g1", giocatore_id: "g1",
stato: "assente", stato: "presente",
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(), aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
}); });
const righe = await leggi( await upsert("risposte_presenze", chiave, {
"risposte_presenze", evento_id: evento,
`evento_id=eq.${evento}&select=stato,aggiornato_il`, giocatore_id: "g1",
); stato: "assente",
assert.equal(righe.length, 1, "una risposta per giocatore"); aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta"); // Il ripensamento prova anche a riscrivere l'istante della prima risposta: è
assert.notEqual( // esattamente la mossa che il trigger deve annullare.
righe[0]?.["aggiornato_il"], risposto_il: new Date("2026-01-08T19:00:00Z").toISOString(),
prima[0]?.["aggiornato_il"], });
"l'istante della risposta si muove: è quello che alimenta la serie di conferme", 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 ---------------------------- // --- scout live: lo stato viene sostituito, non fuso ----------------------------
await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => { await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => {
+39
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { import {
contaPresenzeGiocatore, contaPresenzeGiocatore,
destinatariSollecito,
serieConferme, serieConferme,
serieConsecutiva, serieConsecutiva,
totaliEventiGiocatore, totaliEventiGiocatore,
@@ -80,6 +81,44 @@ const conRistretti: Evento[] = [...eventi, ev("a7", "allenamento", "2026-08-29",
assert.equal(totaliEventiGiocatore("g9", conRistretti, OGGI), 6, "il convocato ce l'ha in più"); 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"); 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 -------------------- // --- serie conferme: risposta entro 24h dalla convocazione --------------------
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({ const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
...ev(id, "allenamento", data), ...ev(id, "allenamento", data),