diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3dae9ee..f8690af 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -74,6 +74,12 @@ Prima versione, pre-release. di Statistiche; la tab «Classifica» è stata rimossa dalla barra delle sottosezioni. - Tolto l'hint statico "+2 questo mese" dalla StatTile Presenze in home (sezione «Colpo d'occhio»): mostrava un testo fisso, non un dato calcolato. +- La StatTile Media voto in home applica ora la stessa soglia minima di voti del badge + Pagellone (`VOTI_MINIMI_PAGELLA`): sotto soglia mostra `—` invece di una media poco + significativa (DD-028). +- L'MVP di una partita richiede ora un quorum minimo di 2 voti totali (`VOTI_MINIMI_MVP`) + oltre al margine netto già richiesto: un solo voto non assegna più la vittoria (DD-028). + Alcuni conteggi `mvp` già mostrati possono scendere per effetto della nuova regola. ### Sicurezza diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 2abf5cb..7d280a2 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -43,6 +43,7 @@ Serve a rispondere a domande del tipo: | [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | | [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | | [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati | +| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP | **In valutazione** @@ -1024,3 +1025,54 @@ dall'audit che ha originato questa decisione. **Riesame** Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database, non solo in UI. + +### DD-028 — Soglia minima di campione per Media voto e MVP in home + +**Data:** 9 settembre 2026 +**Stato:** Accettata + +**Contesto** +Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media +voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario: +`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10 +mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema +che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al +badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()` +assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore +votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione. + +**Decisione** + +- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA` + (5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura + `mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020). + La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il + badge. +- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e + `mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita + (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già + esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare + nessuno, nemmeno in assenza di concorrenza. + +**Alternative scartate** + +- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati + dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata + per il gap trovato in audit. +- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo + (un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto + per l'MVP. + +**Conseguenze** + +- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio + `mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro, + perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico + persistito a parte. +- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la + soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già + noto in [pagelle.md](modules/pagelle.md). + +**Riesame** +Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o +se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra. diff --git a/docs/modules/mvp.md b/docs/modules/mvp.md index 6c77e87..60b7284 100644 --- a/docs/modules/mvp.md +++ b/docs/modules/mvp.md @@ -37,6 +37,9 @@ restano nel database ma non vengono più letti da nessuna schermata). direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle. - `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità, nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. +- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali + sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a + incoronare nessuno, nemmeno senza concorrenza. - `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile nel profilo e in home. @@ -57,7 +60,8 @@ restano nel database ma non vengono più letti da nessuna schermata). (non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento (`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. -- In caso di parità, nessun MVP viene assegnato per quella partita. +- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella + partita. --- diff --git a/docs/modules/pagelle.md b/docs/modules/pagelle.md index 564a0b1..ebdc45f 100644 --- a/docs/modules/pagelle.md +++ b/docs/modules/pagelle.md @@ -36,8 +36,12 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`. - `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi [badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato - come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`): senza voti - ricevuti la media vale `0` e la StatTile mostra `—` invece del numero. + come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`). +- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la + funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra + `—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione + testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le + StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti"). --- @@ -57,9 +61,10 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`. - **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id` sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non mostra mai il votante, ma il dato non è né aggregato né mascherato lato server. -- La media mostrata nel profilo non richiede un numero minimo di voti: con un solo voto - ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`) applica invece un - minimo di voti prima di considerarla — la StatTile del profilo no. +- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti: + con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`) + e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di + considerarla — profilo e squadra no. - Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore). diff --git a/src/lib/mvp-voti.ts b/src/lib/mvp-voti.ts index a6afff5..d7ebdcc 100644 --- a/src/lib/mvp-voti.ts +++ b/src/lib/mvp-voti.ts @@ -60,6 +60,9 @@ export function useVotaMvp() { export type ConteggioMvp = { id: string; nome: string; voti: number }; +/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */ +export const VOTI_MINIMI_MVP = 2; + /** Conteggio voti di una partita, dal più votato. */ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { const map = new Map(); @@ -83,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record { const out: Record = {}; for (const [matchId] of perMatch) { const top = conteggioPartita(voti, matchId); - // In caso di parità nessun MVP assegnato finché il voto non si sblocca. - if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) { + const totaleVoti = top.reduce((s, c) => s + c.voti, 0); + // In caso di parità, o sotto il quorum minimo, nessun MVP assegnato. + if ( + totaleVoti >= VOTI_MINIMI_MVP && + top.length > 0 && + (top.length === 1 || top[0]!.voti > top[1]!.voti) + ) { out[matchId] = top[0]!.nome; } } @@ -95,13 +103,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) { return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null; } -/** MVP vinti per giocatore, contando una vittoria per partita votata. */ +/** + * MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio + * al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti + * restituisce una mappa vuota. + */ export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record { const out: Record = {}; const matchIds = new Set(voti.map((v) => v.match_id)); for (const matchId of matchIds) { const top = conteggioPartita(voti, matchId); - if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) { + const totaleVoti = top.reduce((s, c) => s + c.voti, 0); + if ( + totaleVoti >= VOTI_MINIMI_MVP && + top.length > 0 && + (top.length === 1 || top[0]!.voti > top[1]!.voti) + ) { const id = top[0]!.id; out[id] = (out[id] ?? 0) + 1; } diff --git a/src/lib/pagelle.ts b/src/lib/pagelle.ts index be1f684..8e56a18 100644 --- a/src/lib/pagelle.ts +++ b/src/lib/pagelle.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; +import { VOTI_MINIMI_PAGELLA } from "./badges"; /** Voto anonimo da 1 a 10 dato a un compagno per una partita. */ export type VotoPagella = { @@ -62,7 +63,8 @@ function arrotonda(n: number) { /** * Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto - * di stagione/reset): giocatoreId -> media e numero di voti. + * di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti + * non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback. */ export function mediePagelle(voti: VotoPagella[]): Record { const somma: Record = {}; @@ -93,6 +95,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string return out; } +/** + * Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto + * `VOTI_MINIMI_PAGELLA` voti ricevuti, `—` invece della media grezza — stessa soglia del + * badge Pagellone, non applicata invece dalle StatTile di profilo e squadra. + */ +export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" { + return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—"; +} + /** Media pagelle di tutta la squadra su tutte le partite. */ export function mediaSquadra(voti: VotoPagella[]) { if (voti.length === 0) return 0; diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index 6ca3a74..72b567f 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -23,7 +23,7 @@ function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string, oggi = da ); } -/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */ +/** Presenze effettive (presente o in ritardo) su eventi CrAPP. Senza eventi rilevanti restituisce 0. */ export function contaPresenzeGiocatore( giocatoreId: string, eventi: Evento[], diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f4a706f..6b60011 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -15,6 +15,7 @@ import { useCsi } from "@/lib/csi"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { useScoutMatches } from "@/lib/scout-store"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; +import { mediaVotoColpoDOcchio } from "@/lib/pagelle"; export const Route = createFileRoute("/")({ head: () => ({ @@ -238,8 +239,8 @@ function Index() {
- - + +
diff --git a/test/integration/mvp-badge.test.ts b/test/integration/mvp-badge.test.ts index 8b24155..65c330e 100644 --- a/test/integration/mvp-badge.test.ts +++ b/test/integration/mvp-badge.test.ts @@ -127,13 +127,20 @@ if (!locale) { const badgeVg2 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg2"] ?? 0)); assert.equal(badgeVg2.grado, null, "zero vittorie nette: nessun badge sbloccato"); - // Una terza vittoria netta porta vg1 da bronzo ad argento (soglia 3). + // Una terza vittoria netta porta vg1 da bronzo ad argento (soglia 3). Due voti, + // non uno solo: sotto il quorum minimo (VOTI_MINIMI_MVP) nessuna vittoria conterebbe. await upsert({ match_id: `${PREFISSO}-m4`, votante_id: "va", votato_id: "vg1", votato_nome: "Uno", }); + await upsert({ + match_id: `${PREFISSO}-m4`, + votante_id: "vb", + votato_id: "vg1", + votato_nome: "Uno", + }); const vintiDopo = mvpVintiPerGiocatore(await leggiVoti()); assert.equal(vintiDopo["vg1"], 3); assert.equal( diff --git a/test/integration/s-tiebreak-badge.test.ts b/test/integration/s-tiebreak-badge.test.ts index a6b9868..af9986d 100644 --- a/test/integration/s-tiebreak-badge.test.ts +++ b/test/integration/s-tiebreak-badge.test.ts @@ -89,20 +89,33 @@ if (!locale) { await prova( "sotto la soglia minima di voti pagella, 2 MVP e media alta non bastano", async () => { - // "tb1" vince nettamente m1 e m2 (2 MVP), e riceve un solo voto pagella da 9 (media - // alta ma su un campione troppo piccolo): il segreto deve restare bloccato. + // "tb1" vince nettamente m1 e m2 (2 MVP, due voti a testa per rispettare il quorum + // minimo), e riceve un solo voto pagella da 9 (media alta ma su un campione troppo + // piccolo): il segreto deve restare bloccato. await votaMvp({ match_id: `${PREFISSO}-m1`, votante_id: "va", votato_id: "tb1", votato_nome: "Uno", }); + await votaMvp({ + match_id: `${PREFISSO}-m1`, + votante_id: "vf", + votato_id: "tb1", + votato_nome: "Uno", + }); await votaMvp({ match_id: `${PREFISSO}-m2`, votante_id: "va", votato_id: "tb1", votato_nome: "Uno", }); + await votaMvp({ + match_id: `${PREFISSO}-m2`, + votante_id: "vf", + votato_id: "tb1", + votato_nome: "Uno", + }); await votaPagella({ match_id: `${PREFISSO}-m1`, votante_id: "va", diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts index 827b83a..2cadc97 100644 --- a/test/unit/mvp-voti.test.ts +++ b/test/unit/mvp-voti.test.ts @@ -6,6 +6,7 @@ import { mvpVintiPerGiocatore, vincitoriMvp, votoMvpAperto, + VOTI_MINIMI_MVP, type VotoMvp, } from "@/lib/mvp-voti"; @@ -27,6 +28,7 @@ const partita = [ v("m1", "g3", "g2", "Bruno"), v("m1", "g4", "g5", "Anna"), v("m2", "g1", "g5", "Anna"), + v("m2", "g3", "g5", "Anna"), ]; assert.deepEqual(conteggioPartita(partita, "m1"), [ @@ -46,10 +48,18 @@ assert.deepEqual( assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); assert.deepEqual(vincitoriMvp([]), {}); + +// --- vincitoriMvp: quorum minimo di voti ------------------------------------- +assert.equal(VOTI_MINIMI_MVP, 2, "un solo voto non deve mai bastare da solo"); assert.deepEqual( vincitoriMvp([v("m4", "g1", "g2", "Solo")]), + {}, + "un solo votante, senza concorrenza: sotto quorum, nessun MVP", +); +assert.deepEqual( + vincitoriMvp([v("m4", "g1", "g2", "Solo"), v("m4", "g3", "g2", "Solo")]), { m4: "Solo" }, - "un solo votante basta se non c'è concorrenza", + "due voti allo stesso candidato: quorum raggiunto", ); // Tre candidati: i primi due pari in testa, un terzo staccato. Deve restare senza MVP, @@ -83,10 +93,15 @@ assert.deepEqual( "la parità non assegna", ); assert.deepEqual( - mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]), + mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), { g2: 2, g5: 1 }, "i titoli si sommano su partite diverse", ); +assert.deepEqual( + mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]), + { g2: 1, g5: 1 }, + "m5 ha un solo voto: sotto quorum, non conta", +); assert.deepEqual(mvpVintiPerGiocatore([]), {}); // --- votoMvpAperto: due ore dopo il fischio d'inizio ------------------------- diff --git a/test/unit/pagelle.test.ts b/test/unit/pagelle.test.ts index cae1b98..d30da85 100644 --- a/test/unit/pagelle.test.ts +++ b/test/unit/pagelle.test.ts @@ -2,11 +2,13 @@ import assert from "node:assert/strict"; import { mediaSquadra, + mediaVotoColpoDOcchio, mediePagelle, mieiVoti, pagellePartita, type VotoPagella, } from "@/lib/pagelle"; +import { VOTI_MINIMI_PAGELLA } from "@/lib/badges"; const voto = ( match_id: string, @@ -61,4 +63,17 @@ assert.deepEqual(mieiVoti(voti, "m1", "g99"), {}, "chi non ha votato non ha voti assert.equal(mediaSquadra(voti), 6.8, "(8+7+6+6)/4 = 6.75 → 6.8"); assert.equal(mediaSquadra([]), 0, "nessun voto: media zero, non NaN"); +// --- mediaVotoColpoDOcchio: soglia minima per la StatTile home (DD-028) ------ +assert.equal( + mediaVotoColpoDOcchio({ mediaVoto: 10, votiPagella: VOTI_MINIMI_PAGELLA - 1 }), + "—", + "sotto soglia: nascosta anche con media altissima", +); +assert.equal( + mediaVotoColpoDOcchio({ mediaVoto: 7.5, votiPagella: VOTI_MINIMI_PAGELLA }), + 7.5, + "esattamente in soglia: la media conta", +); +assert.equal(mediaVotoColpoDOcchio({ mediaVoto: 0, votiPagella: 0 }), "—", "nessun voto ricevuto"); + console.log("pagelle: ok");