From 15f058f76043adee56c660308cdad7f95a57ac0f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 9 Sep 2026 10:55:06 +0200 Subject: [PATCH] =?UTF-8?q?Corregge=20la=20classifica=20interna=20di=20Squ?= =?UTF-8?q?adra=20a=20parit=C3=A0=20di=20punteggio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I giocatori a pari valore condividono ora la stessa posizione (dense rank) invece di essere numerati in sequenza, e il sottotitolo di ogni riga segue il criterio scelto invece di mostrare sempre le presenze consecutive. Per Palloni mostra le volte consecutive in cui il giocatore li ha portati (nuovo Giocatore.seriePalloni). Co-Authored-By: Claude Sonnet 5 --- docs/CHANGELOG.md | 13 ++++++++ docs/modules/palloni.md | 5 +++ src/lib/crapp-data.ts | 3 ++ src/lib/palloni-core.ts | 18 +++++++++++ src/lib/rosa.ts | 52 +++++++++++++++++++++++++++++- src/routes/squadra.tsx | 19 +++++++---- test/unit/palloni-core.test.ts | 52 ++++++++++++++++++++++++++++++ test/unit/rosa.test.ts | 59 ++++++++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 test/unit/rosa.test.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index acbc51c..266543b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -90,6 +90,19 @@ Prima versione, pre-release. (migration `m16_funzione_bonifica_dati_evento_orfani`, riservata al service role), coperta da test di integrazione invece che verificata solo a mano (DD-029). +### Corretto + +- Classifica interna di Squadra: a parità di valore (es. stesse presenze) i giocatori + condividono ora la stessa posizione invece di essere numerati in sequenza (`classificaRank` + in `src/lib/rosa.ts`); la corona di primo posto va a tutti i pari merito in testa, non solo + al primo dell'elenco. +- Il sottotitolo di ogni riga della classifica interna di Squadra mostrava sempre le + "presenze consecutive" anche ordinando per Media voto, MVP, Palloni o Cacche, un dato + scollegato dal criterio scelto: ora segue il criterio selezionato (`dettaglioClassifica` in + `src/lib/rosa.ts`). Per Palloni mostra le volte consecutive in cui il giocatore li ha + portati (nuovo campo `Giocatore.seriePalloni`, calcolato da `serieConsecutivaPalloni` in + `src/lib/palloni-core.ts`), non più le presenze. + ### Sicurezza - Migration `m4_solo_autenticati`: tolto al ruolo `anon` l'accesso alle tabelle dell'app diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index 3470ebe..70ae62b 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -39,6 +39,11 @@ compaiono. `TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel giorno non arriva. +- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il + giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel + sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso + criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno + confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati"). - `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx` è il banner in Home per il giocatore di turno. diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index 1abdff5..87b598b 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -41,6 +41,8 @@ export type Giocatore = { votiPagella: number; /** Quante volte ha portato i palloni. */ palloni: number; + /** Volte consecutive (fino a oggi) in cui ha portato i palloni. */ + seriePalloni: number; /** Partite di campionato con almeno 3 cacche dichiarate. */ cacche: number; /** Media di cacche dichiarate per partita. */ @@ -111,6 +113,7 @@ export const giocatori: Giocatore[] = rosaCSI infortuni: 0, ritardi: 0, palloni: 0, + seriePalloni: 0, cacche: 0, cacchePartita: 0, })) diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts index 02372a1..0d0f492 100644 --- a/src/lib/palloni-core.ts +++ b/src/lib/palloni-core.ts @@ -1,6 +1,7 @@ import { formatData } from "./crapp-data"; import type { Evento } from "./eventi"; import { dataOggi } from "./scout-live"; +import { aggiornaSerie } from "./serie"; export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null }; @@ -81,6 +82,23 @@ export function conteggioTurni( return out; } +/** + * Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già + * passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza + * turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato + * i palloni quella volta. + */ +export function serieConsecutivaPalloni( + giocatoreId: string, + turni: Record, + eventi: Evento[], + oggi: string = dataOggi(), +): number { + return eventiPalloni(eventi) + .filter((e) => e.data < oggi && turni[e.id]) + .reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0); +} + export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] { return eventiPalloni(eventi).filter((e) => e.data === isoData); } diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index f671c8c..671df11 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -4,7 +4,7 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti"; import { mediePagelle, usePagelle } from "./pagelle"; import { statisticheCacche, useCacche } from "./cacche"; -import { conteggioTurni } from "./palloni-core"; +import { conteggioTurni, serieConsecutivaPalloni } from "./palloni-core"; import { useTurniPalloni } from "./palloni"; import { useInfortuniERitardi } from "./infortuni"; import { useGiocatoreId } from "./user-store"; @@ -96,6 +96,7 @@ export function useRosa(): Giocatore[] { mediaVoto: medie[g.id]?.media ?? 0, votiPagella: medie[g.id]?.voti ?? 0, palloni: palloni[g.id] ?? 0, + seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi), cacche: statCacche[g.id]?.giornateTop ?? 0, cacchePartita: statCacche[g.id]?.media ?? 0, infortuni: infortuni[g.id] ?? 0, @@ -115,6 +116,55 @@ export function useRosa(): Giocatore[] { ]); } +/** Criteri di ordinamento della classifica interna di Squadra. */ +export type CriterioClassifica = "presenze" | "mediaVoto" | "mvp" | "palloni" | "cacchePartita"; + +/** + * Dettaglio mostrato sotto il nome nella classifica interna, coerente col criterio + * selezionato: mostrare sempre le "presenze consecutive" aveva senso solo per Presenze, + * per gli altri criteri era un dato fuorviante perché scollegato dal valore in classifica. + */ +export function dettaglioClassifica( + g: { + streak: number; + votiPagella: number; + totaliEventi: number; + cacche: number; + seriePalloni: number; + }, + criterio: CriterioClassifica, +): string { + switch (criterio) { + case "mediaVoto": + return `${g.votiPagella} voti pagella`; + case "mvp": + return `${g.totaliEventi} partite giocate`; + case "cacchePartita": + return `${g.cacche} giornate top`; + case "palloni": + return `${g.seriePalloni} volte consecutive`; + default: + return `${g.streak} presenze consecutive`; + } +} + +/** + * Posizione in classifica ("dense rank"): a parità di valore i giocatori condividono la + * stessa posizione e il numero successivo non salta (1, 1, 2 — non 1, 1, 3). `valori` deve + * essere già ordinato in modo decrescente, coerente con l'ordine visualizzato. + */ +export function classificaRank(valori: number[]): number[] { + const rank: number[] = []; + for (let i = 0; i < valori.length; i++) { + if (i > 0 && valori[i] === valori[i - 1]) { + rank.push(rank[i - 1] ?? 1); + } else { + rank.push((rank[i - 1] ?? 0) + 1); + } + } + return rank; +} + /** Il giocatore selezionato sul dispositivo, con le statistiche complete. */ export function useIo(): Giocatore | null { const id = useGiocatoreId(); diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 8018776..7b9442d 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -7,7 +7,13 @@ import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { Avatar } from "@/components/crapp/Avatar"; import { formatData } from "@/lib/crapp-data"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; -import { useRosa, useObiettivi } from "@/lib/rosa"; +import { + useRosa, + useObiettivi, + classificaRank, + dettaglioClassifica, + type CriterioClassifica, +} from "@/lib/rosa"; import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese"; import { totaliSquadra, useScoutMatches } from "@/lib/scout-store"; import { useCsi } from "@/lib/csi"; @@ -70,7 +76,7 @@ const criteri = [ { id: "cacchePartita", label: "Cacche" }, ] as const; -type Criterio = (typeof criteri)[number]["id"]; +type Criterio = CriterioClassifica; function valore( g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number }, @@ -95,6 +101,7 @@ function Squadra() { ) : 0; const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio)); + const rank = classificaRank(ordinati.map((g) => valore(g, criterio))); const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1; const { data: csi } = useCsi(); const matchGiocati = csi ? partiteGiocate(csi.partite).length : scoutMatches.length; @@ -288,17 +295,17 @@ function Squadra() {
- {i + 1} + {rank[i]}

