Applica una soglia minima di campione a Media voto e MVP in home (DD-028)

Rimuove l'hint statico "+2 questo mese" dalla StatTile Presenze. La StatTile
Media voto usa ora la stessa soglia minima di voti del badge Pagellone
(VOTI_MINIMI_PAGELLA), tramite la nuova funzione pura testabile
mediaVotoColpoDOcchio(). L'MVP di partita richiede un quorum minimo di 2
voti totali (VOTI_MINIMI_MVP) oltre al margine netto già richiesto: un
solo voto non assegna più la vittoria, con effetto anche sui conteggi
già mostrati. Aggiorna test unitari e di integrazione, e documenta la
decisione in DESIGN_DECISIONS.md, pagelle.md, mvp.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 09:56:56 +02:00
co-authored by Claude Sonnet 5
parent 6d177d5d8a
commit 00235d1594
12 changed files with 165 additions and 19 deletions
+6
View File
@@ -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
+52
View File
@@ -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.
+5 -1
View File
@@ -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.
---
+10 -5
View File
@@ -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).
+21 -4
View File
@@ -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<string, ConteggioMvp>();
@@ -83,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
const out: Record<string, string> = {};
for (const [matchId] of perMatch) {
const top = conteggioPartita(voti, matchId);
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
}
}
@@ -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<string, number> {
const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id));
for (const matchId of matchIds) {
const top = conteggioPartita(voti, matchId);
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
+12 -1
View File
@@ -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<string, MediaPagella> {
const somma: Record<string, { tot: number; n: number }> = {};
@@ -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;
+1 -1
View File
@@ -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[],
+3 -2
View File
@@ -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() {
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" hint="+2 questo mese" />
<StatTile valore={giocatore.mediaVoto || ""} label="Media voto" />
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
+8 -1
View File
@@ -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(
+15 -2
View File
@@ -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",
+17 -2
View File
@@ -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 -------------------------
+15
View File
@@ -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");