Sposto rosa, classifica e storico partite dagli array hardcoded al CMS Strapi
Nuovi moduli src/lib/strapi-client.ts + rosa-base.ts/classifica-csi.ts/ storico-match.ts (hook TanStack Query, cache lunga, nessun polling). L'id stabile gN diventa il campo `codice` su Strapi, per non rompere le FK verso pagelle/cacche/mvp/palloni/presenze/azioni scout già chiavate su quella stringa. isAdmin ora prende il Giocatore invece di un id, così non dipende più dal roster. Rifattorizzati di conseguenza tutti i punti che importavano `giocatori`/`classifica`/`storicoMatch` come array sincroni (componenti, hook, due route server) per usare gli hook o un parametro esplicito. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatData, giocatori, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
|
||||
const tipoMeta = {
|
||||
partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
|
||||
@@ -36,6 +37,7 @@ export function EventoCard({
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = giocatori.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -118,33 +120,35 @@ export function EventoCard({
|
||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
|
||||
|
||||
{io ? (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending}
|
||||
onClick={() =>
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
|
||||
attivo ? cn(meta.className, "border-transparent shadow-card") : "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending}
|
||||
onClick={() =>
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
|
||||
attivo
|
||||
? cn(meta.className, "border-transparent shadow-card")
|
||||
: "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ClipboardCheck, Lock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||
|
||||
@@ -12,21 +13,23 @@ const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
/** Pagelle di fine partita: voto anonimo 1-10 a ciascun compagno. */
|
||||
export function Pagelle({
|
||||
matchId,
|
||||
convocati = giocatori,
|
||||
convocati,
|
||||
chiuse = false,
|
||||
}: {
|
||||
matchId: string;
|
||||
convocati?: typeof giocatori;
|
||||
convocati?: Giocatore[];
|
||||
chiuse?: boolean;
|
||||
}) {
|
||||
const rosaBase = useRosaBase();
|
||||
const io = useGiocatoreCorrente();
|
||||
const { voti: tutti, isPending } = usePagelle();
|
||||
const vota = useVotaPagella();
|
||||
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
||||
|
||||
const lista = convocati ?? rosaBase;
|
||||
const medie = pagellePartita(tutti, matchId);
|
||||
const miei = io ? mieiVoti(tutti, matchId, io.id) : {};
|
||||
const daVotare = convocati.filter((g) => g.id !== io?.id);
|
||||
const daVotare = lista.filter((g) => g.id !== io?.id);
|
||||
const fatti = daVotare.filter((g) => miei[g.id] !== undefined).length;
|
||||
|
||||
async function invia(votatoId: string, voto: number) {
|
||||
@@ -58,7 +61,7 @@ export function Pagelle({
|
||||
<p className="mt-3 text-xs text-muted-foreground">Carico le pagelle…</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{convocati.map((g) => {
|
||||
{lista.map((g) => {
|
||||
const media = medie[g.id];
|
||||
const mio = miei[g.id];
|
||||
const sonoIo = g.id === io?.id;
|
||||
@@ -104,7 +107,9 @@ export function Pagelle({
|
||||
onClick={() => invia(g.id, v)}
|
||||
className={cn(
|
||||
"rounded-lg py-1.5 text-[11px] font-bold tabular-nums transition-transform active:scale-90",
|
||||
mio === v ? "bg-accent text-accent-foreground" : "bg-card text-foreground",
|
||||
mio === v
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { isAdmin, statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
@@ -14,12 +15,14 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
|
||||
const mancanti = giocatori.filter((g) => !risposte[g.id]);
|
||||
const risposteN = giocatori.length - mancanti.length;
|
||||
const perc = Math.round((risposteN / giocatori.length) * 100);
|
||||
const daSollecitare = mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length;
|
||||
const daSollecitare =
|
||||
mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length;
|
||||
|
||||
async function sollecita() {
|
||||
setSollecito(true);
|
||||
@@ -105,7 +108,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io.id) ? (
|
||||
{io && isAdmin(io) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
@@ -159,7 +162,7 @@ function Gruppo({
|
||||
}: {
|
||||
titolo: string;
|
||||
n: number;
|
||||
lista: typeof giocatori;
|
||||
lista: Giocatore[];
|
||||
attenzione?: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "com
|
||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||
|
||||
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
|
||||
const abilitato = !!io && isAdmin(io.id);
|
||||
const abilitato = !!io && isAdmin(io);
|
||||
|
||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||
const occupato = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche";
|
||||
|
||||
@@ -9,6 +9,7 @@ const opzioni = [0, 1, 2, 3, 4, 5];
|
||||
/** Sondaggio goliardico pre-partita: quante cacche prima del fischio d'inizio. */
|
||||
export function SondaggioCacche({ eventoId }: { eventoId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const { righe } = useCacche();
|
||||
const salva = useSalvaCacche();
|
||||
|
||||
@@ -68,7 +69,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
|
||||
Media squadra {media}
|
||||
</span>
|
||||
{classifica.map((r, i) => (
|
||||
<span key={r.giocatore_id} className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
<span
|
||||
key={r.giocatore_id}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
|
||||
>
|
||||
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const giocatori = useRosaBase();
|
||||
const { salvati, turni, isPending } = useTurniPalloni();
|
||||
const assegna = useAssegnaTurno();
|
||||
const io = useGiocatoreCorrente();
|
||||
@@ -78,4 +79,4 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useState } from "react";
|
||||
import { Crown, Vote } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
|
||||
|
||||
/** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */
|
||||
export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const voti = useVotiMvp();
|
||||
const vota = useVotaMvp();
|
||||
const [aperto, setAperto] = useState(false);
|
||||
@@ -97,4 +98,4 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Check, Crown, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import {
|
||||
categorieSocial,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
||||
export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const voti = useVotiSocial();
|
||||
const vota = useVotaSocial();
|
||||
const [aperta, setAperta] = useState<string | null>(null);
|
||||
@@ -122,8 +123,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||
{vincitore.nome} · {vincitore.voti}{" "}
|
||||
{vincitore.voti === 1 ? "voto" : "voti"}
|
||||
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
@@ -137,4 +137,4 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { strapiFetch } from "./strapi-client";
|
||||
import type { RigaClassifica } from "./crapp-data";
|
||||
|
||||
type RigaClassificaStrapi = {
|
||||
pos: number;
|
||||
squadra: string;
|
||||
giocate: number;
|
||||
vinte: number;
|
||||
perse: number;
|
||||
set_fatti: number;
|
||||
set_subiti: number;
|
||||
punti: number;
|
||||
};
|
||||
|
||||
function daEntry(e: RigaClassificaStrapi): RigaClassifica {
|
||||
return {
|
||||
pos: e.pos,
|
||||
squadra: e.squadra,
|
||||
giocate: e.giocate,
|
||||
vinte: e.vinte,
|
||||
perse: e.perse,
|
||||
setFatti: e.set_fatti,
|
||||
setSubiti: e.set_subiti,
|
||||
punti: e.punti,
|
||||
};
|
||||
}
|
||||
|
||||
export const CLASSIFICA_KEY = ["classifica-csi"] as const;
|
||||
|
||||
async function fetchClassifica(): Promise<RigaClassifica[]> {
|
||||
const entries = await strapiFetch<RigaClassificaStrapi>("riga-classificas?sort=pos");
|
||||
return entries.map(daEntry);
|
||||
}
|
||||
|
||||
/** Classifica campionato CSI dal CMS: cambia a giornata, non a sessione. */
|
||||
export function useClassificaCsi(): RigaClassifica[] {
|
||||
const query = useQuery({
|
||||
queryKey: CLASSIFICA_KEY,
|
||||
queryFn: fetchClassifica,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
+12
-105
@@ -2,10 +2,18 @@ export type Stato = "presente" | "assente" | "forse" | "ritardo" | "infortunato"
|
||||
|
||||
export const statoMeta: Record<Stato, { label: string; emoji: string; className: string }> = {
|
||||
presente: { label: "Presente", emoji: "✅", className: "bg-success text-success-foreground" },
|
||||
assente: { label: "Assente", emoji: "❌", className: "bg-destructive text-destructive-foreground" },
|
||||
assente: {
|
||||
label: "Assente",
|
||||
emoji: "❌",
|
||||
className: "bg-destructive text-destructive-foreground",
|
||||
},
|
||||
forse: { label: "Forse", emoji: "🤔", className: "bg-warning text-warning-foreground" },
|
||||
ritardo: { label: "In ritardo", emoji: "⏱️", className: "bg-info text-info-foreground" },
|
||||
infortunato: { label: "Infortunato", emoji: "🩹", className: "bg-primary text-primary-foreground" },
|
||||
infortunato: {
|
||||
label: "Infortunato",
|
||||
emoji: "🩹",
|
||||
className: "bg-primary text-primary-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -42,89 +50,6 @@ export type Giocatore = {
|
||||
iniziali: string;
|
||||
};
|
||||
|
||||
type Rosa = { nome: string; nascita: string; ruolo: string; numero?: number };
|
||||
|
||||
/** Rosa reale CRAP Volley (tesseramento CSI Bologna 25/26). */
|
||||
const rosaCSI: Rosa[] = [
|
||||
{ nome: "Salvador Battistella", nascita: "1997-08-30", ruolo: "Libero", numero: 88 },
|
||||
{ nome: "Mattias Bologna", nascita: "1996-12-07", ruolo: "Centrale", numero: 73 },
|
||||
{ nome: "Alessandra Brunacci", nascita: "2000-04-28", ruolo: "Palleggiatore", numero: 8 },
|
||||
{ nome: "Ivan Cacciari", nascita: "1995-05-01", ruolo: "Banda", numero: 23 },
|
||||
{ nome: "Mattia Catalano", nascita: "1995-08-13", ruolo: "Palleggiatore", numero: 21 },
|
||||
{ nome: "Silvia Chilese", nascita: "1996-02-01", ruolo: "Libero", numero: 11 },
|
||||
{ nome: "Alessio Cocco", nascita: "1997-07-04", ruolo: "Centrale", numero: 77 },
|
||||
{ nome: "Carlo Di Castelnuovo", nascita: "1994-05-28", ruolo: "Opposto", numero: 14 },
|
||||
{ nome: "Camilla Esposito", nascita: "2003-04-04", ruolo: "Palleggiatore", numero: 7 },
|
||||
{ nome: "Davide Grilli", nascita: "1998-11-30", ruolo: "Opposto", numero: 1 },
|
||||
{ nome: "Antonella Loverre", nascita: "2000-02-02", ruolo: "Banda", numero: 22 },
|
||||
{ nome: "Laura Passabì", nascita: "1999-10-03", ruolo: "Banda", numero: 5 },
|
||||
{ nome: "Nicola Pezzoli", nascita: "2000-09-16", ruolo: "Centrale", numero: 4 },
|
||||
{ nome: "Iacopo Ricci", nascita: "1996-12-13", ruolo: "Banda", numero: 2 },
|
||||
{ nome: "Cristina Titone", nascita: "1993-03-24", ruolo: "Libero", numero: 3 },
|
||||
{ nome: "Francesca Tucci", nascita: "2001-04-18", ruolo: "Centrale", numero: 18 },
|
||||
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
|
||||
];
|
||||
|
||||
function inizialiDa(nome: string) {
|
||||
return nome
|
||||
.split(" ")
|
||||
.map((p) => p[0] ?? "")
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
type StatsDemo = Pick<Giocatore, "presenze" | "streak" | "mvp" | "mediaVoto">;
|
||||
|
||||
/** Statistiche demo: mix di badge bronzo / argento / oro già sbloccati. */
|
||||
const statsDemo: Record<string, StatsDemo> = {
|
||||
"Ivan Cacciari": { presenze: 31, streak: 8, mvp: 5, mediaVoto: 8.7 },
|
||||
"Davide Grilli": { presenze: 27, streak: 5, mvp: 3, mediaVoto: 8.4 },
|
||||
"Nicola Pezzoli": { presenze: 24, streak: 4, mvp: 2, mediaVoto: 8.2 },
|
||||
"Laura Passabì": { presenze: 22, streak: 6, mvp: 3, mediaVoto: 8.1 },
|
||||
"Francesca Tucci": { presenze: 19, streak: 3, mvp: 1, mediaVoto: 7.9 },
|
||||
"Iacopo Ricci": { presenze: 21, streak: 2, mvp: 3, mediaVoto: 7.8 },
|
||||
"Alessandra Brunacci": { presenze: 14, streak: 3, mvp: 1, mediaVoto: 7.5 },
|
||||
"Mattias Bologna": { presenze: 12, streak: 2, mvp: 1, mediaVoto: 7.4 },
|
||||
"Giada Valbonesi": { presenze: 13, streak: 4, mvp: 1, mediaVoto: 7.6 },
|
||||
"Alessio Cocco": { presenze: 11, streak: 1, mvp: 0, mediaVoto: 7.2 },
|
||||
"Mattia Catalano": { presenze: 9, streak: 2, mvp: 0, mediaVoto: 7.0 },
|
||||
"Antonella Loverre": { presenze: 8, streak: 1, mvp: 0, mediaVoto: 6.9 },
|
||||
"Carlo Di Castelnuovo": { presenze: 7, streak: 1, mvp: 0, mediaVoto: 6.8 },
|
||||
"Camilla Esposito": { presenze: 6, streak: 2, mvp: 0, mediaVoto: 6.7 },
|
||||
"Salvador Battistella": { presenze: 20, streak: 5, mvp: 1, mediaVoto: 7.7 },
|
||||
"Silvia Chilese": { presenze: 16, streak: 3, mvp: 0, mediaVoto: 7.3 },
|
||||
"Cristina Titone": { presenze: 15, streak: 2, mvp: 0, mediaVoto: 7.1 },
|
||||
};
|
||||
|
||||
/** Serie demo derivate dalle statistiche: ogni tipo ha il suo contatore. */
|
||||
function serieDa(s?: StatsDemo) {
|
||||
const streak = s?.streak ?? 0;
|
||||
const presenze = s?.presenze ?? 0;
|
||||
return {
|
||||
serieAllenamenti: streak,
|
||||
seriePartite: Math.ceil(streak / 2),
|
||||
serieConferme: presenze >= 20 ? 12 : presenze >= 14 ? 8 : presenze >= 8 ? 4 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export const giocatori: Giocatore[] = rosaCSI.map((r, i) => ({
|
||||
id: `g${i + 1}`,
|
||||
nome: r.nome,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
nascita: r.nascita,
|
||||
iniziali: inizialiDa(r.nome),
|
||||
totaliEventi: 32,
|
||||
infortuni: 0,
|
||||
ritardi: 0,
|
||||
palloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
...(statsDemo[r.nome] ?? { presenze: 0, streak: 0, mvp: 0, mediaVoto: 0 }),
|
||||
...serieDa(statsDemo[r.nome]),
|
||||
}));
|
||||
|
||||
export type Match = {
|
||||
id: string;
|
||||
data: string;
|
||||
@@ -136,13 +61,6 @@ export type Match = {
|
||||
mvp: string;
|
||||
};
|
||||
|
||||
export const storicoMatch: Match[] = [
|
||||
{ id: "m1", data: "2026-07-25", avversario: "Pallavolo Sesto", casa: true, setNostri: 3, setLoro: 1, parziali: [[25, 19], [23, 25], [25, 21], [25, 18]], mvp: "Davide Grilli" },
|
||||
{ id: "m2", data: "2026-07-18", avversario: "ASD Rondinella", casa: false, setNostri: 2, setLoro: 3, parziali: [[25, 22], [19, 25], [25, 23], [20, 25], [12, 15]], mvp: "Ivan Cacciari" },
|
||||
{ id: "m3", data: "2026-07-11", avversario: "Virtus Cinisello", casa: true, setNostri: 3, setLoro: 0, parziali: [[25, 15], [25, 20], [25, 17]], mvp: "Laura Passabì" },
|
||||
{ id: "m4", data: "2026-07-04", avversario: "Nuova Bovisa", casa: false, setNostri: 3, setLoro: 2, parziali: [[21, 25], [25, 23], [18, 25], [25, 20], [15, 11]], mvp: "Nicola Pezzoli" },
|
||||
];
|
||||
|
||||
export type RigaClassifica = {
|
||||
pos: number;
|
||||
squadra: string;
|
||||
@@ -154,16 +72,6 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
export const classifica: RigaClassifica[] = [
|
||||
{ pos: 1, squadra: "ASD Rondinella", giocate: 12, vinte: 10, perse: 2, setFatti: 33, setSubiti: 12, punti: 29 },
|
||||
{ pos: 2, squadra: "CRAP Volley", giocate: 12, vinte: 9, perse: 3, setFatti: 31, setSubiti: 16, punti: 26 },
|
||||
{ pos: 3, squadra: "Pallavolo Sesto", giocate: 12, vinte: 8, perse: 4, setFatti: 29, setSubiti: 19, punti: 24 },
|
||||
{ pos: 4, squadra: "Volley Bruzzano", giocate: 12, vinte: 7, perse: 5, setFatti: 26, setSubiti: 21, punti: 21 },
|
||||
{ pos: 5, squadra: "Aurora Nera", giocate: 12, vinte: 5, perse: 7, setFatti: 22, setSubiti: 25, punti: 16 },
|
||||
{ pos: 6, squadra: "Virtus Cinisello", giocate: 12, vinte: 3, perse: 9, setFatti: 15, setSubiti: 30, punti: 10 },
|
||||
{ pos: 7, squadra: "Nuova Bovisa", giocate: 12, vinte: 2, perse: 10, setFatti: 13, setSubiti: 32, punti: 7 },
|
||||
];
|
||||
|
||||
export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
@@ -172,7 +80,6 @@ export function formatData(iso: string) {
|
||||
/** Referenti che possono gestire eventi e sollecitare le risposte. */
|
||||
export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"];
|
||||
|
||||
export function isAdmin(giocatoreId: string) {
|
||||
const g = giocatori.find((x) => x.id === giocatoreId);
|
||||
return Boolean(g && adminNomi.includes(g.nome));
|
||||
export function isAdmin(giocatore: Pick<Giocatore, "nome"> | null | undefined) {
|
||||
return Boolean(giocatore && adminNomi.includes(giocatore.nome));
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
||||
|
||||
@@ -158,7 +158,10 @@ export function useEliminaEvento() {
|
||||
}
|
||||
|
||||
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
|
||||
export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
|
||||
export function compleanniEventi(
|
||||
giocatori: Giocatore[],
|
||||
anno = new Date().getFullYear(),
|
||||
): Evento[] {
|
||||
return giocatori
|
||||
.map((g) => {
|
||||
const md = g.nascita.slice(5);
|
||||
@@ -181,7 +184,7 @@ export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
|
||||
}
|
||||
|
||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||
export function convocatiEvento(evento: Evento | null) {
|
||||
export function convocatiEvento(giocatori: Giocatore[], evento: Evento | null) {
|
||||
if (!evento || evento.convocati.length === 0) return giocatori;
|
||||
return giocatori.filter((g) => evento.convocati.includes(g.id));
|
||||
}
|
||||
|
||||
+18
-14
@@ -1,4 +1,4 @@
|
||||
import { giocatori, storicoMatch, type Giocatore } from "./crapp-data";
|
||||
import type { Giocatore, Match } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import type { MappaPresenze } from "./presenze";
|
||||
import { mediaSquadra, type VotoPagella } from "./pagelle";
|
||||
@@ -26,12 +26,12 @@ export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, page
|
||||
|
||||
const MESE = "2026-08";
|
||||
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi) {
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi, numGiocatori: number) {
|
||||
const delMese = ctx.eventi.filter(
|
||||
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||
);
|
||||
if (delMese.length === 0) return 0;
|
||||
const posti = delMese.length * giocatori.length;
|
||||
if (delMese.length === 0 || numGiocatori === 0) return 0;
|
||||
const posti = delMese.length * numGiocatori;
|
||||
const presenti = delMese.reduce((s, e) => {
|
||||
const risposte = ctx.presenze[e.id] ?? {};
|
||||
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
||||
@@ -39,10 +39,10 @@ function percentualePresenzeMese(ctx: ContestoObiettivi) {
|
||||
return Math.round((presenti / posti) * 100);
|
||||
}
|
||||
|
||||
function percentualeRisposte(ctx: ContestoObiettivi) {
|
||||
function percentualeRisposte(ctx: ContestoObiettivi, numGiocatori: number) {
|
||||
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
|
||||
if (daRispondere.length === 0) return 0;
|
||||
const posti = daRispondere.length * giocatori.length;
|
||||
if (daRispondere.length === 0 || numGiocatori === 0) return 0;
|
||||
const posti = daRispondere.length * numGiocatori;
|
||||
const risposte = daRispondere.reduce(
|
||||
(s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length,
|
||||
0,
|
||||
@@ -50,21 +50,21 @@ function percentualeRisposte(ctx: ContestoObiettivi) {
|
||||
return Math.round((risposte / posti) * 100);
|
||||
}
|
||||
|
||||
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
|
||||
|
||||
/** Obiettivi collaborativi: si muovono con il contributo di tutta la rosa. */
|
||||
export function obiettiviSquadra(
|
||||
rosa: Giocatore[] = giocatori,
|
||||
rosa: Giocatore[] = [],
|
||||
ctx: ContestoObiettivi = contestoVuoto,
|
||||
storicoMatch: Match[] = [],
|
||||
): ObiettivoSquadra[] {
|
||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
|
||||
return [
|
||||
{
|
||||
id: "o1",
|
||||
titolo: "90% di presenze ad agosto",
|
||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||
valore: percentualePresenzeMese(ctx),
|
||||
valore: percentualePresenzeMese(ctx, rosa.length),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-08-31",
|
||||
@@ -75,7 +75,7 @@ export function obiettiviSquadra(
|
||||
id: "o2",
|
||||
titolo: "Tutti rispondono alle convocazioni",
|
||||
descrizione: "Percentuale di risposte date sugli eventi in programma",
|
||||
valore: percentualeRisposte(ctx),
|
||||
valore: percentualeRisposte(ctx, rosa.length),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-09-30",
|
||||
@@ -169,8 +169,12 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
|
||||
return Math.min(100, Math.round((o.valore / o.target) * 100));
|
||||
}
|
||||
|
||||
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
|
||||
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
|
||||
export function obiettiviOrdinati(
|
||||
rosa?: Giocatore[],
|
||||
ctx?: ContestoObiettivi,
|
||||
storicoMatch?: Match[],
|
||||
) {
|
||||
return obiettiviSquadra(rosa, ctx, storicoMatch).sort((a, b) => {
|
||||
const pa = progressoObiettivo(a);
|
||||
const pb = progressoObiettivo(b);
|
||||
const ca = pa >= 100 ? 1 : 0;
|
||||
|
||||
+11
-12
@@ -1,4 +1,4 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
|
||||
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
||||
@@ -18,6 +18,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
export function completaTurni(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
giocatori: Giocatore[],
|
||||
): Record<string, string> {
|
||||
const risultato: Record<string, string> = { ...turni };
|
||||
const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0]));
|
||||
@@ -32,17 +33,15 @@ export function completaTurni(
|
||||
}
|
||||
if (assegnato) return;
|
||||
|
||||
const scelto = giocatori
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ca = conteggio.get(a.id) ?? 0;
|
||||
const cb = conteggio.get(b.id) ?? 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
const ua = ultimo.get(a.id) ?? -1;
|
||||
const ub = ultimo.get(b.id) ?? -1;
|
||||
if (ua !== ub) return ua - ub;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
})[0];
|
||||
const scelto = giocatori.slice().sort((a, b) => {
|
||||
const ca = conteggio.get(a.id) ?? 0;
|
||||
const cb = conteggio.get(b.id) ?? 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
const ua = ultimo.get(a.id) ?? -1;
|
||||
const ub = ultimo.get(b.id) ?? -1;
|
||||
if (ua !== ub) return ua - ub;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
})[0];
|
||||
|
||||
if (!scelto) return;
|
||||
risultato[evento.id] = scelto.id;
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRosaBase } from "./rosa-base";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
@@ -26,8 +27,9 @@ export function useTurniPalloni() {
|
||||
// Cambia raramente: una lettura per sessione è sufficiente.
|
||||
const query = useQuery({ queryKey: TURNI_KEY, queryFn: fetchTurni, staleTime: 30 * 60_000 });
|
||||
const { eventi } = useEventi();
|
||||
const giocatori = useRosaBase();
|
||||
const salvati = query.data ?? {};
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi) };
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi, giocatori) };
|
||||
}
|
||||
|
||||
export function useAssegnaTurno() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { giocatori } from "./crapp-data";
|
||||
import { useRosaBase } from "./rosa-base";
|
||||
|
||||
/**
|
||||
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
|
||||
@@ -46,6 +46,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
> {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const giocatori = useRosaBase();
|
||||
|
||||
return useMemo(() => {
|
||||
const oggi = new Date();
|
||||
@@ -60,8 +61,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
|
||||
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
|
||||
for (const e of rilevanti) {
|
||||
const ids =
|
||||
e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
|
||||
const ids = e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
|
||||
for (const id of ids) {
|
||||
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
|
||||
rec.totali += 1;
|
||||
@@ -73,5 +73,5 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
|
||||
}
|
||||
return out;
|
||||
}, [eventi, presenze]);
|
||||
}
|
||||
}, [eventi, presenze, giocatori]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { strapiFetch } from "./strapi-client";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
function inizialiDa(nome: string) {
|
||||
return nome
|
||||
.split(" ")
|
||||
.map((p) => p[0] ?? "")
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
type GiocatoreStrapi = {
|
||||
codice: string;
|
||||
nome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
nascita: string;
|
||||
};
|
||||
|
||||
/** Anagrafica base (senza statistiche, calcolate a parte da useRosa/giocatoriConScout). */
|
||||
function daEntry(e: GiocatoreStrapi): Giocatore {
|
||||
return {
|
||||
id: e.codice,
|
||||
nome: e.nome,
|
||||
numero: e.numero,
|
||||
ruolo: e.ruolo,
|
||||
nascita: e.nascita,
|
||||
iniziali: inizialiDa(e.nome),
|
||||
totaliEventi: 0,
|
||||
presenze: 0,
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
palloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
infortuni: 0,
|
||||
ritardi: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const ROSA_BASE_KEY = ["rosa-base"] as const;
|
||||
|
||||
/** Usabile anche fuori da React (route server, script): stessa fonte dati dell'hook. */
|
||||
export async function fetchRosaBase(): Promise<Giocatore[]> {
|
||||
const entries = await strapiFetch<GiocatoreStrapi>("giocatoris?sort=numero");
|
||||
return entries.map((e) => daEntry(e));
|
||||
}
|
||||
|
||||
/** Anagrafica rosa dal CMS: cambia a stagione, non a sessione — cache lunga, niente polling. */
|
||||
export function useRosaBaseQuery() {
|
||||
return useQuery({ queryKey: ROSA_BASE_KEY, queryFn: fetchRosaBase, staleTime: 60 * 60_000 });
|
||||
}
|
||||
|
||||
export function useRosaBase(): Giocatore[] {
|
||||
return useRosaBaseQuery().data ?? [];
|
||||
}
|
||||
+8
-6
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { useRosaBase } from "./rosa-base";
|
||||
import { giocatoriConScout, useScoutMatches } from "./scout-store";
|
||||
import { useVotiMvp, vincitoriMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
@@ -11,6 +12,7 @@ import { useGiocatoreBase } from "./user-store";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { obiettiviOrdinati } from "./obiettivi";
|
||||
import { useStoricoMatch } from "./storico-match";
|
||||
|
||||
/**
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
@@ -18,6 +20,7 @@ import { obiettiviOrdinati } from "./obiettivi";
|
||||
* nessuna query aggiuntiva rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
const base = useRosaBase();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const voti = useVotiMvp();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
@@ -31,7 +34,7 @@ export function useRosa(): Giocatore[] {
|
||||
const medie = mediePagelle(pagelle);
|
||||
const statCacche = statisticheCacche(cacche);
|
||||
const palloni = conteggioTurni(turni);
|
||||
return giocatoriConScout(scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
|
||||
return giocatoriConScout(base, scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
|
||||
...g,
|
||||
mediaVoto: medie[g.id]?.media ?? g.mediaVoto,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
@@ -40,7 +43,7 @@ export function useRosa(): Giocatore[] {
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
|
||||
}, [base, scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
|
||||
}
|
||||
|
||||
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||
@@ -57,7 +60,6 @@ export function useObiettivi() {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle });
|
||||
const storicoMatch = useStoricoMatch();
|
||||
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle }, storicoMatch);
|
||||
}
|
||||
|
||||
export { giocatori };
|
||||
|
||||
+10
-3
@@ -1,4 +1,4 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
|
||||
function riga(campi: Array<string | number>) {
|
||||
@@ -11,9 +11,16 @@ function riga(campi: Array<string | number>) {
|
||||
}
|
||||
|
||||
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
|
||||
export function csvScoutMatch(match: ScoutMatch): string {
|
||||
export function csvScoutMatch(giocatori: Giocatore[], match: ScoutMatch): string {
|
||||
const righe: string[] = [];
|
||||
righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"]));
|
||||
righe.push(
|
||||
riga([
|
||||
"Partita",
|
||||
match.casa ? "CRAP Volley" : match.avversario,
|
||||
"vs",
|
||||
match.casa ? match.avversario : "CRAP Volley",
|
||||
]),
|
||||
);
|
||||
righe.push(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
|
||||
+54
-20
@@ -1,24 +1,54 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { giocatori, classifica, type Giocatore, type RigaClassifica } from "./crapp-data";
|
||||
import type { Giocatore, RigaClassifica } from "./crapp-data";
|
||||
|
||||
export type AzioneTipo =
|
||||
| "attacco"
|
||||
| "ace"
|
||||
| "muro"
|
||||
| "errore"
|
||||
| "punto_avv"
|
||||
| "errore_avv";
|
||||
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
||||
|
||||
export const azioniMeta: Record<
|
||||
AzioneTipo,
|
||||
{ label: string; short: string; nostro: boolean; richiedeGiocatore: boolean; className: string }
|
||||
> = {
|
||||
attacco: { label: "Punto attacco", short: "Punto", nostro: true, richiedeGiocatore: true, className: "bg-accent text-accent-foreground" },
|
||||
ace: { label: "Ace", short: "Ace", nostro: true, richiedeGiocatore: true, className: "bg-success text-success-foreground" },
|
||||
muro: { label: "Muro", short: "Muro", nostro: true, richiedeGiocatore: true, className: "bg-info text-info-foreground" },
|
||||
errore: { label: "Errore nostro", short: "Errore", nostro: false, richiedeGiocatore: true, className: "bg-destructive text-destructive-foreground" },
|
||||
punto_avv: { label: "Punto avversario", short: "Punto avv.", nostro: false, richiedeGiocatore: false, className: "bg-muted text-muted-foreground" },
|
||||
errore_avv: { label: "Errore avversario", short: "Err. avv.", nostro: true, richiedeGiocatore: false, className: "bg-secondary text-foreground" },
|
||||
attacco: {
|
||||
label: "Punto attacco",
|
||||
short: "Punto",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-accent text-accent-foreground",
|
||||
},
|
||||
ace: {
|
||||
label: "Ace",
|
||||
short: "Ace",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-success text-success-foreground",
|
||||
},
|
||||
muro: {
|
||||
label: "Muro",
|
||||
short: "Muro",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-info text-info-foreground",
|
||||
},
|
||||
errore: {
|
||||
label: "Errore nostro",
|
||||
short: "Errore",
|
||||
nostro: false,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-destructive text-destructive-foreground",
|
||||
},
|
||||
punto_avv: {
|
||||
label: "Punto avversario",
|
||||
short: "Punto avv.",
|
||||
nostro: false,
|
||||
richiedeGiocatore: false,
|
||||
className: "bg-muted text-muted-foreground",
|
||||
},
|
||||
errore_avv: {
|
||||
label: "Errore avversario",
|
||||
short: "Err. avv.",
|
||||
nostro: true,
|
||||
richiedeGiocatore: false,
|
||||
className: "bg-secondary text-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export type Azione = {
|
||||
@@ -125,10 +155,11 @@ export function totaliSquadra(matches: ScoutMatch[]) {
|
||||
/** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva.
|
||||
* `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */
|
||||
export function giocatoriConScout(
|
||||
baseGiocatori: Giocatore[],
|
||||
matches: ScoutMatch[],
|
||||
mvpPerMatch: Record<string, string> = {},
|
||||
): Giocatore[] {
|
||||
return giocatori.map((g) => {
|
||||
return baseGiocatori.map((g) => {
|
||||
let presenze = 0;
|
||||
let mvp = 0;
|
||||
for (const m of matches) {
|
||||
@@ -144,9 +175,12 @@ export function giocatoriConScout(
|
||||
});
|
||||
}
|
||||
|
||||
/** Classifica demo aggiornata con i match scoutati (solo la nostra riga). */
|
||||
export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
|
||||
if (matches.length === 0) return classifica;
|
||||
/** Classifica CSI aggiornata con i match scoutati (solo la nostra riga). */
|
||||
export function classificaConScout(
|
||||
classificaBase: RigaClassifica[],
|
||||
matches: ScoutMatch[],
|
||||
): RigaClassifica[] {
|
||||
if (matches.length === 0) return classificaBase;
|
||||
const agg = matches.reduce(
|
||||
(s, m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
@@ -160,7 +194,7 @@ export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
|
||||
},
|
||||
{ giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 },
|
||||
);
|
||||
return classifica
|
||||
return classificaBase
|
||||
.map((r) =>
|
||||
r.squadra === "CRAP Volley"
|
||||
? {
|
||||
@@ -176,4 +210,4 @@ export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
|
||||
)
|
||||
.sort((a, b) => b.punti - a.punti || b.setFatti - b.setSubiti - (a.setFatti - a.setSubiti))
|
||||
.map((r, i) => ({ ...r, pos: i + 1 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { strapiFetch } from "./strapi-client";
|
||||
import type { Match } from "./crapp-data";
|
||||
|
||||
type MatchStoricoStrapi = {
|
||||
data: string;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
set_nostri: number;
|
||||
set_loro: number;
|
||||
parziali: Array<[number, number]> | null;
|
||||
mvp: string | null;
|
||||
};
|
||||
|
||||
function daEntry(e: MatchStoricoStrapi & { id: number }): Match {
|
||||
return {
|
||||
id: `m${e.id}`,
|
||||
data: e.data,
|
||||
avversario: e.avversario,
|
||||
casa: e.casa,
|
||||
setNostri: e.set_nostri,
|
||||
setLoro: e.set_loro,
|
||||
parziali: e.parziali ?? [],
|
||||
mvp: e.mvp ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export const STORICO_MATCH_KEY = ["storico-match"] as const;
|
||||
|
||||
async function fetchStoricoMatch(): Promise<Match[]> {
|
||||
const entries = await strapiFetch<MatchStoricoStrapi>("match-storicos?sort=data:desc");
|
||||
return entries.map(daEntry);
|
||||
}
|
||||
|
||||
/** Storico partite campionato CSI dal CMS: cambia a partita giocata, non a sessione. */
|
||||
export function useStoricoMatch(): Match[] {
|
||||
const query = useQuery({
|
||||
queryKey: STORICO_MATCH_KEY,
|
||||
queryFn: fetchStoricoMatch,
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Client di sola lettura per il CMS Strapi (rosa, classifica, storico partite — vedi
|
||||
// infra/strapi/). Niente SDK: l'API REST di Strapi è semplice query/JSON, un wrapper fetch basta.
|
||||
// I content type letti qui hanno il ruolo "Public" abilitato in lettura (find/findOne), stesso
|
||||
// pattern delle tabelle Supabase "open RLS" già usate nel progetto — nessun token da gestire
|
||||
// lato client per queste letture.
|
||||
|
||||
function strapiUrl(): string {
|
||||
const url = import.meta.env["VITE_STRAPI_URL"] || process.env["VITE_STRAPI_URL"];
|
||||
if (!url) throw new Error("Manca la variabile d'ambiente VITE_STRAPI_URL.");
|
||||
return url;
|
||||
}
|
||||
|
||||
type StrapiEntry<T> = T & { id: number };
|
||||
type StrapiListResponse<T> = { data: StrapiEntry<T>[] };
|
||||
|
||||
/** GET su un content type Strapi (path relativo, es. "giocatoris?sort=numero"). */
|
||||
export async function strapiFetch<T>(path: string): Promise<StrapiEntry<T>[]> {
|
||||
const res = await fetch(`${strapiUrl()}/api/${path}`);
|
||||
if (!res.ok) throw new Error(`Strapi ${path}: ${res.status}`);
|
||||
const json = (await res.json()) as StrapiListResponse<T>;
|
||||
return json.data;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { useRosaBase } from "./rosa-base";
|
||||
import { conInfortuni, useInfortuniERitardi } from "./infortuni";
|
||||
|
||||
const KEY = "crapp-user-v1";
|
||||
@@ -36,7 +37,7 @@ export function resetGiocatore() {
|
||||
write(null);
|
||||
}
|
||||
|
||||
/** Solo lettura locale: nessuna dipendenza da React Query (usabile fuori dal provider). */
|
||||
/** Richiede un ancestor QueryClientProvider (la rosa base arriva dal CMS Strapi). */
|
||||
export function useGiocatoreBase(): Giocatore | null {
|
||||
const id = useSyncExternalStore(
|
||||
(cb) => {
|
||||
@@ -46,7 +47,8 @@ export function useGiocatoreBase(): Giocatore | null {
|
||||
() => read(),
|
||||
() => null,
|
||||
);
|
||||
return id ? (giocatori.find((x) => x.id === id) ?? null) : null;
|
||||
const rosa = useRosaBase();
|
||||
return id ? (rosa.find((x) => x.id === id) ?? null) : null;
|
||||
}
|
||||
|
||||
export function useGiocatoreCorrente(): Giocatore | null {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/pub
|
||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||
import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio'
|
||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||
import { Route as ApiPublicScoutFinaleRouteImport } from './routes/api/public/scout-finale'
|
||||
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -96,6 +97,11 @@ const ApiPublicPushSubscribeRoute = ApiPublicPushSubscribeRouteImport.update({
|
||||
path: '/api/public/push-subscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicScoutFinaleRoute = ApiPublicScoutFinaleRouteImport.update({
|
||||
id: '/api/public/scout-finale',
|
||||
path: '/api/public/scout-finale',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicSollecitaPresenzeRoute =
|
||||
ApiPublicSollecitaPresenzeRouteImport.update({
|
||||
id: '/api/public/sollecita-presenze',
|
||||
@@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -135,6 +142,7 @@ export interface FileRoutesByTo {
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -153,6 +161,7 @@ export interface FileRoutesById {
|
||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||
'/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute
|
||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||
'/api/public/scout-finale': typeof ApiPublicScoutFinaleRoute
|
||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -172,6 +181,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/scout-finale'
|
||||
| '/api/public/sollecita-presenze'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -189,6 +199,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/scout-finale'
|
||||
| '/api/public/sollecita-presenze'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -206,6 +217,7 @@ export interface FileRouteTypes {
|
||||
| '/api/public/push-config'
|
||||
| '/api/public/push-messaggio'
|
||||
| '/api/public/push-subscribe'
|
||||
| '/api/public/scout-finale'
|
||||
| '/api/public/sollecita-presenze'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -224,6 +236,7 @@ export interface RootRouteChildren {
|
||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||
ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute
|
||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||
ApiPublicScoutFinaleRoute: typeof ApiPublicScoutFinaleRoute
|
||||
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
|
||||
}
|
||||
|
||||
@@ -327,6 +340,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicPushSubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/scout-finale': {
|
||||
id: '/api/public/scout-finale'
|
||||
path: '/api/public/scout-finale'
|
||||
fullPath: '/api/public/scout-finale'
|
||||
preLoaderRoute: typeof ApiPublicScoutFinaleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/public/sollecita-presenze': {
|
||||
id: '/api/public/sollecita-presenze'
|
||||
path: '/api/public/sollecita-presenze'
|
||||
@@ -352,6 +372,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||
ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute,
|
||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||
ApiPublicScoutFinaleRoute: ApiPublicScoutFinaleRoute,
|
||||
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
+37
-8
@@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TeamLogo } from "../components/crapp/ui-bits";
|
||||
import { useGiocatoreBase } from "../lib/user-store";
|
||||
import { useRosaBaseQuery } from "../lib/rosa-base";
|
||||
|
||||
function NotFoundComponent() {
|
||||
return (
|
||||
@@ -87,22 +88,36 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
|
||||
{ title: "CrAPP — L'app del CRAP Volley" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
content:
|
||||
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
},
|
||||
{ name: "author", content: "CRAP Volley" },
|
||||
{ name: "theme-color", content: "#111111" },
|
||||
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
content:
|
||||
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:site", content: "@Lovable" },
|
||||
{ name: "twitter:title", content: "CrAPP — L'app del CRAP Volley" },
|
||||
{ name: "twitter:description", content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile." },
|
||||
{ property: "og:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" },
|
||||
{ name: "twitter:image", content: "https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png" },
|
||||
{
|
||||
name: "twitter:description",
|
||||
content:
|
||||
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
},
|
||||
{
|
||||
property: "og:image",
|
||||
content:
|
||||
"https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png",
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
content:
|
||||
"https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/fd5ebd0b-6661-43fa-936c-35856d1068c4/id-preview-2942946e--8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c.lovable.app-1785491191791.png",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
@@ -142,18 +157,32 @@ function RootShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function RootComponent() {
|
||||
const { queryClient } = Route.useRouteContext();
|
||||
// La rosa base (nome/id giocatore) arriva dal CMS Strapi via TanStack Query: il provider deve
|
||||
// avvolgere anche RootBody, che la legge tramite useGiocatoreBase.
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RootBody />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootBody() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const giocatore = useGiocatoreBase();
|
||||
const rosaQuery = useRosaBaseQuery();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const isBenvenuto = location.pathname === "/benvenuto";
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Aspetta il primo caricamento della rosa base: prima che arrivi, "nessun giocatore
|
||||
// risolto" non significa "nessuna selezione salvata".
|
||||
if (rosaQuery.isPending) return;
|
||||
if (!giocatore && !isBenvenuto) {
|
||||
navigate({ to: "/benvenuto" });
|
||||
}
|
||||
}, [giocatore, isBenvenuto, navigate]);
|
||||
}, [giocatore, isBenvenuto, navigate, rosaQuery.isPending]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
@@ -164,7 +193,7 @@ function RootComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<div className="mx-auto min-h-screen max-w-md bg-background pb-24">
|
||||
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
|
||||
<Outlet />
|
||||
@@ -172,6 +201,6 @@ function RootComponent() {
|
||||
{!isBenvenuto && <BottomNav />}
|
||||
{!isBenvenuto && <CelebrazioneBadge />}
|
||||
<Toaster position="top-center" duration={3500} closeButton />
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowLeft, MapPin, Clock, Users, Dumbbell, CalendarDays } from "lucide-react";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData, giocatori } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
|
||||
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
|
||||
export const Route = createFileRoute("/allenamento/$id")({
|
||||
head: () => {
|
||||
@@ -13,9 +14,15 @@ export const Route = createFileRoute("/allenamento/$id")({
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${titolo} — CrAPP` },
|
||||
{ name: "description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." },
|
||||
{
|
||||
name: "description",
|
||||
content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||
{ property: "og:description", content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley." },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Dettaglio allenamento, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
],
|
||||
@@ -28,6 +35,7 @@ function AllenamentoDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
const giocatori = useRosaBase();
|
||||
const presentiVeri = giocatori.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
).length;
|
||||
@@ -84,7 +92,7 @@ function AllenamentoDetail() {
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocatiEvento(evento).length}
|
||||
Conferme: {presentiVeri}/{convocatiEvento(giocatori, evento).length}
|
||||
</div>
|
||||
|
||||
<TurnoPalloni eventoId={evento.id} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { completaTurni, eventiPalloni, eventoPrecedente, oggiISO } from "@/lib/palloni-core";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { fetchRosaBase } from "@/lib/rosa-base";
|
||||
|
||||
export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
server: {
|
||||
@@ -15,7 +16,8 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
const salvati: Record<string, string> = {};
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
const eventi = await leggiEventi();
|
||||
const turni = completaTurni(salvati, eventi);
|
||||
const giocatori = await fetchRosaBase();
|
||||
const turni = completaTurni(salvati, eventi, giocatori);
|
||||
|
||||
const oggi = oggiISO();
|
||||
const destinatari = new Set<string>();
|
||||
@@ -56,4 +58,4 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { formatData, giocatori } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import {
|
||||
completaTurni,
|
||||
eventiPalloni,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
oggiISO,
|
||||
} from "@/lib/palloni-core";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { fetchRosaBase } from "@/lib/rosa-base";
|
||||
|
||||
const schema = z.object({ endpoint: z.string().url().max(1000) });
|
||||
|
||||
@@ -40,7 +41,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
|
||||
.eq("endpoint", parsed.data.endpoint)
|
||||
.maybeSingle();
|
||||
|
||||
if (!iscrizione) return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
|
||||
if (!iscrizione)
|
||||
return Response.json({ title: "CrAPP", body: "Controlla il turno palloni." });
|
||||
|
||||
const { data: righe } = await supabaseAdmin
|
||||
.from("turni_palloni")
|
||||
@@ -48,7 +50,8 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
|
||||
const salvati: Record<string, string> = {};
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
const eventi = await leggiEventi();
|
||||
const turni = completaTurni(salvati, eventi);
|
||||
const giocatori = await fetchRosaBase();
|
||||
const turni = completaTurni(salvati, eventi, giocatori);
|
||||
|
||||
const oggi = oggiISO();
|
||||
const mioId = iscrizione.giocatore_id;
|
||||
@@ -76,9 +79,11 @@ export const Route = createFileRoute("/api/public/push-messaggio")({
|
||||
|
||||
return Response.json({
|
||||
title: "CrAPP · Turno palloni",
|
||||
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
|
||||
body: nome
|
||||
? `${nome}, controlla il turno palloni nel calendario.`
|
||||
: "Controlla il calendario.",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { formatData, giocatori } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
import { fetchRosaBase } from "@/lib/rosa-base";
|
||||
|
||||
const schema = z.object({
|
||||
eventoId: z.string().min(1).max(50),
|
||||
@@ -28,6 +29,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
.eq("evento_id", evento.id);
|
||||
|
||||
const stati = new Map((righe ?? []).map((r) => [r.giocatore_id, r.stato]));
|
||||
const giocatori = await fetchRosaBase();
|
||||
const destinatari = giocatori
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
export const Route = createFileRoute("/benvenuto")({
|
||||
@@ -25,6 +25,7 @@ export const Route = createFileRoute("/benvenuto")({
|
||||
function Benvenuto() {
|
||||
const navigate = useNavigate();
|
||||
const giocatore = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
|
||||
useEffect(() => {
|
||||
if (giocatore) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -97,12 +98,13 @@ function Calendario() {
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const { eventi } = useEventi();
|
||||
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||
|
||||
const compleanni = compleanniEventi(anno);
|
||||
const compleanni = compleanniEventi(giocatori, anno);
|
||||
const compleanniMese = compleanni.filter((c) => c.data.startsWith(mesePrefix));
|
||||
const eventiMese = eventi.filter((e) => e.data.startsWith(mesePrefix));
|
||||
|
||||
@@ -120,7 +122,9 @@ function Calendario() {
|
||||
setDrawerAperto(true);
|
||||
}
|
||||
|
||||
const eventiGiornoSelezionato = giornoSelezionato ? eventiPerGiorno.get(giornoSelezionato) ?? [] : [];
|
||||
const eventiGiornoSelezionato = giornoSelezionato
|
||||
? (eventiPerGiorno.get(giornoSelezionato) ?? [])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -200,7 +204,8 @@ function Calendario() {
|
||||
.join(", ")})`
|
||||
: undefined;
|
||||
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
|
||||
const isOggi = !!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
|
||||
const isOggi =
|
||||
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
|
||||
const Cella = haEventi ? "button" : "div";
|
||||
return (
|
||||
<Cella
|
||||
@@ -222,7 +227,9 @@ function Calendario() {
|
||||
aria-label={haEventi ? `Eventi del ${giorno}` : undefined}
|
||||
aria-current={isOggi ? "date" : undefined}
|
||||
>
|
||||
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">{giorno}</span>
|
||||
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">
|
||||
{giorno}
|
||||
</span>
|
||||
</Cella>
|
||||
);
|
||||
})}
|
||||
@@ -245,7 +252,7 @@ function Calendario() {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io.id) ? (
|
||||
{io && isAdmin(io) ? (
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { storicoMatch } from "@/lib/crapp-data";
|
||||
import { useClassificaCsi } from "@/lib/classifica-csi";
|
||||
import { useStoricoMatch } from "@/lib/storico-match";
|
||||
import { classificaConScout, useScoutMatches } from "@/lib/scout-store";
|
||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
|
||||
@@ -22,8 +23,10 @@ export const Route = createFileRoute("/classifica")({
|
||||
});
|
||||
|
||||
function Classifica() {
|
||||
const classificaBase = useClassificaCsi();
|
||||
const storicoMatch = useStoricoMatch();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const classifica = classificaConScout(scoutMatches);
|
||||
const classifica = classificaConScout(classificaBase, scoutMatches);
|
||||
const risultati = [
|
||||
...scoutMatches.map((m) => ({
|
||||
id: m.id,
|
||||
@@ -41,7 +44,7 @@ function Classifica() {
|
||||
<div className="px-5 pt-4">
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3.5 w-3.5 text-accent" />
|
||||
Dati CSI aggiornati oggi alle 08:40 (demo)
|
||||
Dati CSI gestiti da chi cura la classifica
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ScoutEntry variante="compatto" />
|
||||
@@ -101,4 +104,4 @@ function Classifica() {
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -4,7 +4,8 @@ import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData, giocatori, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
@@ -47,12 +48,13 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
||||
|
||||
function GestioneEventi() {
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const { eventi, isPending } = useEventi();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||
|
||||
if (!io || !isAdmin(io.id)) {
|
||||
if (!io || !isAdmin(io)) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
@@ -118,7 +120,9 @@ function GestioneEventi() {
|
||||
</div>
|
||||
|
||||
{bozza ? (
|
||||
<Section titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}>
|
||||
<Section
|
||||
titolo={eventi.some((e) => e.id === bozza.id) ? "Modifica evento" : "Nuovo evento"}
|
||||
>
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="grid grid-cols-4 gap-1 rounded-full bg-secondary p-1">
|
||||
{tipi.map((t) => (
|
||||
@@ -239,7 +243,9 @@ function GestioneEventi() {
|
||||
}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
scelto ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
|
||||
scelto
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{g.nome}
|
||||
@@ -276,7 +282,10 @@ function GestioneEventi() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eventi.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card">
|
||||
<div
|
||||
key={e.id}
|
||||
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
|
||||
+80
-68
@@ -7,7 +7,8 @@ import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
import { classifica, storicoMatch } from "@/lib/crapp-data";
|
||||
import { useClassificaCsi } from "@/lib/classifica-csi";
|
||||
import { useStoricoMatch } from "@/lib/storico-match";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import { useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||
@@ -24,7 +25,8 @@ export const Route = createFileRoute("/")({
|
||||
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
content:
|
||||
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -33,13 +35,15 @@ export const Route = createFileRoute("/")({
|
||||
|
||||
function Index() {
|
||||
const giocatore = useIo();
|
||||
const classifica = useClassificaCsi();
|
||||
const storicoMatch = useStoricoMatch();
|
||||
const { eventi } = useEventi();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
||||
const prossimo = prossimi[0] ?? null;
|
||||
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
|
||||
const noi = classifica.find((r) => r.squadra === "CRAP Volley")!;
|
||||
const ultima = storicoMatch[0]!;
|
||||
const noi = classifica.find((r) => r.squadra === "CRAP Volley") ?? null;
|
||||
const ultima = storicoMatch[0] ?? null;
|
||||
const obiettivi = useObiettivi();
|
||||
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
||||
|
||||
@@ -58,25 +62,27 @@ function Index() {
|
||||
<TeamLogo className="h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">{noi.pos}º</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">In classifica</p>
|
||||
{noi ? (
|
||||
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">{noi.pos}º</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">In classifica</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">
|
||||
{noi.vinte}-{noi.perse}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">Bilancio</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">Streak</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">
|
||||
{noi.vinte}-{noi.perse}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">Bilancio</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase text-primary-foreground/60">Streak</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Reveal>
|
||||
|
||||
<PromemoriaPalloni />
|
||||
@@ -114,66 +120,72 @@ function Index() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
titolo="Ultima partita"
|
||||
indice={3}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-xs font-semibold text-accent"
|
||||
>
|
||||
Storico <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ultima.casa ? "In casa" : "Trasferta"} · MVP {ultima.mvp}
|
||||
{ultima ? (
|
||||
<Section
|
||||
titolo="Ultima partita"
|
||||
indice={3}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-xs font-semibold text-accent"
|
||||
>
|
||||
Storico <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ultima.casa ? "In casa" : "Trasferta"} · MVP {ultima.mvp}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-display text-3xl leading-none text-accent">
|
||||
{ultima.setNostri}-{ultima.setLoro}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-display text-3xl leading-none text-accent">
|
||||
{ultima.setNostri}-{ultima.setLoro}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{ultima.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-[11px] font-semibold tabular-nums"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{ultima.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-[11px] font-semibold tabular-nums"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={4}
|
||||
azione={
|
||||
<Link to="/squadra" className="inline-flex items-center text-xs font-semibold text-accent">
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-xs font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target}{" "}
|
||||
{obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(obiettivo)}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
|
||||
+31
-10
@@ -2,13 +2,15 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import { useStoricoMatch } from "@/lib/storico-match";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
||||
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
|
||||
@@ -21,9 +23,15 @@ export const Route = createFileRoute("/partita/$id")({
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${titolo} — CrAPP` },
|
||||
{ name: "description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." },
|
||||
{
|
||||
name: "description",
|
||||
content: "Dettaglio partita, formazione e risultati del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||
{ property: "og:description", content: "Dettaglio partita, formazione e risultati del CRAP Volley." },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Dettaglio partita, formazione e risultati del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
],
|
||||
@@ -36,6 +44,8 @@ function PartitaDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const io = useGiocatoreCorrente();
|
||||
const giocatori = useRosaBase();
|
||||
const storicoMatch = useStoricoMatch();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
const presentiVeri = giocatori.filter(
|
||||
@@ -56,7 +66,7 @@ function PartitaDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
const convocati = convocatiEvento(evento);
|
||||
const convocati = convocatiEvento(giocatori, evento);
|
||||
const scout = scoutMatches.find((m) => m.id === evento.id || m.data === evento.data) ?? null;
|
||||
const match = scout
|
||||
? {
|
||||
@@ -71,7 +81,7 @@ function PartitaDetail() {
|
||||
: storicoMatch.find((m) => m.data === evento.data);
|
||||
const totaliTeam = scout ? totaliSquadra([scout]) : null;
|
||||
const avversario = evento.titolo.includes(" vs ")
|
||||
? evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo
|
||||
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
|
||||
: evento.titolo;
|
||||
const casa = evento.casa;
|
||||
const vinta = match && match.setNostri > match.setLoro;
|
||||
@@ -138,7 +148,9 @@ function PartitaDetail() {
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-lg font-display font-bold",
|
||||
vinta ? "bg-success text-success-foreground" : "bg-destructive text-destructive-foreground",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{match.setNostri} - {match.setLoro}
|
||||
@@ -147,7 +159,10 @@ function PartitaDetail() {
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{match.parziali.map(([noi, loro], i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
|
||||
>
|
||||
<span className="font-display text-lg">{noi}</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
|
||||
<span className="font-display text-lg">{loro}</span>
|
||||
@@ -169,7 +184,8 @@ function PartitaDetail() {
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il risultato.
|
||||
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
||||
risultato.
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
@@ -224,10 +240,15 @@ function PartitaDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{io && isAdmin(io.id) ? (
|
||||
{io && isAdmin(io) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))}
|
||||
onClick={() =>
|
||||
scaricaCsv(
|
||||
`scout-${scout.data}-${scout.avversario}.csv`,
|
||||
csvScoutMatch(giocatori, scout),
|
||||
)
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
|
||||
+36
-12
@@ -3,7 +3,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori, formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { useRosaBase } from "@/lib/rosa-base";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
totaliPerGiocatore,
|
||||
type Azione,
|
||||
type AzioneTipo,
|
||||
type ScoutMatch,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
export const Route = createFileRoute("/scout")({
|
||||
@@ -52,7 +54,17 @@ export const Route = createFileRoute("/scout")({
|
||||
|
||||
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
|
||||
|
||||
function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; titolo: string; testo: string; children?: React.ReactNode }) {
|
||||
function Blocco({
|
||||
icona,
|
||||
titolo,
|
||||
testo,
|
||||
children,
|
||||
}: {
|
||||
icona: React.ReactNode;
|
||||
titolo: string;
|
||||
testo: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-10">
|
||||
<div className="rounded-3xl bg-card p-6 text-center shadow-card">
|
||||
@@ -88,7 +100,7 @@ function Scout() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controllo, partita?.id, io?.id]);
|
||||
|
||||
if (io && !isAdmin(io.id)) {
|
||||
if (io && !isAdmin(io)) {
|
||||
return (
|
||||
<Blocco
|
||||
icona={<Lock className="h-6 w-6" />}
|
||||
@@ -99,7 +111,9 @@ function Scout() {
|
||||
}
|
||||
|
||||
if (!pronto || sessione.isLoading || statoSalvato.isLoading) {
|
||||
return <Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />;
|
||||
return (
|
||||
<Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!partita) {
|
||||
@@ -185,7 +199,10 @@ function ScoutBoard({
|
||||
const base =
|
||||
iniziale ??
|
||||
statoIniziale(
|
||||
partita.titolo.replace(/CRAP Volley/gi, "").replace(/\s*vs\s*/i, "").trim() || "Avversario",
|
||||
partita.titolo
|
||||
.replace(/CRAP Volley/gi, "")
|
||||
.replace(/\s*vs\s*/i, "")
|
||||
.trim() || "Avversario",
|
||||
partita.titolo.trim().toLowerCase().startsWith("crap"),
|
||||
);
|
||||
const [avversario, setAvversario] = useState(base.avversario);
|
||||
@@ -196,6 +213,7 @@ function ScoutBoard({
|
||||
const salva = useSalvaStatoScout();
|
||||
const cancella = useCancellaStatoScout();
|
||||
const { risposte } = usePresenzeEvento(partita.id);
|
||||
const giocatori = useRosaBase();
|
||||
const finito = useRef(false);
|
||||
|
||||
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
|
||||
@@ -204,7 +222,7 @@ function ScoutBoard({
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
);
|
||||
return presenti.length > 0 ? presenti : giocatori;
|
||||
}, [risposte]);
|
||||
}, [risposte, giocatori]);
|
||||
|
||||
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
|
||||
useEffect(() => {
|
||||
@@ -271,7 +289,7 @@ function ScoutBoard({
|
||||
return;
|
||||
}
|
||||
const vinti = parziali.filter(([n, l]) => n > l).length;
|
||||
salvaScoutMatch({
|
||||
const match: ScoutMatch = {
|
||||
id: `s${Date.now()}`,
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
avversario: avversario.trim() || "Avversario",
|
||||
@@ -281,7 +299,15 @@ function ScoutBoard({
|
||||
parziali,
|
||||
mvp: "",
|
||||
azioni,
|
||||
});
|
||||
};
|
||||
salvaScoutMatch(match);
|
||||
// Best-effort: se il device non ha rete o Strapi è giù, il match resta comunque salvato
|
||||
// in locale (fonte usata da useRosa/giocatoriConScout) esattamente come oggi.
|
||||
fetch("/api/public/scout-finale", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(match),
|
||||
}).catch((error) => console.error("scout-finale", error));
|
||||
toast.success("Partita salvata: ora la squadra può votare l'MVP");
|
||||
finito.current = true;
|
||||
void cancella.mutateAsync(partita.id);
|
||||
@@ -437,9 +463,7 @@ function ScoutBoard({
|
||||
i === 0 && "anim-riga",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{g ? `#${g.numero} ${g.nome}` : "Avversario"}
|
||||
</span>
|
||||
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
|
||||
@@ -503,4 +527,4 @@ function ScoutBoard({
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-18
@@ -4,7 +4,8 @@ import { Cake, ChevronDown, Crown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { formatData, storicoMatch } from "@/lib/crapp-data";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { useStoricoMatch } from "@/lib/storico-match";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import { useRosa, useObiettivi } from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
|
||||
@@ -43,7 +44,8 @@ export const Route = createFileRoute("/squadra")({
|
||||
{ title: "Squadra CRAP Volley — CrAPP" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.",
|
||||
content:
|
||||
"Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.",
|
||||
},
|
||||
{ property: "og:title", content: "Squadra CRAP Volley — CrAPP" },
|
||||
{
|
||||
@@ -55,7 +57,6 @@ export const Route = createFileRoute("/squadra")({
|
||||
component: Squadra,
|
||||
});
|
||||
|
||||
|
||||
const criteri = [
|
||||
{ id: "presenze", label: "Presenze" },
|
||||
{ id: "mediaVoto", label: "Media voto" },
|
||||
@@ -75,6 +76,7 @@ function valore(
|
||||
|
||||
function Squadra() {
|
||||
const rosa = useRosa();
|
||||
const storicoMatch = useStoricoMatch();
|
||||
const mese = usePresenzeUltimoMeseTutti();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const votiMvp = useVotiMvp();
|
||||
@@ -85,7 +87,9 @@ function Squadra() {
|
||||
const obiettivi = useObiettivi();
|
||||
const team = totaliSquadra(scoutMatches);
|
||||
const mediaPresenze = rosa.length
|
||||
? Math.round((rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100)
|
||||
? Math.round(
|
||||
(rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100,
|
||||
)
|
||||
: 0;
|
||||
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
|
||||
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
|
||||
@@ -116,7 +120,10 @@ function Squadra() {
|
||||
<div className="space-y-2">
|
||||
{rosa.map((g) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const sbloccati = [
|
||||
...stati.filter((b) => b.grado !== null),
|
||||
...badgeSegretiSbloccati(g),
|
||||
];
|
||||
const isOpen = aperto === g.id;
|
||||
return (
|
||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
@@ -126,7 +133,11 @@ function Squadra() {
|
||||
className="flex w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Avatar id={g.id} fallback={g.numero ? String(g.numero) : g.iniziali} className="h-11 w-11 text-lg" />
|
||||
<Avatar
|
||||
id={g.id}
|
||||
fallback={g.numero ? String(g.numero) : g.iniziali}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
@@ -160,19 +171,20 @@ function Squadra() {
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)} {g.nascita.slice(0, 4)}
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
|
||||
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
].map((s) => (
|
||||
{[
|
||||
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
|
||||
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
<p className="mt-1 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
@@ -201,9 +213,16 @@ function Squadra() {
|
||||
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` : ""}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className={cn("mt-1.5 text-[10px] font-bold uppercase", meta.text)}>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[10px] font-bold uppercase",
|
||||
meta.text,
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -411,7 +430,9 @@ function Squadra() {
|
||||
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;
|
||||
return raggiunto
|
||||
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
|
||||
: false;
|
||||
}).length;
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user