{g.nome} - {i === 0 ? ( + {rank[i] === 1 ? ( ) : null}

- #{g.numero} · {g.ruolo} · {g.streak} presenze consecutive + #{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}

@@ -317,7 +324,7 @@ function Squadra() {
), - [mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, max], + [mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max], ); const contenutoObiettivi = useMemo( diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts index 7954766..cebeec7 100644 --- a/test/unit/palloni-core.test.ts +++ b/test/unit/palloni-core.test.ts @@ -12,6 +12,7 @@ import { eventoPrecedente, eventoSuccessivo, oggiISO, + serieConsecutivaPalloni, } from "@/lib/palloni-core"; const rosa = giocatori.map((g) => ({ id: g.id, nome: g.nome })); @@ -213,4 +214,55 @@ assert.deepEqual( "senza turni assegnati non c'è nessuno da avvisare", ); +// --- serieConsecutivaPalloni: volte consecutive in cui li ha portati lui ----- +const eventiSerie: Evento[] = [ + evento("s1", "2026-04-01", "partita"), + evento("s2", "2026-04-02", "partita"), + evento("s3", "2026-04-03", "partita"), + evento("s4", "2026-04-04", "partita"), + evento("s5", "2026-04-05", "partita"), // futuro rispetto a OGGI_SERIE +]; +const OGGI_SERIE = "2026-04-05"; + +assert.equal( + serieConsecutivaPalloni( + "g1", + { s1: "g1", s2: "g1", s3: "g2", s4: "g1" }, + eventiSerie, + OGGI_SERIE, + ), + 1, + "un turno di un altro giocatore azzera la serie: conta solo l'ultimo tratto consecutivo", +); +assert.equal( + serieConsecutivaPalloni( + "g2", + { s1: "g1", s2: "g1", s3: "g2", s4: "g1" }, + eventiSerie, + OGGI_SERIE, + ), + 0, + "l'ultimo evento passato non è suo: serie a zero anche se ne ha uno nel mezzo", +); +assert.equal( + serieConsecutivaPalloni("g1", { s1: "g1", s2: "g1", s3: "g1" }, eventiSerie, OGGI_SERIE), + 3, + "tre volte di fila fino all'ultimo evento passato", +); +assert.equal( + serieConsecutivaPalloni("g1", { s1: "g1", s2: "g1", s4: "g1" }, eventiSerie, OGGI_SERIE), + 3, + "s3 senza turno confermato non spezza la serie: viene saltato, non conta contro nessuno", +); +assert.equal( + serieConsecutivaPalloni("g1", { s1: "g1", s5: "g1" }, eventiSerie, OGGI_SERIE), + 1, + "il turno dell'evento futuro (s5) non è ancora contato", +); +assert.equal( + serieConsecutivaPalloni("g1", {}, eventiSerie, OGGI_SERIE), + 0, + "nessun turno assegnato: nessuna serie", +); + console.log("palloni-core: ok"); diff --git a/test/unit/rosa.test.ts b/test/unit/rosa.test.ts new file mode 100644 index 0000000..3f41ed2 --- /dev/null +++ b/test/unit/rosa.test.ts @@ -0,0 +1,59 @@ +/** Check della classifica della rosa: `bun test/unit/rosa.test.ts`. */ +import assert from "node:assert/strict"; +import { classificaRank, dettaglioClassifica } from "@/lib/rosa"; + +// --- classificaRank: dense rank, a parità di valore stessa posizione ----------- + +assert.deepEqual( + classificaRank([4, 4, 3]), + [1, 1, 2], + "due giocatori a pari merito condividono la posizione, il numero successivo non salta", +); + +assert.deepEqual( + classificaRank([5, 4, 3, 2]), + [1, 2, 3, 4], + "nessun pareggio: posizione progressiva normale", +); + +assert.deepEqual( + classificaRank([3, 3, 3, 1]), + [1, 1, 1, 2], + "tre giocatori a pari merito in testa condividono tutti la posizione #1", +); + +assert.deepEqual(classificaRank([]), [], "rosa vuota: nessuna posizione"); + +assert.deepEqual(classificaRank([0, 0]), [1, 1], "parità anche a valore zero"); + +// --- dettaglioClassifica: il sottotitolo segue il criterio selezionato ------- + +const g = { streak: 12, votiPagella: 18, totaliEventi: 22, cacche: 3, seriePalloni: 4 }; + +assert.equal( + dettaglioClassifica(g, "presenze"), + "12 presenze consecutive", + "presenze: mostra la serie di presenze consecutive", +); +assert.equal( + dettaglioClassifica(g, "palloni"), + "4 volte consecutive", + "palloni: mostra le volte consecutive in cui ha portato i palloni, non le presenze", +); +assert.equal( + dettaglioClassifica(g, "mediaVoto"), + "18 voti pagella", + "media voto: mostra quanti voti compongono la media", +); +assert.equal( + dettaglioClassifica(g, "mvp"), + "22 partite giocate", + "mvp: mostra le partite giocate, non le presenze consecutive", +); +assert.equal( + dettaglioClassifica(g, "cacchePartita"), + "3 giornate top", + "cacche: mostra le giornate da primo classificato", +); + +console.log("rosa: ok");