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");