From 99265f6a1ee92d4c3ca3f1d130d854afdfccf604 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 15 Nov 2026 19:36:00 +0100 Subject: [PATCH] Calcoli di badge, obiettivi e percentuali spostati da route e componenti a src/lib squadra: mediaPresenzeRosa, obiettiviCompletati, mediaProgressoObiettivi, quantiAlGrado; scout: percentualePiu; utils: percentuale (usata da profilo, RosaPresenze, CollezioneBadge). Test unitari per ciascuna. Co-Authored-By: Claude Sonnet 5.5 --- src/components/crapp/CollezioneBadge.tsx | 4 ++-- src/components/crapp/RosaPresenze.tsx | 4 ++-- src/components/crapp/ScoutReport.tsx | 8 +++---- src/lib/badges.ts | 8 +++++++ src/lib/obiettivi.ts | 19 ++++++++++++++++ src/lib/scout-store.ts | 5 ++++ src/lib/utils.ts | 5 ++++ src/routes/profilo.tsx | 4 ++-- src/routes/squadra.tsx | 29 ++++++++++-------------- test/unit/badges.test.ts | 14 ++++++++++++ test/unit/obiettivi.test.ts | 21 +++++++++++++++++ test/unit/scout-store.test.ts | 5 ++++ test/unit/utils.test.ts | 10 ++++++-- 13 files changed, 106 insertions(+), 30 deletions(-) diff --git a/src/components/crapp/CollezioneBadge.tsx b/src/components/crapp/CollezioneBadge.tsx index eb604ff..5fa4452 100644 --- a/src/components/crapp/CollezioneBadge.tsx +++ b/src/components/crapp/CollezioneBadge.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import { cn } from "@/lib/utils"; +import { cn, percentuale } from "@/lib/utils"; import type { Giocatore } from "@/lib/crapp-data"; import { collezioneBadge, @@ -134,7 +134,7 @@ export function CollezioneBadge({ const vicino = prossimoTraguardo(g); const social = badgeSocialVinti(votiSocial, g.id); const socialVinti = categorieSocial.filter((cat) => social[cat.id]); - const pct = Math.round((c.ottenuti / c.totali) * 100); + const pct = percentuale(c.ottenuti, c.totali); return (
diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index 43a3d7e..fcdd6be 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { BellRing, HelpCircle, Loader2 } from "lucide-react"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; +import { cn, percentuale } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import { Avatar } from "@/components/crapp/Avatar"; import { Barra } from "@/components/motion/Barra"; @@ -44,7 +44,7 @@ export function RosaPresenze({ const mancanti = rosa.filter((g) => !risposte[g.id]); const risposteN = rosa.length - mancanti.length; - const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0; + const perc = percentuale(risposteN, rosa.length); const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length; const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine; diff --git a/src/components/crapp/ScoutReport.tsx b/src/components/crapp/ScoutReport.tsx index 7511397..563fa89 100644 --- a/src/components/crapp/ScoutReport.tsx +++ b/src/components/crapp/ScoutReport.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { BarChart3, Download, Info } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn, percentuale } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import type { Giocatore } from "@/lib/crapp-data"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; @@ -10,11 +10,9 @@ import { totaliPerGiocatore, totaliSquadra, type ScoutMatch, + percentualePiu, } from "@/lib/scout-store"; -const pct = (piu: number, meno: number) => - piu + meno === 0 ? null : Math.round((piu / (piu + meno)) * 100); - /** Lettura dello scout di una partita: squadra per fondamentale, set per set, dettaglio per giocatore. */ export function ScoutReport({ scout, @@ -149,7 +147,7 @@ export function ScoutReport({
{fondamentali.map((f) => { const { piu, meno } = squadra[f.id]; - const p = pct(piu, meno); + const p = percentualePiu(piu, meno); return (
diff --git a/src/lib/badges.ts b/src/lib/badges.ts index 178a070..6999cff 100644 --- a/src/lib/badges.ts +++ b/src/lib/badges.ts @@ -194,6 +194,14 @@ export function gradoRaggiunto(def: BadgeDef, valore: number): Grado | null { return grado; } +/** Quanti giocatori della rosa hanno raggiunto almeno `grado` per il badge `def`. */ +export function quantiAlGrado(def: BadgeDef, rosa: Giocatore[], grado: Grado): number { + return rosa.filter((g) => { + const raggiunto = gradoRaggiunto(def, def.valore(g)); + return raggiunto ? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado) : false; + }).length; +} + export function statoBadge(def: BadgeDef, g: Giocatore): BadgeStato { const valore = def.valore(g); const grado = gradoRaggiunto(def, valore); diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index 7c9064f..ac0e681 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -415,6 +415,25 @@ export function progressoObiettivo(o: ObiettivoSquadra) { return Math.min(100, Math.round((o.valore / o.target) * 100)); } +export function obiettiviCompletati(obiettivi: ObiettivoSquadra[]) { + return obiettivi.filter((o) => progressoObiettivo(o) >= 100).length; +} + +export function mediaProgressoObiettivi(obiettivi: ObiettivoSquadra[]) { + return obiettivi.length + ? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length) + : 0; +} + +/** Media, in percentuale, della quota di eventi a cui ogni giocatore della rosa ha partecipato. */ +export function mediaPresenzeRosa(rosa: Giocatore[]) { + return rosa.length + ? Math.round( + (rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100, + ) + : 0; +} + export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) { return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => { const pa = progressoObiettivo(a); diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index 01efaae..3fb3641 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -96,6 +96,11 @@ export function contaValutazioni( return out; } +/** Percentuale di + sul totale (+ e −) di un fondamentale; `null` se non ci sono valutazioni. */ +export function percentualePiu(piu: number, meno: number): number | null { + return piu + meno === 0 ? null : Math.round((piu / (piu + meno)) * 100); +} + /** * Riepilogo delle valutazioni per la lettura nell'app: totali di squadra e per giocatore, * su tutta la partita o su un solo set. Chi non ha valutazioni nel periodo non compare. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a5ef193..0ac12a2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** Percentuale intera di `parte` su `totale`; 0 se il totale è 0. */ +export function percentuale(parte: number, totale: number): number { + return totale ? Math.round((parte / totale) * 100) : 0; +} diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 2b94190..be6b58d 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -2,7 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Flame, Trash2, Bell, Mail, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn, percentuale } from "@/lib/utils"; import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { Avatar } from "@/components/crapp/Avatar"; @@ -87,7 +87,7 @@ function ProfiloGiocatore() { const tecnici = totaliTecnici(scoutMatches, g.id); - const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0; + const percPresenze = percentuale(g.presenze, g.totaliEventi); return ( <> diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 472ed07..a9141cf 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, inizialiDa } from "@/lib/crapp-data"; import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; -import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; +import { + mediaPresenzeRosa, + mediaProgressoObiettivi, + microcopyObiettivo, + obiettiviCompletati, + progressoObiettivo, +} from "@/lib/obiettivi"; import { useRosa, useObiettivi, @@ -45,7 +51,7 @@ import { descrizioneSoglie, gradiOrdine, gradoMeta, - gradoRaggiunto, + quantiAlGrado, collezioneBadge, } from "@/lib/badges"; @@ -111,20 +117,14 @@ function Squadra() { const [filtroAperto, setFiltroAperto] = useState(false); const obiettivi = useObiettivi(); const team = totaliSquadraValutazioni(scoutMatches); - const mediaPresenze = rosa.length - ? Math.round( - (rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100, - ) - : 0; + const mediaPresenze = mediaPresenzeRosa(rosa); 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; - const completati = obiettivi.filter((o) => progressoObiettivo(o) >= 100).length; - const mediaObiettivi = obiettivi.length - ? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length) - : 0; + const completati = obiettiviCompletati(obiettivi); + const mediaObiettivi = mediaProgressoObiettivi(obiettivi); // Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`) // non deve ricalcolare badge e obiettivi delle altre sezioni. @@ -504,12 +504,7 @@ function Squadra() {
{gradiOrdine.map((grado) => { const meta = gradoMeta[grado]; - const quanti = rosa.filter((g) => { - const raggiunto = gradoRaggiunto(b, b.valore(g)); - return raggiunto - ? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado) - : false; - }).length; + const quanti = quantiAlGrado(b, rosa, grado); return (
quantiAlGrado(def, rosa, grado); + assert.ok( + n("bronzo") >= n("argento") && n("argento") >= n("oro"), + "i gradi alti non superano i bassi", + ); + assert.equal(n("bronzo"), rosa.filter((x) => gradoRaggiunto(def, def.valore(x)) !== null).length); +} + console.log("badges: ok"); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 77da3cc..6e29f5a 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -6,7 +6,10 @@ import type { VotoPagella } from "@/lib/pagelle"; import type { MappaPresenze } from "@/lib/presenze"; import { contestoVuoto, + mediaPresenzeRosa, + mediaProgressoObiettivi, microcopyObiettivo, + obiettiviCompletati, obiettiviOrdinati, obiettiviSquadra, progressoObiettivo, @@ -735,4 +738,22 @@ for (const ob of obiettiviSquadra(giocatori, contestoVuoto)) { } } +// --- riepiloghi usati dalla pagina Squadra ----------------------------------------- +{ + const ob = (valore: number): ObiettivoSquadra => + ({ ...obiettiviSquadra(giocatori, contestoVuoto)[0]!, valore, target: 10 }) as ObiettivoSquadra; + assert.equal(obiettiviCompletati([]), 0); + assert.equal(mediaProgressoObiettivi([]), 0); + assert.equal(obiettiviCompletati([ob(10), ob(15), ob(3)]), 2); + assert.equal(mediaProgressoObiettivi([ob(10), ob(0)]), 50); + assert.equal(mediaPresenzeRosa([]), 0); + const p = (presenze: number, totaliEventi: number) => ({ + ...giocatori[0]!, + presenze, + totaliEventi, + }); + assert.equal(mediaPresenzeRosa([p(5, 10), p(10, 10)]), 75); + assert.equal(mediaPresenzeRosa([p(0, 0)]), 0, "nessun evento: niente divisione per zero"); +} + console.log("obiettivi: ok"); diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts index 316426c..7958a01 100644 --- a/test/unit/scout-store.test.ts +++ b/test/unit/scout-store.test.ts @@ -6,6 +6,7 @@ import { contaPiuSenzaRicezione, haPunteggio, parzialiFinali, + percentualePiu, sintesiValutazioni, contaValutazioni, fondamentali, @@ -269,4 +270,8 @@ assert.deepEqual( assert.deepEqual(set2.squadra.muro, { piu: 1, meno: 0 }); assert.deepEqual(set2.squadra.battuta, { piu: 0, meno: 0 }); assert.equal(sintesiValutazioni([]).perGiocatore.length, 0); +assert.equal(percentualePiu(0, 0), null); +assert.equal(percentualePiu(3, 1), 75); +assert.equal(percentualePiu(0, 4), 0); + console.log("scout-store sintesi: ok"); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 5a3217a..16e5dc3 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -1,6 +1,6 @@ -/** Check dell'helper di classi CSS: `bun test/unit/utils.test.ts`. */ +/** Check dell'helper di utils (classi CSS e percentuale): `bun test/unit/utils.test.ts`. */ import assert from "node:assert/strict"; -import { cn } from "@/lib/utils"; +import { cn, percentuale } from "@/lib/utils"; // --- cn: unisce classi statiche e condizionali -------------------------------- assert.equal(cn("a", "b"), "a b"); @@ -16,4 +16,10 @@ assert.equal( "classi non in conflitto convivono", ); +// --- percentuale ----------------------------------------------------------------- +assert.equal(percentuale(1, 0), 0, "totale 0: nessuna divisione per zero"); +assert.equal(percentuale(1, 3), 33); +assert.equal(percentuale(2, 3), 67); +assert.equal(percentuale(5, 5), 100); + console.log("utils: ok");