Initial independent version of CrAPP
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const KEY = "crapp-avatars-v1";
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: Record<string, string> | undefined;
|
||||
|
||||
function read(): Record<string, string> {
|
||||
if (cache) return cache;
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record<string, string>;
|
||||
} catch {
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function write(next: Record<string, string>) {
|
||||
cache = next;
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota o storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
const EMPTY: Record<string, string> = {};
|
||||
|
||||
export function useAvatars(): Record<string, string> {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
() => read(),
|
||||
() => EMPTY,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAvatar(id: string | undefined): string | null {
|
||||
const all = useAvatars();
|
||||
return id ? (all[id] ?? null) : null;
|
||||
}
|
||||
|
||||
export function rimuoviAvatar(id: string) {
|
||||
const next = { ...read() };
|
||||
delete next[id];
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function salvaAvatar(id: string, dataUrl: string) {
|
||||
write({ ...read(), [id]: dataUrl });
|
||||
}
|
||||
|
||||
/** Ridimensiona e comprime l'immagine scelta per stare in localStorage. */
|
||||
export function fileToAvatar(file: File, size = 256): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error("Lettura file fallita"));
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error("Immagine non valida"));
|
||||
img.onload = () => {
|
||||
const lato = Math.min(img.width, img.height);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("Canvas non disponibile"));
|
||||
ctx.drawImage(
|
||||
img,
|
||||
(img.width - lato) / 2,
|
||||
(img.height - lato) / 2,
|
||||
lato,
|
||||
lato,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
resolve(canvas.toDataURL("image/jpeg", 0.82));
|
||||
};
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
export type CategoriaSocial = {
|
||||
id: string;
|
||||
nome: string;
|
||||
descrizione: string;
|
||||
emoji: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
/** Categorie votate dai compagni a fine partita: un voto a testa per categoria. */
|
||||
export const categorieSocial: CategoriaSocial[] = [
|
||||
{
|
||||
id: "affidabile",
|
||||
nome: "Compagno affidabile",
|
||||
descrizione: "Sempre presente, sempre sul pezzo",
|
||||
emoji: "🛡️",
|
||||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
id: "spirito",
|
||||
nome: "Miglior spirito di squadra",
|
||||
descrizione: "Carica il gruppo dal primo all'ultimo punto",
|
||||
emoji: "📣",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: "fairplay",
|
||||
nome: "Fair play",
|
||||
descrizione: "Rispetto per compagni, avversari e arbitro",
|
||||
emoji: "🤝",
|
||||
icon: Scale,
|
||||
},
|
||||
{
|
||||
id: "meme",
|
||||
nome: "Meme della partita",
|
||||
descrizione: "La scena che rivedremo per tutta la stagione",
|
||||
emoji: "😂",
|
||||
icon: Laugh,
|
||||
},
|
||||
{
|
||||
id: "cuore",
|
||||
nome: "Cuore del gruppo",
|
||||
descrizione: "Chi tiene unita la squadra anche fuori dal campo",
|
||||
emoji: "❤️",
|
||||
icon: HandHeart,
|
||||
},
|
||||
];
|
||||
|
||||
export type VotoSocial = {
|
||||
match_id: string;
|
||||
categoria: string;
|
||||
votante_id: string;
|
||||
votato_id: string;
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
const CHIAVE = ["badge-social-voti"] as const;
|
||||
|
||||
/** Tutti i voti social (poche righe): nessun polling, cache lunga. */
|
||||
export function useVotiSocial() {
|
||||
return useQuery({
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoSocial[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("badge_social_voti")
|
||||
.select("match_id, categoria, votante_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoSocial[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVotaSocial() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoSocial) => {
|
||||
const { error } = await supabase
|
||||
.from("badge_social_voti")
|
||||
.upsert(voto, { onConflict: "match_id,categoria,votante_id" });
|
||||
if (error) throw error;
|
||||
return voto;
|
||||
},
|
||||
// Aggiornamento locale della cache: zero riletture.
|
||||
onSuccess: (voto) => {
|
||||
qc.setQueryData<VotoSocial[]>(CHIAVE, (prec) => {
|
||||
const altri = (prec ?? []).filter(
|
||||
(v) =>
|
||||
!(
|
||||
v.match_id === voto.match_id &&
|
||||
v.categoria === voto.categoria &&
|
||||
v.votante_id === voto.votante_id
|
||||
),
|
||||
);
|
||||
return [...altri, voto];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ConteggioSocial = { id: string; nome: string; voti: number };
|
||||
|
||||
export function conteggioCategoria(
|
||||
voti: VotoSocial[],
|
||||
matchId: string,
|
||||
categoria: string,
|
||||
): ConteggioSocial[] {
|
||||
const map = new Map<string, ConteggioSocial>();
|
||||
for (const v of voti) {
|
||||
if (v.match_id !== matchId || v.categoria !== categoria) continue;
|
||||
const cur = map.get(v.votato_id) ?? { id: v.votato_id, nome: v.votato_nome, voti: 0 };
|
||||
cur.voti += 1;
|
||||
map.set(v.votato_id, cur);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
||||
}
|
||||
|
||||
/** Vincitore di una categoria: serve un vantaggio netto, altrimenti parità. */
|
||||
export function vincitoreCategoria(
|
||||
voti: VotoSocial[],
|
||||
matchId: string,
|
||||
categoria: string,
|
||||
): ConteggioSocial | null {
|
||||
const c = conteggioCategoria(voti, matchId, categoria);
|
||||
if (c.length === 0) return null;
|
||||
if (c.length > 1 && c[1]!.voti === c[0]!.voti) return null;
|
||||
return c[0]!;
|
||||
}
|
||||
|
||||
export function mioVotoSocial(
|
||||
voti: VotoSocial[],
|
||||
matchId: string,
|
||||
categoria: string,
|
||||
votanteId: string,
|
||||
) {
|
||||
return (
|
||||
voti.find(
|
||||
(v) =>
|
||||
v.match_id === matchId && v.categoria === categoria && v.votante_id === votanteId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Badge social vinti da un giocatore in tutte le partite: categoria -> numero. */
|
||||
export function badgeSocialVinti(voti: VotoSocial[], giocatoreId: string) {
|
||||
const matchIds = [...new Set(voti.map((v) => v.match_id))];
|
||||
const out: Record<string, number> = {};
|
||||
for (const m of matchIds) {
|
||||
for (const cat of categorieSocial) {
|
||||
const v = vincitoreCategoria(voti, m, cat.id);
|
||||
if (v?.id === giocatoreId) out[cat.id] = (out[cat.id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Trophy,
|
||||
Repeat,
|
||||
Lock,
|
||||
Ghost,
|
||||
Zap,
|
||||
Rocket,
|
||||
Anchor,
|
||||
Stethoscope,
|
||||
AlarmClock,
|
||||
ClipboardCheck,
|
||||
CircleDot,
|
||||
Toilet,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
export type Grado = "bronzo" | "argento" | "oro";
|
||||
|
||||
export const gradiOrdine: Grado[] = ["bronzo", "argento", "oro"];
|
||||
|
||||
export const gradoMeta: Record<
|
||||
Grado,
|
||||
{ label: string; text: string; bg: string; ring: string }
|
||||
> = {
|
||||
bronzo: { label: "Bronzo", text: "text-bronzo", bg: "bg-bronzo/15", ring: "ring-bronzo/40" },
|
||||
argento: { label: "Argento", text: "text-argento", bg: "bg-argento/20", ring: "ring-argento/50" },
|
||||
oro: { label: "Oro", text: "text-oro", bg: "bg-oro/20", ring: "ring-oro/50" },
|
||||
};
|
||||
|
||||
export type BadgeDef = {
|
||||
id: string;
|
||||
nome: string;
|
||||
descrizione: string;
|
||||
unita: string;
|
||||
icon: LucideIcon;
|
||||
soglie: Record<Grado, number>;
|
||||
valore: (g: Giocatore) => number;
|
||||
/** Badge segreto: nome e requisiti restano nascosti finché non si sblocca. */
|
||||
segreto?: boolean;
|
||||
/** Frase celebrativa mostrata allo sblocco. */
|
||||
celebrazione?: string;
|
||||
/** Emoji usata nella celebrazione e nella notifica push. */
|
||||
emoji?: string;
|
||||
/** Testo della notifica push allo sblocco. */
|
||||
notificaPush?: string;
|
||||
};
|
||||
|
||||
export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "mvp",
|
||||
nome: "MVP",
|
||||
descrizione: "Riconoscimento per il miglior giocatore della partita, scelto dai compagni a fine match.",
|
||||
unita: "MVP",
|
||||
icon: Trophy,
|
||||
soglie: { bronzo: 1, argento: 3, oro: 5 },
|
||||
valore: (g) => g.mvp,
|
||||
},
|
||||
{
|
||||
id: "pagella",
|
||||
nome: "Pagellone",
|
||||
descrizione: "Media dei voti che i compagni ti danno a fine partita: conta come giochi, non quanti punti fai.",
|
||||
unita: "di media voto",
|
||||
icon: ClipboardCheck,
|
||||
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
|
||||
valore: (g) => g.mediaVoto,
|
||||
},
|
||||
{
|
||||
id: "palloni",
|
||||
nome: "Sherpa dei palloni",
|
||||
descrizione: "Quante volte ti sei caricato la sacca dei palloni: lavoro oscuro, badge luminoso.",
|
||||
unita: "turni palloni",
|
||||
icon: CircleDot,
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
valore: (g) => g.palloni,
|
||||
},
|
||||
{
|
||||
id: "presenze",
|
||||
nome: "Presenza fissa",
|
||||
descrizione: "Per chi è sempre lì, partita dopo partita, senza tirarsi indietro.",
|
||||
unita: "presenze",
|
||||
icon: Repeat,
|
||||
soglie: { bronzo: 5, argento: 15, oro: 30 },
|
||||
valore: (g) => g.presenze,
|
||||
},
|
||||
{
|
||||
id: "serie-allenamenti",
|
||||
nome: "Sempre in palestra",
|
||||
descrizione: "Allenamenti consecutivi a cui sei stato presente: la costanza paga più del talento.",
|
||||
unita: "allenamenti di fila",
|
||||
icon: Rocket,
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
valore: (g) => g.serieAllenamenti,
|
||||
},
|
||||
{
|
||||
id: "serie-conferme",
|
||||
nome: "Risposta lampo",
|
||||
descrizione: "Conferme rapide agli eventi: la squadra sa subito che ci sei.",
|
||||
unita: "conferme entro 24h",
|
||||
icon: Zap,
|
||||
soglie: { bronzo: 3, argento: 8, oro: 15 },
|
||||
valore: (g) => g.serieConferme,
|
||||
},
|
||||
];
|
||||
|
||||
/** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */
|
||||
export const badgeSegreti: BadgeDef[] = [
|
||||
{
|
||||
id: "s-tiebreak",
|
||||
nome: "Uomo tie-break",
|
||||
descrizione: "Sbloccato da chi ha almeno 2 MVP e una media voto alta: nei momenti caldi ci sei sempre.",
|
||||
unita: "MVP con media alta",
|
||||
icon: Ghost,
|
||||
segreto: true,
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
|
||||
celebrazione: "Nei momenti caldi ci sei sempre.",
|
||||
},
|
||||
{
|
||||
id: "s-mai-forfait",
|
||||
nome: "Mai un forfait",
|
||||
descrizione: "Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
|
||||
unita: "requisito nascosto",
|
||||
icon: Anchor,
|
||||
segreto: true,
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.serieConferme >= 10 && g.presenze >= 15 ? 1 : 0),
|
||||
celebrazione: "Su di te la squadra può contare a occhi chiusi.",
|
||||
},
|
||||
{
|
||||
id: "s-infermeria",
|
||||
nome: "Cliente VIP dell'Infermeria",
|
||||
descrizione:
|
||||
"Hai saltato almeno 3 allenamenti o partite per infortunio. L'infermeria ormai ti aspetta con il caffè pronto ☕😂",
|
||||
unita: "eventi da infortunato",
|
||||
icon: Stethoscope,
|
||||
segreto: true,
|
||||
emoji: "🩺",
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.infortuni >= 3 ? 1 : 0),
|
||||
celebrazione: "L'infermeria ormai ti aspetta con il caffè pronto ☕😂",
|
||||
notificaPush: "Speriamo che sia l'ultimo! 😅",
|
||||
},
|
||||
{
|
||||
id: "s-ritardi",
|
||||
nome: "Aspettate, arrivo!",
|
||||
descrizione:
|
||||
"Hai collezionato almeno 5 ritardi. Il tuo messaggio preferito è: 'Aspettate, arrivo!' 😅",
|
||||
unita: "eventi in ritardo",
|
||||
icon: AlarmClock,
|
||||
segreto: true,
|
||||
emoji: "⏰",
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.ritardi >= 5 ? 1 : 0),
|
||||
celebrazione:
|
||||
"Hai collezionato almeno 5 ritardi. Il tuo messaggio preferito è: 'Aspettate, arrivo!' 😅",
|
||||
notificaPush: "Forse è il momento di puntare la sveglia 10 minuti prima! 😄",
|
||||
},
|
||||
{
|
||||
id: "s-cacche",
|
||||
nome: "Trono di ferro",
|
||||
descrizione:
|
||||
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
|
||||
unita: "partite da record",
|
||||
icon: Toilet,
|
||||
segreto: true,
|
||||
emoji: "🚽",
|
||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||
valore: (g) => (g.cacche >= 3 ? 1 : 0),
|
||||
celebrazione: "Il bagno del PalaCRAP porta ufficialmente il tuo nome 🚽😂",
|
||||
notificaPush: "Scarico completo: badge segreto sbloccato! 😄",
|
||||
},
|
||||
];
|
||||
|
||||
export const tuttiBadge: BadgeDef[] = [...badgeDefs, ...badgeSegreti];
|
||||
|
||||
export const iconaSegreto = Lock;
|
||||
|
||||
export type BadgeStato = {
|
||||
def: BadgeDef;
|
||||
valore: number;
|
||||
grado: Grado | null;
|
||||
prossimo: Grado | null;
|
||||
prossimaSoglia: number | null;
|
||||
progresso: number;
|
||||
};
|
||||
|
||||
export function gradoRaggiunto(def: BadgeDef, valore: number): Grado | null {
|
||||
let grado: Grado | null = null;
|
||||
for (const g of gradiOrdine) if (valore >= def.soglie[g]) grado = g;
|
||||
return grado;
|
||||
}
|
||||
|
||||
export function statoBadge(def: BadgeDef, g: Giocatore): BadgeStato {
|
||||
const valore = def.valore(g);
|
||||
const grado = gradoRaggiunto(def, valore);
|
||||
const prossimo = gradiOrdine.find((x) => valore < def.soglie[x]) ?? null;
|
||||
const prossimaSoglia = prossimo ? def.soglie[prossimo] : null;
|
||||
const progresso = prossimaSoglia
|
||||
? Math.min(100, Math.round((valore / prossimaSoglia) * 100))
|
||||
: 100;
|
||||
return { def, valore, grado, prossimo, prossimaSoglia, progresso };
|
||||
}
|
||||
|
||||
export function badgeGiocatore(g: Giocatore): BadgeStato[] {
|
||||
return badgeDefs.map((def) => statoBadge(def, g));
|
||||
}
|
||||
|
||||
/** Badge segreti già sbloccati dal giocatore. */
|
||||
export function badgeSegretiSbloccati(g: Giocatore): BadgeStato[] {
|
||||
return badgeSegreti.map((def) => statoBadge(def, g)).filter((b) => b.grado !== null);
|
||||
}
|
||||
|
||||
/** Quanti segreti restano da scoprire. */
|
||||
export function segretiNascosti(g: Giocatore) {
|
||||
return badgeSegreti.length - badgeSegretiSbloccati(g).length;
|
||||
}
|
||||
|
||||
export type Collezione = {
|
||||
sbloccati: BadgeStato[];
|
||||
inProgresso: BadgeStato[];
|
||||
segreti: BadgeStato[];
|
||||
nascosti: number;
|
||||
totali: number;
|
||||
ottenuti: number;
|
||||
};
|
||||
|
||||
export function collezioneBadge(g: Giocatore): Collezione {
|
||||
const normali = badgeGiocatore(g);
|
||||
const segreti = badgeSegretiSbloccati(g);
|
||||
const sbloccati = normali.filter((b) => b.grado !== null);
|
||||
return {
|
||||
sbloccati,
|
||||
inProgresso: normali.filter((b) => b.grado === null),
|
||||
segreti,
|
||||
nascosti: badgeSegreti.length - segreti.length,
|
||||
totali: tuttiBadge.length,
|
||||
ottenuti: sbloccati.length + segreti.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Il traguardo più vicino: il badge a cui manca meno, in proporzione. */
|
||||
export function prossimoTraguardo(g: Giocatore): BadgeStato | null {
|
||||
const candidati = badgeGiocatore(g).filter((b) => b.prossimaSoglia !== null);
|
||||
if (candidati.length === 0) return null;
|
||||
return candidati.sort((a, b) => b.progresso - a.progresso)[0]!;
|
||||
}
|
||||
|
||||
/** Riga "Ti mancano X per il prossimo livello". */
|
||||
export function mancanoPer(b: BadgeStato) {
|
||||
if (!b.prossimaSoglia || !b.prossimo) return "Livello massimo raggiunto";
|
||||
const manca = b.prossimaSoglia - b.valore;
|
||||
return `Ti mancano ${manca} ${b.def.unita} per il ${gradoMeta[b.prossimo].label.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Microcopy motivazionale in base a quanto sei vicino. */
|
||||
export function microcopyBadge(b: BadgeStato) {
|
||||
if (!b.prossimaSoglia) return "Hai fatto tutto: badge d'oro in bacheca.";
|
||||
if (b.progresso >= 90) return "Ci sei quasi: un ultimo sforzo!";
|
||||
if (b.progresso >= 60) return "Sei in piena corsa, continua così.";
|
||||
if (b.progresso >= 25) return "Buon ritmo, il prossimo livello si avvicina.";
|
||||
return "Ogni partita conta: si parte da qui.";
|
||||
}
|
||||
|
||||
export function badgeSbloccati(g: Giocatore): BadgeStato[] {
|
||||
return badgeGiocatore(g).filter((b) => b.grado !== null);
|
||||
}
|
||||
|
||||
export function descrizioneSoglie(def: BadgeDef) {
|
||||
return `${def.soglie.bronzo}/${def.soglie.argento}/${def.soglie.oro} ${def.unita}`;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
/** Sondaggio goliardico pre-partita: quante cacche prima del match di campionato. */
|
||||
export type RigaCacche = {
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
quantita: number;
|
||||
};
|
||||
|
||||
export const CACCHE_KEY = ["cacche"] as const;
|
||||
|
||||
export function useCacche() {
|
||||
const query = useQuery({
|
||||
queryKey: CACCHE_KEY,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<RigaCacche[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("cacche_partita")
|
||||
.select("evento_id, giocatore_id, quantita");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as RigaCacche[];
|
||||
},
|
||||
});
|
||||
return { ...query, righe: query.data ?? [] };
|
||||
}
|
||||
|
||||
export function useSalvaCacche() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (riga: RigaCacche) => {
|
||||
const { error } = await supabase
|
||||
.from("cacche_partita")
|
||||
.upsert(riga, { onConflict: "evento_id,giocatore_id" });
|
||||
if (error) throw error;
|
||||
return riga;
|
||||
},
|
||||
onSuccess: (riga) => {
|
||||
qc.setQueryData<RigaCacche[]>(CACCHE_KEY, (prec) => {
|
||||
const altre = (prec ?? []).filter(
|
||||
(r) => !(r.evento_id === riga.evento_id && r.giocatore_id === riga.giocatore_id),
|
||||
);
|
||||
return [...altre, riga];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type StatCacche = {
|
||||
totale: number;
|
||||
giornate: number;
|
||||
media: number;
|
||||
record: number;
|
||||
/** Giornate con almeno 3 cacche: soglia del badge segreto. */
|
||||
giornateTop: number;
|
||||
};
|
||||
|
||||
function arrotonda(n: number) {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
/** Statistiche per giocatore: giocatoreId -> conteggi. */
|
||||
export function statisticheCacche(righe: RigaCacche[]): Record<string, StatCacche> {
|
||||
const out: Record<string, StatCacche> = {};
|
||||
for (const r of righe) {
|
||||
const cur = out[r.giocatore_id] ?? { totale: 0, giornate: 0, media: 0, record: 0, giornateTop: 0 };
|
||||
cur.totale += r.quantita;
|
||||
cur.giornate += 1;
|
||||
cur.record = Math.max(cur.record, r.quantita);
|
||||
if (r.quantita >= 3) cur.giornateTop += 1;
|
||||
out[r.giocatore_id] = cur;
|
||||
}
|
||||
for (const s of Object.values(out)) s.media = arrotonda(s.totale / s.giornate);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Media di squadra di una singola partita. */
|
||||
export function mediaPartita(righe: RigaCacche[], eventoId: string) {
|
||||
const dellaPartita = righe.filter((r) => r.evento_id === eventoId);
|
||||
if (dellaPartita.length === 0) return 0;
|
||||
return arrotonda(dellaPartita.reduce((s, r) => s + r.quantita, 0) / dellaPartita.length);
|
||||
}
|
||||
|
||||
/** Media di squadra su tutte le partite censite. */
|
||||
export function mediaStagione(righe: RigaCacche[]) {
|
||||
if (righe.length === 0) return 0;
|
||||
return arrotonda(righe.reduce((s, r) => s + r.quantita, 0) / righe.length);
|
||||
}
|
||||
|
||||
/** Record assoluto della stagione: chi e quante. */
|
||||
export function recordStagione(righe: RigaCacche[]): RigaCacche | null {
|
||||
return righe.reduce<RigaCacche | null>(
|
||||
(best, r) => (!best || r.quantita > best.quantita ? r : best),
|
||||
null,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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" },
|
||||
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" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Statistiche personali: solo dati equi per tutti i ruoli.
|
||||
* Punti, ace e muri restano nello Scout Live come dato tecnico di squadra.
|
||||
*/
|
||||
export type Giocatore = {
|
||||
id: string;
|
||||
nome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
nascita: string;
|
||||
presenze: number;
|
||||
totaliEventi: number;
|
||||
streak: number;
|
||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||
serieAllenamenti: number;
|
||||
seriePartite: number;
|
||||
serieConferme: number;
|
||||
/** MVP eletti dalla squadra. */
|
||||
mvp: number;
|
||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||
mediaVoto: number;
|
||||
/** Quante volte ha portato i palloni. */
|
||||
palloni: number;
|
||||
/** Partite di campionato con almeno 3 cacche dichiarate. */
|
||||
cacche: number;
|
||||
/** Media di cacche dichiarate per partita. */
|
||||
cacchePartita: number;
|
||||
/** Eventi (allenamenti o partite) saltati per infortunio. */
|
||||
infortuni: number;
|
||||
/** Eventi (allenamenti o partite) a cui sei arrivato in ritardo. */
|
||||
ritardi: number;
|
||||
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;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
setNostri: number;
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
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;
|
||||
giocate: number;
|
||||
vinte: number;
|
||||
perse: number;
|
||||
setFatti: number;
|
||||
setSubiti: number;
|
||||
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" });
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Captures the original Error out-of-band so server.ts can recover the stack
|
||||
// when h3 has already swallowed the throw into a generic 500 Response.
|
||||
|
||||
let lastCapturedError: { error: unknown; at: number } | undefined;
|
||||
const TTL_MS = 5_000;
|
||||
|
||||
function record(error: unknown) {
|
||||
lastCapturedError = { error, at: Date.now() };
|
||||
}
|
||||
|
||||
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
|
||||
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
|
||||
// the failure detail stripped. Expand Error-like args into a string that keeps the
|
||||
// message, stack, and the full cause chain.
|
||||
const CAUSE_DEPTH_LIMIT = 5;
|
||||
const DESCRIPTION_LENGTH_LIMIT = 8_000;
|
||||
|
||||
export function describeError(error: unknown): string {
|
||||
const parts: string[] = [];
|
||||
let current: unknown = error;
|
||||
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
|
||||
if (!(current instanceof Error)) {
|
||||
parts.push(typeof current === "string" ? current : safeStringify(current));
|
||||
break;
|
||||
}
|
||||
const label = depth === 0 ? "" : "caused by: ";
|
||||
const status = describeStatus(current);
|
||||
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
|
||||
current = current.cause;
|
||||
}
|
||||
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
|
||||
}
|
||||
|
||||
function describeStatus(error: Error): string {
|
||||
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
|
||||
const value = status ?? statusCode;
|
||||
return typeof value === "number" ? ` (status ${value})` : "";
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorLike(value: unknown): value is Error {
|
||||
return value instanceof Error;
|
||||
}
|
||||
|
||||
// Wrap console.error so errors logged by any layer — including h3's internal
|
||||
// unhandled-error logging, which this file cannot hook directly — are both
|
||||
// recorded for consumeLastCapturedError and expanded before serialization.
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
console.error = (...args: unknown[]) => {
|
||||
const expanded = args.map((arg) => {
|
||||
if (!isErrorLike(arg)) return arg;
|
||||
record(arg);
|
||||
return describeError(arg);
|
||||
});
|
||||
originalConsoleError(...expanded);
|
||||
};
|
||||
|
||||
if (typeof globalThis.addEventListener === "function") {
|
||||
globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
|
||||
globalThis.addEventListener("unhandledrejection", (event) =>
|
||||
record((event as PromiseRejectionEvent).reason),
|
||||
);
|
||||
}
|
||||
|
||||
export function consumeLastCapturedError(): unknown {
|
||||
if (!lastCapturedError) return undefined;
|
||||
if (Date.now() - lastCapturedError.at > TTL_MS) {
|
||||
lastCapturedError = undefined;
|
||||
return undefined;
|
||||
}
|
||||
const { error } = lastCapturedError;
|
||||
lastCapturedError = undefined;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function renderErrorPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>This page didn't load</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
|
||||
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
||||
p { color: #4b5563; margin: 0 0 1.5rem; }
|
||||
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
||||
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
|
||||
.primary { background: #111; color: #fff; }
|
||||
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>This page didn't load</h1>
|
||||
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="location.reload()">Try again</button>
|
||||
<a class="secondary" href="/">Go home</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
|
||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||
export async function leggiEventi(): Promise<Evento[]> {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data } = await supabaseAdmin.from("eventi_app").select(COLONNE).order("data");
|
||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { giocatori } from "./crapp-data";
|
||||
|
||||
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
||||
|
||||
export type Evento = {
|
||||
id: string;
|
||||
tipo: EventoTipo;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string;
|
||||
/** Giocatori convocati (vuoto = tutta la rosa). */
|
||||
convocati: string[];
|
||||
/** Solo per le partite: vale per il campionato CSI. */
|
||||
campionato: boolean;
|
||||
/** Solo per le partite: si gioca in casa (false = trasferta). */
|
||||
casa: boolean;
|
||||
/** Le pagelle di questa partita non accettano più voti. */
|
||||
pagelleChiuse: boolean;
|
||||
};
|
||||
|
||||
export type RigaEvento = {
|
||||
id: string;
|
||||
tipo: string;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string | null;
|
||||
convocati: string[] | null;
|
||||
campionato: boolean;
|
||||
casa: boolean | null;
|
||||
pagelle_chiuse: boolean;
|
||||
};
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRiga(r: RigaEvento): Evento {
|
||||
return {
|
||||
id: r.id,
|
||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
||||
titolo: r.titolo,
|
||||
luogo: r.luogo ?? "",
|
||||
data: r.data,
|
||||
ora: r.ora ?? "",
|
||||
note: r.note ?? "",
|
||||
convocati: r.convocati ?? [],
|
||||
campionato: !!r.campionato,
|
||||
casa: r.casa ?? true,
|
||||
pagelleChiuse: !!r.pagelle_chiuse,
|
||||
};
|
||||
}
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
|
||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||
|
||||
export function categoriaEvento(e: Pick<Evento, "tipo" | "campionato">): CategoriaEvento {
|
||||
if (e.tipo === "partita") return e.campionato ? "partita" : "amichevole";
|
||||
if (e.tipo === "allenamento") return "allenamento";
|
||||
return "evento";
|
||||
}
|
||||
|
||||
export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campionato"> {
|
||||
if (c === "partita") return { tipo: "partita", campionato: true };
|
||||
if (c === "amichevole") return { tipo: "partita", campionato: false };
|
||||
return { tipo: c, campionato: false };
|
||||
}
|
||||
|
||||
export const EVENTI_KEY = ["eventi"] as const;
|
||||
|
||||
async function fetchEventi(): Promise<Evento[]> {
|
||||
const { data, error } = await supabase.from("eventi_app").select(COLONNE).order("data");
|
||||
if (error) throw error;
|
||||
return ((data ?? []) as RigaEvento[]).map(daRiga);
|
||||
}
|
||||
|
||||
/** Una lettura per sessione: il calendario cambia raramente. */
|
||||
export function useEventi() {
|
||||
const query = useQuery({ queryKey: EVENTI_KEY, queryFn: fetchEventi, staleTime: 10 * 60_000 });
|
||||
return { ...query, eventi: query.data ?? [] };
|
||||
}
|
||||
|
||||
export function useEvento(id: string) {
|
||||
const { eventi, ...resto } = useEventi();
|
||||
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
||||
}
|
||||
|
||||
export function nuovoIdEvento() {
|
||||
return `e${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export function eventoVuoto(): Evento {
|
||||
return {
|
||||
id: nuovoIdEvento(),
|
||||
tipo: "allenamento",
|
||||
titolo: "",
|
||||
luogo: "Palestra Comunale",
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
ora: "20:30",
|
||||
note: "",
|
||||
convocati: [],
|
||||
campionato: false,
|
||||
casa: true,
|
||||
pagelleChiuse: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSalvaEvento() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (evento: Evento) => {
|
||||
const { error } = await supabase.from("eventi_app").upsert(
|
||||
{
|
||||
id: evento.id,
|
||||
tipo: evento.tipo,
|
||||
titolo: evento.titolo,
|
||||
luogo: evento.luogo,
|
||||
data: evento.data,
|
||||
ora: evento.ora,
|
||||
note: evento.note,
|
||||
convocati: evento.convocati,
|
||||
campionato: evento.campionato,
|
||||
casa: evento.casa,
|
||||
pagelle_chiuse: evento.pagelleChiuse,
|
||||
},
|
||||
{ onConflict: "id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return evento;
|
||||
},
|
||||
// Aggiornamento locale della cache: nessuna rilettura dal database.
|
||||
onSuccess: (evento) => {
|
||||
qc.setQueryData<Evento[]>(EVENTI_KEY, (prec) => {
|
||||
const altri = (prec ?? []).filter((e) => e.id !== evento.id);
|
||||
return [...altri, evento].sort((a, b) => a.data.localeCompare(b.data));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEliminaEvento() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase.from("eventi_app").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
return id;
|
||||
},
|
||||
onSuccess: (id) => {
|
||||
qc.setQueryData<Evento[]>(EVENTI_KEY, (prec) => (prec ?? []).filter((e) => e.id !== id));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
|
||||
export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
|
||||
return giocatori
|
||||
.map((g) => {
|
||||
const md = g.nascita.slice(5);
|
||||
const eta = anno - Number(g.nascita.slice(0, 4));
|
||||
return {
|
||||
id: `c-${g.id}`,
|
||||
tipo: "compleanno" as const,
|
||||
titolo: `Compleanno di ${g.nome}`,
|
||||
luogo: `Compie ${eta} anni`,
|
||||
data: `${anno}-${md}`,
|
||||
ora: "00:00",
|
||||
note: "",
|
||||
convocati: [],
|
||||
campionato: false,
|
||||
casa: true,
|
||||
pagelleChiuse: false,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.data.localeCompare(b.data));
|
||||
}
|
||||
|
||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||
export function convocatiEvento(evento: Evento | null) {
|
||||
if (!evento || evento.convocati.length === 0) return giocatori;
|
||||
return giocatori.filter((g) => evento.convocati.includes(g.id));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { useRispostePresenze, type MappaPresenze } from "./presenze";
|
||||
|
||||
/** giocatoreId -> numero di eventi (allenamenti + partite) con stato "infortunato". */
|
||||
export type ContoInfortuni = Record<string, number>;
|
||||
|
||||
function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
|
||||
const out: ContoInfortuni = {};
|
||||
for (const eventoId of Object.keys(presenze)) {
|
||||
const evento = presenze[eventoId] ?? {};
|
||||
for (const giocatoreId of Object.keys(evento)) {
|
||||
if (evento[giocatoreId] === stato) out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Conta gli infortuni dalla mappa presenze già in cache: ogni evento vale una volta sola. */
|
||||
export function contaInfortuni(presenze: MappaPresenze): ContoInfortuni {
|
||||
return contaStato(presenze, "infortunato");
|
||||
}
|
||||
|
||||
/** Conta i ritardi dalla stessa mappa presenze: ogni evento vale una volta sola. */
|
||||
export function contaRitardi(presenze: MappaPresenze): ContoInfortuni {
|
||||
return contaStato(presenze, "ritardo");
|
||||
}
|
||||
|
||||
export function conInfortuni<T extends Giocatore>(
|
||||
g: T,
|
||||
conto: ContoInfortuni,
|
||||
contoRitardi: ContoInfortuni = {},
|
||||
): T {
|
||||
return {
|
||||
...g,
|
||||
infortuni: conto[g.id] ?? g.infortuni ?? 0,
|
||||
ritardi: contoRitardi[g.id] ?? g.ritardi ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
|
||||
export function useInfortuni(): ContoInfortuni {
|
||||
const { presenze } = useRispostePresenze();
|
||||
return useMemo(() => contaInfortuni(presenze), [presenze]);
|
||||
}
|
||||
|
||||
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
|
||||
export function useRitardi(): ContoInfortuni {
|
||||
const { presenze } = useRispostePresenze();
|
||||
return useMemo(() => contaRitardi(presenze), [presenze]);
|
||||
}
|
||||
|
||||
/** Un solo hook per entrambi i conteggi: evita hook extra nei componenti. */
|
||||
export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: ContoInfortuni } {
|
||||
const { presenze } = useRispostePresenze();
|
||||
return useMemo(
|
||||
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
|
||||
[presenze],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
type LovableErrorOptions = {
|
||||
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
||||
handled?: boolean;
|
||||
severity?: "error" | "warning" | "info";
|
||||
};
|
||||
|
||||
type LovableEvents = {
|
||||
captureException?: (
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
options?: LovableErrorOptions,
|
||||
) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lovableEvents?: LovableEvents;
|
||||
__lovableReportRuntimeError?: (payload: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
filename?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.__lovableEvents?.captureException?.(
|
||||
error,
|
||||
{
|
||||
source: "react_error_boundary",
|
||||
route: window.location.pathname,
|
||||
...context,
|
||||
},
|
||||
{
|
||||
mechanism: "react_error_boundary",
|
||||
handled: false,
|
||||
severity: "error",
|
||||
},
|
||||
);
|
||||
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
||||
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
||||
// which is present only inside the editor preview.
|
||||
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
||||
// opaque "[object Response]", so pull out the status and URL instead.
|
||||
const message =
|
||||
error instanceof Response
|
||||
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
window.__lovableReportRuntimeError?.({
|
||||
message,
|
||||
...(stack !== undefined && { stack }),
|
||||
filename: window.location.pathname,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Sistema di micro-animazioni: solo API standard del browser (nessuna
|
||||
* dipendenza da Lovable). Funziona in qualsiasi progetto React + Vite.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/** True se l'utente ha chiesto meno movimento o il device è poco performante. */
|
||||
export function useMotoRidotto() {
|
||||
const [ridotto, setRidotto] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const memoria = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
|
||||
const debole = typeof memoria === "number" && memoria > 0 && memoria < 2;
|
||||
const applica = () => setRidotto(mq.matches || debole);
|
||||
applica();
|
||||
mq.addEventListener("change", applica);
|
||||
return () => mq.removeEventListener("change", applica);
|
||||
}, []);
|
||||
|
||||
return ridotto;
|
||||
}
|
||||
|
||||
function easeOut(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anima un numero da 0 (o dal valore precedente) fino a `valore`.
|
||||
* Usa requestAnimationFrame: mai bloccante, zero chiamate di rete.
|
||||
*/
|
||||
export function useConteggio(valore: number, durata = 700) {
|
||||
const ridotto = useMotoRidotto();
|
||||
const [corrente, setCorrente] = useState(valore);
|
||||
const daRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (ridotto) {
|
||||
setCorrente(valore);
|
||||
daRef.current = valore;
|
||||
return;
|
||||
}
|
||||
const da = daRef.current;
|
||||
if (da === valore) return;
|
||||
const inizio = performance.now();
|
||||
let raf = 0;
|
||||
const step = (ora: number) => {
|
||||
const t = Math.min(1, (ora - inizio) / durata);
|
||||
setCorrente(Math.round(da + (valore - da) * easeOut(t)));
|
||||
if (t < 1) raf = requestAnimationFrame(step);
|
||||
else daRef.current = valore;
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [valore, durata, ridotto]);
|
||||
|
||||
return corrente;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restituisce la larghezza da applicare a una barra: parte da 0 al mount e
|
||||
* raggiunge il target al frame successivo, lasciando animare la transizione CSS.
|
||||
*/
|
||||
export function useRiempimento(percentuale: number) {
|
||||
const ridotto = useMotoRidotto();
|
||||
const [larghezza, setLarghezza] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (ridotto) {
|
||||
setLarghezza(percentuale);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => setLarghezza(percentuale));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [percentuale, ridotto]);
|
||||
|
||||
return larghezza;
|
||||
}
|
||||
|
||||
/** Coriandoli leggeri, caricati solo al momento del bisogno. */
|
||||
export async function coriandoli(ridotto = false) {
|
||||
if (ridotto || typeof window === "undefined") return;
|
||||
const { default: confetti } = await import("canvas-confetti");
|
||||
confetti({
|
||||
particleCount: 70,
|
||||
spread: 65,
|
||||
startVelocity: 32,
|
||||
ticks: 120,
|
||||
scalar: 0.9,
|
||||
origin: { y: 0.7 },
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
export type VotoMvp = {
|
||||
match_id: string;
|
||||
votante_id: string;
|
||||
votato_id: string;
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
const CHIAVE = ["mvp-voti"] as const;
|
||||
|
||||
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
|
||||
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
||||
export function useVotiMvp() {
|
||||
return useQuery({
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoMvp[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.select("match_id, votante_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoMvp[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useVotaMvp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoMvp) => {
|
||||
const { error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
||||
if (error) throw error;
|
||||
return voto;
|
||||
},
|
||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||
onSuccess: (voto) => {
|
||||
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => {
|
||||
const altri = (prec ?? []).filter(
|
||||
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id),
|
||||
);
|
||||
return [...altri, voto];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type ConteggioMvp = { id: string; nome: string; voti: number };
|
||||
|
||||
/** Conteggio voti di una partita, dal più votato. */
|
||||
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
||||
const map = new Map<string, ConteggioMvp>();
|
||||
for (const v of voti) {
|
||||
if (v.match_id !== matchId) continue;
|
||||
const cur = map.get(v.votato_id) ?? { id: v.votato_id, nome: v.votato_nome, voti: 0 };
|
||||
cur.voti += 1;
|
||||
map.set(v.votato_id, cur);
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
||||
}
|
||||
|
||||
/** Vincitore per ogni partita votata: matchId -> nome MVP. */
|
||||
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
||||
const perMatch = new Map<string, VotoMvp[]>();
|
||||
for (const v of voti) {
|
||||
const arr = perMatch.get(v.match_id) ?? [];
|
||||
arr.push(v);
|
||||
perMatch.set(v.match_id, arr);
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const [matchId] of perMatch) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
|
||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
||||
out[matchId] = top[0]!.nome;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
||||
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import {
|
||||
microcopyObiettivo,
|
||||
progressoObiettivo,
|
||||
type ObiettivoSquadra,
|
||||
} from "./obiettivi";
|
||||
import {
|
||||
badgeGiocatore,
|
||||
badgeSegretiSbloccati,
|
||||
gradoMeta,
|
||||
prossimoTraguardo,
|
||||
} from "./badges";
|
||||
import { serieGiocatore } from "./serie";
|
||||
import { badgeSocialVinti, categorieSocial, type VotoSocial } from "./badge-social";
|
||||
|
||||
export type TonoNotifica = "badge" | "segreto" | "serie" | "squadra" | "social";
|
||||
|
||||
export type NotificaSmart = {
|
||||
id: string;
|
||||
tono: TonoNotifica;
|
||||
emoji: string;
|
||||
titolo: string;
|
||||
testo: string;
|
||||
};
|
||||
|
||||
/** Genera SOLO notifiche che hanno un valore reale: niente rumore. */
|
||||
export function calcolaNotifiche(
|
||||
g: Giocatore,
|
||||
votiSocial: VotoSocial[] = [],
|
||||
obiettivi: ObiettivoSquadra[] = [],
|
||||
): NotificaSmart[] {
|
||||
const out: NotificaSmart[] = [];
|
||||
|
||||
// 1. Badge appena sbloccati
|
||||
for (const b of badgeGiocatore(g)) {
|
||||
if (!b.grado) continue;
|
||||
out.push({
|
||||
id: `badge:${b.def.id}:${b.grado}`,
|
||||
tono: "badge",
|
||||
emoji: "🏅",
|
||||
titolo: `${b.def.nome} ${gradoMeta[b.grado].label}`,
|
||||
testo: `${b.valore} ${b.def.unita}: badge sbloccato, complimenti!`,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Badge segreti
|
||||
for (const b of badgeSegretiSbloccati(g)) {
|
||||
out.push({
|
||||
id: `segreto:${b.def.id}`,
|
||||
tono: "segreto",
|
||||
emoji: b.def.emoji ?? "🔓",
|
||||
titolo: `Badge segreto: ${b.def.nome}`,
|
||||
testo: b.def.notificaPush ?? b.def.celebrazione ?? "Hai scoperto un badge nascosto!",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. A un passo dal completamento
|
||||
const vicino = prossimoTraguardo(g);
|
||||
if (vicino && vicino.prossimaSoglia && vicino.prossimaSoglia - vicino.valore <= 2) {
|
||||
out.push({
|
||||
id: `quasi:${vicino.def.id}:${vicino.prossimaSoglia}`,
|
||||
tono: "badge",
|
||||
emoji: "🔥",
|
||||
titolo: "Sei a un passo",
|
||||
testo: `${vicino.prossimaSoglia - vicino.valore} ${vicino.def.unita} e sblocchi ${vicino.def.nome}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Serie importanti
|
||||
for (const s of serieGiocatore(g)) {
|
||||
if (s.def.traguardi.includes(s.valore)) {
|
||||
out.push({
|
||||
id: `serie:${s.def.tipo}:${s.valore}`,
|
||||
tono: "serie",
|
||||
emoji: "⚡",
|
||||
titolo: `Serie di ${s.valore} ${s.def.label.toLowerCase()}`,
|
||||
testo: "Continuità da veterano: non fermarti ora.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Obiettivo di squadra quasi completato
|
||||
for (const o of obiettivi) {
|
||||
const pct = progressoObiettivo(o);
|
||||
if (pct >= 90 && pct < 100) {
|
||||
out.push({
|
||||
id: `obiettivo:${o.id}:90`,
|
||||
tono: "squadra",
|
||||
emoji: o.emoji,
|
||||
titolo: `${o.titolo}: ${pct}%`,
|
||||
testo: microcopyObiettivo(o),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Esito dei voti social
|
||||
const vinti = badgeSocialVinti(votiSocial, g.id);
|
||||
for (const cat of categorieSocial) {
|
||||
const n = vinti[cat.id];
|
||||
if (!n) continue;
|
||||
out.push({
|
||||
id: `social:${cat.id}:${n}`,
|
||||
tono: "social",
|
||||
emoji: cat.emoji,
|
||||
titolo: `${cat.nome} x${n}`,
|
||||
testo: "I tuoi compagni hanno votato per te.",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEY = "crapp-notifiche-viste-v1";
|
||||
|
||||
function viste(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
return JSON.parse(window.localStorage.getItem(KEY) ?? "[]") as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function segnaViste(ids: string[]) {
|
||||
try {
|
||||
const set = new Set([...viste(), ...ids]);
|
||||
window.localStorage.setItem(KEY, JSON.stringify([...set].slice(-200)));
|
||||
} catch {
|
||||
/* storage non disponibile */
|
||||
}
|
||||
}
|
||||
|
||||
async function notificaSistema(n: NotificaSmart) {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return;
|
||||
if (Notification.permission !== "granted") return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker?.getRegistration();
|
||||
await reg?.showNotification(`${n.emoji} ${n.titolo}`, {
|
||||
body: n.testo,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
tag: n.id,
|
||||
});
|
||||
} catch {
|
||||
/* notifica non disponibile */
|
||||
}
|
||||
}
|
||||
|
||||
/** Coda di notifiche mai mostrate prima, una alla volta. */
|
||||
export function useNotificheSmart(g: Giocatore | null, votiSocial: VotoSocial[] = []) {
|
||||
const [coda, setCoda] = useState<NotificaSmart[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!g) return;
|
||||
const gia = new Set(viste());
|
||||
const nuove = calcolaNotifiche(g, votiSocial).filter((n) => !gia.has(n.id));
|
||||
if (nuove.length === 0) return;
|
||||
setCoda(nuove);
|
||||
segnaViste(nuove.map((n) => n.id));
|
||||
void notificaSistema(nuove[0]!);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [g?.id, votiSocial.length]);
|
||||
|
||||
const chiudi = useCallback(() => setCoda((c) => c.slice(1)), []);
|
||||
|
||||
return { notifica: coda[0] ?? null, restanti: Math.max(0, coda.length - 1), chiudi };
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { giocatori, storicoMatch, type Giocatore } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import type { MappaPresenze } from "./presenze";
|
||||
import { mediaSquadra, type VotoPagella } from "./pagelle";
|
||||
|
||||
export type ObiettivoSquadra = {
|
||||
id: string;
|
||||
titolo: string;
|
||||
descrizione: string;
|
||||
valore: number;
|
||||
target: number;
|
||||
unita: string;
|
||||
scadenza?: string;
|
||||
emoji: string;
|
||||
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
|
||||
impatto: string;
|
||||
};
|
||||
|
||||
export type ContestoObiettivi = {
|
||||
eventi: Evento[];
|
||||
presenze: MappaPresenze;
|
||||
pagelle: VotoPagella[];
|
||||
};
|
||||
|
||||
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
|
||||
|
||||
const MESE = "2026-08";
|
||||
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi) {
|
||||
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;
|
||||
const presenti = delMese.reduce((s, e) => {
|
||||
const risposte = ctx.presenze[e.id] ?? {};
|
||||
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
||||
}, 0);
|
||||
return Math.round((presenti / posti) * 100);
|
||||
}
|
||||
|
||||
function percentualeRisposte(ctx: ContestoObiettivi) {
|
||||
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
|
||||
if (daRispondere.length === 0) return 0;
|
||||
const posti = daRispondere.length * giocatori.length;
|
||||
const risposte = daRispondere.reduce(
|
||||
(s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length,
|
||||
0,
|
||||
);
|
||||
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,
|
||||
ctx: ContestoObiettivi = contestoVuoto,
|
||||
): ObiettivoSquadra[] {
|
||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||
return [
|
||||
{
|
||||
id: "o1",
|
||||
titolo: "90% di presenze ad agosto",
|
||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||
valore: percentualePresenzeMese(ctx),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-08-31",
|
||||
emoji: "📣",
|
||||
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
||||
},
|
||||
{
|
||||
id: "o2",
|
||||
titolo: "Tutti rispondono alle convocazioni",
|
||||
descrizione: "Percentuale di risposte date sugli eventi in programma",
|
||||
valore: percentualeRisposte(ctx),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-09-30",
|
||||
emoji: "⚡",
|
||||
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
||||
},
|
||||
{
|
||||
id: "o7",
|
||||
titolo: "250 presenze complessive",
|
||||
descrizione: "Somma delle presenze di tutta la rosa in stagione",
|
||||
valore: somma((g) => g.presenze),
|
||||
target: 250,
|
||||
unita: "presenze",
|
||||
emoji: "🤝",
|
||||
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
|
||||
},
|
||||
{
|
||||
id: "o12",
|
||||
titolo: "Media pagelle da 7.5",
|
||||
descrizione: "Media di tutti i voti che ci diamo dopo le partite",
|
||||
valore: mediaSquadra(ctx.pagelle),
|
||||
target: 7.5,
|
||||
unita: "di media",
|
||||
emoji: "📝",
|
||||
impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.",
|
||||
},
|
||||
{
|
||||
id: "o13",
|
||||
titolo: "200 pagelle compilate",
|
||||
descrizione: "Quanti voti la squadra ha dato nel corso della stagione",
|
||||
valore: ctx.pagelle.length,
|
||||
target: 200,
|
||||
unita: "voti",
|
||||
emoji: "🗳️",
|
||||
impatto: "Vota i compagni a fine partita: bastano due minuti.",
|
||||
},
|
||||
{
|
||||
id: "o11",
|
||||
titolo: "Continuità di squadra",
|
||||
descrizione: "Giocatori con almeno 3 allenamenti consecutivi",
|
||||
valore: continui,
|
||||
target: 12,
|
||||
unita: "giocatori",
|
||||
emoji: "🔗",
|
||||
impatto: "Tieni viva la tua serie e sblocchi anche questo.",
|
||||
},
|
||||
{
|
||||
id: "o3",
|
||||
titolo: "Prima vittoria del campionato",
|
||||
descrizione: "Sbloccare la stagione con i primi 3 punti",
|
||||
valore: Math.min(vittorie, 1),
|
||||
target: 1,
|
||||
unita: "vittorie",
|
||||
emoji: "🎉",
|
||||
impatto: "Una prestazione di gruppo e il primo passo è fatto.",
|
||||
},
|
||||
{
|
||||
id: "o4",
|
||||
titolo: "5 vittorie in campionato",
|
||||
descrizione: "Metà strada verso la zona playoff",
|
||||
valore: Math.min(vittorie, 5),
|
||||
target: 5,
|
||||
unita: "vittorie",
|
||||
emoji: "🔥",
|
||||
impatto: "Ogni vittoria ci avvicina ai playoff.",
|
||||
},
|
||||
{
|
||||
id: "o5",
|
||||
titolo: "10 vittorie in campionato",
|
||||
descrizione: "Obiettivo stagionale per il podio",
|
||||
valore: vittorie,
|
||||
target: 10,
|
||||
unita: "vittorie",
|
||||
emoji: "🏆",
|
||||
impatto: "L'obiettivo grande: serve tutta la stagione insieme.",
|
||||
},
|
||||
{
|
||||
id: "o6",
|
||||
titolo: "1 evento di squadra al mese",
|
||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
|
||||
target: 1,
|
||||
unita: "eventi",
|
||||
emoji: "🍕",
|
||||
impatto: "Il gruppo si costruisce anche fuori dal campo.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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) => {
|
||||
const pa = progressoObiettivo(a);
|
||||
const pb = progressoObiettivo(b);
|
||||
const ca = pa >= 100 ? 1 : 0;
|
||||
const cb = pb >= 100 ? 1 : 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
return pb - pa;
|
||||
});
|
||||
}
|
||||
|
||||
/** Microcopy motivazionale per un obiettivo di squadra. */
|
||||
export function microcopyObiettivo(o: ObiettivoSquadra) {
|
||||
const pct = progressoObiettivo(o);
|
||||
const manca = Math.max(0, Math.round((o.target - o.valore) * 10) / 10);
|
||||
if (pct >= 100) return "Obiettivo centrato: grande squadra!";
|
||||
if (pct >= 90) return `Ci siamo quasi: mancano ${manca} ${o.unita}.`;
|
||||
if (pct >= 50) return `Oltre metà strada: ancora ${manca} ${o.unita}.`;
|
||||
if (pct > 0) return `Si parte: ${manca} ${o.unita} al traguardo.`;
|
||||
return "Tocca a noi far partire questo obiettivo.";
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
||||
export type VotoPagella = {
|
||||
match_id: string;
|
||||
votante_id: string;
|
||||
votato_id: string;
|
||||
voto: number;
|
||||
};
|
||||
|
||||
export const PAGELLE_KEY = ["pagelle"] as const;
|
||||
|
||||
/** Poche righe per stagione: una lettura per sessione basta. */
|
||||
export function usePagelle() {
|
||||
const query = useQuery({
|
||||
queryKey: PAGELLE_KEY,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoPagella[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("pagelle_voti")
|
||||
.select("match_id, votante_id, votato_id, voto");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoPagella[];
|
||||
},
|
||||
});
|
||||
return { ...query, voti: query.data ?? [] };
|
||||
}
|
||||
|
||||
export function useVotaPagella() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoPagella) => {
|
||||
const { error } = await supabase
|
||||
.from("pagelle_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id,votato_id" });
|
||||
if (error) throw error;
|
||||
return voto;
|
||||
},
|
||||
// Cache aggiornata localmente: nessuna rilettura.
|
||||
onSuccess: (voto) => {
|
||||
qc.setQueryData<VotoPagella[]>(PAGELLE_KEY, (prec) => {
|
||||
const altri = (prec ?? []).filter(
|
||||
(v) =>
|
||||
!(
|
||||
v.match_id === voto.match_id &&
|
||||
v.votante_id === voto.votante_id &&
|
||||
v.votato_id === voto.votato_id
|
||||
),
|
||||
);
|
||||
return [...altri, voto];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type MediaPagella = { media: number; voti: number };
|
||||
|
||||
function arrotonda(n: number) {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
|
||||
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
|
||||
const somma: Record<string, { tot: number; n: number }> = {};
|
||||
for (const v of voti) {
|
||||
const cur = somma[v.votato_id] ?? { tot: 0, n: 0 };
|
||||
cur.tot += v.voto;
|
||||
cur.n += 1;
|
||||
somma[v.votato_id] = cur;
|
||||
}
|
||||
const out: Record<string, MediaPagella> = {};
|
||||
for (const [id, s] of Object.entries(somma)) {
|
||||
out[id] = { media: arrotonda(s.tot / s.n), voti: s.n };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Media della singola partita, giocatore per giocatore (voti anonimi). */
|
||||
export function pagellePartita(voti: VotoPagella[], matchId: string): Record<string, MediaPagella> {
|
||||
return mediePagelle(voti.filter((v) => v.match_id === matchId));
|
||||
}
|
||||
|
||||
/** I voti che ho già dato in questa partita: votatoId -> voto. */
|
||||
export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string) {
|
||||
const out: Record<string, number> = {};
|
||||
for (const v of voti) {
|
||||
if (v.match_id === matchId && v.votante_id === votanteId) out[v.votato_id] = v.voto;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Media pagelle di tutta la squadra su tutte le partite. */
|
||||
export function mediaSquadra(voti: VotoPagella[]) {
|
||||
if (voti.length === 0) return 0;
|
||||
return arrotonda(voti.reduce((s, v) => s + v.voto, 0) / voti.length);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
|
||||
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
||||
|
||||
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
|
||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo !== "compleanno")
|
||||
.slice()
|
||||
.sort((a, b) => a.data.localeCompare(b.data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Completa i turni mancanti proponendo, a rotazione, chi ha portato i palloni
|
||||
* meno volte (a parità, chi non lo fa da più tempo).
|
||||
*/
|
||||
export function completaTurni(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
): Record<string, string> {
|
||||
const risultato: Record<string, string> = { ...turni };
|
||||
const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0]));
|
||||
const ultimo = new Map<string, number>(giocatori.map((g) => [g.id, -1]));
|
||||
|
||||
eventiPalloni(eventi).forEach((evento, indice) => {
|
||||
const assegnato = risultato[evento.id];
|
||||
if (assegnato && conteggio.has(assegnato)) {
|
||||
conteggio.set(assegnato, (conteggio.get(assegnato) ?? 0) + 1);
|
||||
ultimo.set(assegnato, indice);
|
||||
return;
|
||||
}
|
||||
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];
|
||||
|
||||
if (!scelto) return;
|
||||
risultato[evento.id] = scelto.id;
|
||||
conteggio.set(scelto.id, (conteggio.get(scelto.id) ?? 0) + 1);
|
||||
ultimo.set(scelto.id, indice);
|
||||
});
|
||||
|
||||
return risultato;
|
||||
}
|
||||
|
||||
/** Quante volte ciascun giocatore è incaricato dei palloni. */
|
||||
export function conteggioTurni(turni: Record<string, string>): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const id of Object.values(turni)) out[id] = (out[id] ?? 0) + 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
|
||||
return eventiPalloni(eventi).filter((e) => e.data === isoData);
|
||||
}
|
||||
|
||||
/** Evento immediatamente precedente: chi era incaricato lì deve riportare i palloni. */
|
||||
export function eventoPrecedente(eventi: Evento[], eventoId: string): Evento | undefined {
|
||||
const lista = eventiPalloni(eventi);
|
||||
const i = lista.findIndex((e) => e.id === eventoId);
|
||||
return i > 0 ? lista[i - 1] : undefined;
|
||||
}
|
||||
|
||||
export function eventoSuccessivo(eventi: Evento[], eventoId: string): Evento | undefined {
|
||||
const lista = eventiPalloni(eventi);
|
||||
const i = lista.findIndex((e) => e.id === eventoId);
|
||||
return i >= 0 ? lista[i + 1] : undefined;
|
||||
}
|
||||
|
||||
export function oggiISO(): string {
|
||||
const d = new Date();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
type RigaTurno = {
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
aggiornato_da: string | null;
|
||||
};
|
||||
|
||||
async function fetchTurni(): Promise<Record<string, string>> {
|
||||
const { data, error } = await supabase
|
||||
.from("turni_palloni")
|
||||
.select("evento_id, giocatore_id, aggiornato_da");
|
||||
if (error) throw error;
|
||||
const mappa: Record<string, string> = {};
|
||||
for (const riga of (data ?? []) as RigaTurno[]) mappa[riga.evento_id] = riga.giocatore_id;
|
||||
return mappa;
|
||||
}
|
||||
|
||||
/** Turni salvati + proposta automatica a rotazione per gli eventi non ancora assegnati. */
|
||||
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 salvati = query.data ?? {};
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi) };
|
||||
}
|
||||
|
||||
export function useAssegnaTurno() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => {
|
||||
const { error } = await supabase.from("turni_palloni").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
aggiornato_da: input.da,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
// Scrittura unica + aggiornamento cache locale, senza rilettura.
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<Record<string, string>>(TURNI_KEY, (prec) => ({
|
||||
...(prec ?? {}),
|
||||
[input.eventoId]: input.giocatoreId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { giocatori } from "./crapp-data";
|
||||
|
||||
/**
|
||||
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
|
||||
* Usa solo cache già in memoria: nessuna query aggiuntiva.
|
||||
*/
|
||||
export function usePresenzeUltimoMese(giocatoreId: string | undefined) {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!giocatoreId) return { presenti: 0, totali: 0, percentuale: 0 };
|
||||
const oggi = new Date();
|
||||
const inizio = new Date(oggi);
|
||||
inizio.setDate(inizio.getDate() - 30);
|
||||
const da = inizio.toISOString().slice(0, 10);
|
||||
const a = oggi.toISOString().slice(0, 10);
|
||||
|
||||
const rilevanti = eventi.filter(
|
||||
(e) =>
|
||||
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
||||
e.data >= da &&
|
||||
e.data <= a &&
|
||||
(e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
|
||||
);
|
||||
const presenti = rilevanti.filter((e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
}).length;
|
||||
const totali = rilevanti.length;
|
||||
return {
|
||||
presenti,
|
||||
totali,
|
||||
percentuale: totali ? Math.round((presenti / totali) * 100) : 0,
|
||||
};
|
||||
}, [eventi, presenze, giocatoreId]);
|
||||
}
|
||||
|
||||
/** Percentuale presenze ultimi 30 giorni per tutti i giocatori (mappa per id). */
|
||||
export function usePresenzeUltimoMeseTutti(): Record<
|
||||
string,
|
||||
{ presenti: number; totali: number; percentuale: number }
|
||||
> {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
|
||||
return useMemo(() => {
|
||||
const oggi = new Date();
|
||||
const inizio = new Date(oggi);
|
||||
inizio.setDate(inizio.getDate() - 30);
|
||||
const da = inizio.toISOString().slice(0, 10);
|
||||
const a = oggi.toISOString().slice(0, 10);
|
||||
|
||||
const rilevanti = eventi.filter(
|
||||
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
|
||||
);
|
||||
|
||||
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);
|
||||
for (const id of ids) {
|
||||
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
|
||||
rec.totali += 1;
|
||||
const stato = presenze[e.id]?.[id];
|
||||
if (stato === "presente" || stato === "ritardo") rec.presenti += 1;
|
||||
}
|
||||
}
|
||||
for (const rec of Object.values(out)) {
|
||||
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
|
||||
}
|
||||
return out;
|
||||
}, [eventi, presenze]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Stato } from "./crapp-data";
|
||||
|
||||
export const PRESENZE_KEY = ["risposte-presenze"] as const;
|
||||
|
||||
/** eventoId -> giocatoreId -> stato */
|
||||
export type MappaPresenze = Record<string, Record<string, Stato>>;
|
||||
|
||||
async function fetchPresenze(): Promise<MappaPresenze> {
|
||||
const { data, error } = await supabase
|
||||
.from("risposte_presenze")
|
||||
.select("evento_id, giocatore_id, stato");
|
||||
if (error) throw error;
|
||||
const mappa: MappaPresenze = {};
|
||||
for (const riga of data ?? []) {
|
||||
(mappa[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
|
||||
}
|
||||
return mappa;
|
||||
}
|
||||
|
||||
/** Una lettura per sessione: le risposte cambiano poco durante la navigazione. */
|
||||
export function useRispostePresenze() {
|
||||
const query = useQuery({ queryKey: PRESENZE_KEY, queryFn: fetchPresenze, staleTime: 5 * 60_000 });
|
||||
return { ...query, presenze: query.data ?? {} };
|
||||
}
|
||||
|
||||
export function usePresenzeEvento(eventoId: string) {
|
||||
const { presenze, ...resto } = useRispostePresenze();
|
||||
return { ...resto, risposte: presenze[eventoId] ?? {} };
|
||||
}
|
||||
|
||||
export function useSalvaPresenza() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => {
|
||||
if (input.stato === null) {
|
||||
const { error } = await supabase
|
||||
.from("risposte_presenze")
|
||||
.delete()
|
||||
.eq("evento_id", input.eventoId)
|
||||
.eq("giocatore_id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { error } = await supabase.from("risposte_presenze").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
stato: input.stato,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id,giocatore_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
return input;
|
||||
},
|
||||
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<MappaPresenze>(PRESENZE_KEY, (prec) => {
|
||||
const mappa: MappaPresenze = { ...(prec ?? {}) };
|
||||
const evento = { ...(mappa[input.eventoId] ?? {}) };
|
||||
if (input.stato === null) delete evento[input.giocatoreId];
|
||||
else evento[input.giocatoreId] = input.stato;
|
||||
mappa[input.eventoId] = evento;
|
||||
return mappa;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
function base64UrlToUint8Array(base64Url: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const base64 = (base64Url + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function chiaviDa(sub: PushSubscription) {
|
||||
const json = sub.toJSON();
|
||||
return { p256dh: json.keys?.["p256dh"] ?? "", auth: json.keys?.["auth"] ?? "" };
|
||||
}
|
||||
|
||||
export function pushSupportato() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
export async function statoNotifiche(): Promise<boolean> {
|
||||
if (!pushSupportato()) return false;
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
return Boolean(sub);
|
||||
}
|
||||
|
||||
export async function attivaNotifiche(giocatoreId: string): Promise<void> {
|
||||
if (!pushSupportato()) throw new Error("Notifiche non supportate su questo dispositivo");
|
||||
|
||||
const permesso = await Notification.requestPermission();
|
||||
if (permesso !== "granted") throw new Error("Permesso notifiche negato");
|
||||
|
||||
const config = await fetch("/api/public/push-config").then((r) => r.json());
|
||||
if (!config?.publicKey) throw new Error("Notifiche non configurate");
|
||||
|
||||
const reg = await navigator.serviceWorker.register("/push-sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const esistente = await reg.pushManager.getSubscription();
|
||||
const sub =
|
||||
esistente ??
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: base64UrlToUint8Array(config.publicKey) as BufferSource,
|
||||
}));
|
||||
|
||||
const res = await fetch("/api/public/push-subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint, giocatoreId, ...chiaviDa(sub) }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Salvataggio iscrizione non riuscito");
|
||||
}
|
||||
|
||||
export async function disattivaNotifiche(): Promise<void> {
|
||||
if (!pushSupportato()) return;
|
||||
const reg = await navigator.serviceWorker.getRegistration("/push-sw.js");
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await fetch("/api/public/push-subscribe", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
await reg?.unregister();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import { giocatoriConScout, useScoutMatches } from "./scout-store";
|
||||
import { useVotiMvp, vincitoriMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
import { statisticheCacche, useCacche } from "./cacche";
|
||||
import { conteggioTurni } from "./palloni-core";
|
||||
import { useTurniPalloni } from "./palloni";
|
||||
import { useInfortuniERitardi } from "./infortuni";
|
||||
import { useGiocatoreBase } from "./user-store";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { obiettiviOrdinati } from "./obiettivi";
|
||||
|
||||
/**
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
* palloni, infortuni, ritardi, cacche). Usa solo cache già in memoria:
|
||||
* nessuna query aggiuntiva rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
const scoutMatches = useScoutMatches();
|
||||
const voti = useVotiMvp();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
const { righe: cacche } = useCacche();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
|
||||
const votiMvp = voti.data ?? [];
|
||||
|
||||
return useMemo(() => {
|
||||
const medie = mediePagelle(pagelle);
|
||||
const statCacche = statisticheCacche(cacche);
|
||||
const palloni = conteggioTurni(turni);
|
||||
return giocatoriConScout(scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
|
||||
...g,
|
||||
mediaVoto: medie[g.id]?.media ?? g.mediaVoto,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
|
||||
}
|
||||
|
||||
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||
export function useIo(): Giocatore | null {
|
||||
const base = useGiocatoreBase();
|
||||
const rosa = useRosa();
|
||||
if (!base) return null;
|
||||
return rosa.find((g) => g.id === base.id) ?? base;
|
||||
}
|
||||
|
||||
/** Obiettivi collaborativi calcolati sui dati reali già in cache. */
|
||||
export function useObiettivi() {
|
||||
const rosa = useRosa();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle });
|
||||
}
|
||||
|
||||
export { giocatori };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
|
||||
function riga(campi: Array<string | number>) {
|
||||
return campi
|
||||
.map((c) => {
|
||||
const testo = String(c);
|
||||
return /[";\n]/.test(testo) ? `"${testo.replace(/"/g, '""')}"` : testo;
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
|
||||
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
|
||||
export function csvScoutMatch(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(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
match.parziali.forEach((p, i) => righe.push(riga([i + 1, p[0], p[1]])));
|
||||
righe.push("");
|
||||
righe.push(riga(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
|
||||
const totali = totaliPerGiocatore(match.azioni);
|
||||
for (const g of giocatori) {
|
||||
const t = totali.get(g.id);
|
||||
if (!t) continue;
|
||||
righe.push(riga([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
}
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Giocatore", "Azione"]));
|
||||
for (const a of match.azioni) {
|
||||
const g = giocatori.find((x) => x.id === a.giocatoreId);
|
||||
righe.push(riga([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
|
||||
}
|
||||
return righe.join("\n");
|
||||
}
|
||||
|
||||
export function scaricaCsv(nomeFile: string, contenuto: string) {
|
||||
const blob = new Blob([`\uFEFF${contenuto}`], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = nomeFile;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEventi, type Evento } from "./eventi";
|
||||
|
||||
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
||||
export const SCADENZA_MINUTI = 5;
|
||||
|
||||
export function dataOggi(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** La partita in programma oggi, se c'è. */
|
||||
export function partitaDiOggi(eventi: Evento[], oggi = dataOggi()): Evento | null {
|
||||
return eventi.find((e) => e.tipo === "partita" && e.data === oggi) ?? null;
|
||||
}
|
||||
|
||||
export type SessioneScout = {
|
||||
evento_id: string;
|
||||
giocatore_id: string;
|
||||
giocatore_nome: string;
|
||||
aggiornato_il: string;
|
||||
};
|
||||
|
||||
export function sessioneScaduta(s: SessioneScout | null): boolean {
|
||||
if (!s) return true;
|
||||
return Date.now() - new Date(s.aggiornato_il).getTime() > SCADENZA_MINUTI * 60_000;
|
||||
}
|
||||
|
||||
const storageKey = (eventoId: string) => `crap-scout-session-${eventoId}`;
|
||||
|
||||
function readSession(eventoId: string): SessioneScout | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(eventoId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SessioneScout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(eventoId: string, sessione: SessioneScout | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (sessione) {
|
||||
window.localStorage.setItem(storageKey(eventoId), JSON.stringify(sessione));
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey(eventoId));
|
||||
}
|
||||
try {
|
||||
const bc = new BroadcastChannel(`crap-scout-${eventoId}`);
|
||||
bc.postMessage(sessione);
|
||||
bc.close();
|
||||
} catch {
|
||||
// fallback: storage event is already fired by localStorage
|
||||
}
|
||||
}
|
||||
|
||||
export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] as const;
|
||||
|
||||
export function useSessioneScout(eventoId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: SESSIONE_KEY(eventoId ?? "-"),
|
||||
enabled: !!eventoId,
|
||||
// Sincronizzazione via BroadcastChannel/storage: nessun polling.
|
||||
staleTime: Infinity,
|
||||
queryFn: async (): Promise<SessioneScout | null> => {
|
||||
if (!eventoId || typeof window === "undefined") return null;
|
||||
return readSession(eventoId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventoId) return;
|
||||
let bc: BroadcastChannel | null = null;
|
||||
try {
|
||||
bc = new BroadcastChannel(`crap-scout-${eventoId}`);
|
||||
bc.onmessage = (e) => {
|
||||
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.data as SessioneScout | null);
|
||||
};
|
||||
} catch {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey(eventoId)) {
|
||||
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.onmessage = null;
|
||||
bc.close();
|
||||
}
|
||||
};
|
||||
}, [eventoId, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */
|
||||
export function useApriSessioneScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const current = readSession(input.eventoId);
|
||||
if (current && !sessioneScaduta(current) && current.giocatore_id !== input.giocatoreId) {
|
||||
return false;
|
||||
}
|
||||
writeSession(input.eventoId, {
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
giocatore_nome: input.nome,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
onSuccess: (_ok, input) => {
|
||||
queryClient.invalidateQueries({ queryKey: SESSIONE_KEY(input.eventoId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useChiudiSessioneScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const current = readSession(input.eventoId);
|
||||
if (current && current.giocatore_id === input.giocatoreId) {
|
||||
writeSession(input.eventoId, null);
|
||||
}
|
||||
},
|
||||
onSuccess: (_d, input) => {
|
||||
queryClient.invalidateQueries({ queryKey: SESSIONE_KEY(input.eventoId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mantiene viva la sessione mentre lo scout è aperto. */
|
||||
export function useHeartbeatScout(eventoId: string | null, giocatoreId: string | null, attivo: boolean) {
|
||||
useEffect(() => {
|
||||
if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return;
|
||||
const id = window.setInterval(() => {
|
||||
const current = readSession(eventoId);
|
||||
if (current && current.giocatore_id === giocatoreId) {
|
||||
current.aggiornato_il = new Date().toISOString();
|
||||
writeSession(eventoId, current);
|
||||
}
|
||||
}, 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [attivo, eventoId, giocatoreId]);
|
||||
}
|
||||
|
||||
/** Versione SSR-safe: null finché il client non è montato. */
|
||||
export function usePartitaDiOggi(): { pronto: boolean; partita: Evento | null } {
|
||||
const { eventi, isPending } = useEventi();
|
||||
const [montato, setMontato] = useState(false);
|
||||
useEffect(() => {
|
||||
setMontato(true);
|
||||
}, []);
|
||||
if (!montato || isPending) return { pronto: false, partita: null };
|
||||
return { pronto: true, partita: partitaDiOggi(eventi) };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Azione } from "./scout-store";
|
||||
|
||||
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
||||
export type StatoScout = {
|
||||
azioni: Azione[];
|
||||
setChiusi: Array<[number, number]>;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
};
|
||||
|
||||
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
|
||||
azioni: [],
|
||||
setChiusi: [],
|
||||
avversario,
|
||||
casa,
|
||||
});
|
||||
|
||||
export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const;
|
||||
|
||||
export function useStatoScout(eventoId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: SCOUT_STATO_KEY(eventoId ?? "-"),
|
||||
enabled: !!eventoId,
|
||||
staleTime: Infinity,
|
||||
queryFn: async (): Promise<StatoScout | null> => {
|
||||
if (!eventoId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("scout_live")
|
||||
.select("stato")
|
||||
.eq("evento_id", eventoId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
const stato = data?.stato as StatoScout | undefined;
|
||||
if (!stato || !Array.isArray(stato.azioni)) return null;
|
||||
return stato;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSalvaStatoScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; stato: StatoScout }) => {
|
||||
const { error } = await supabase.from("scout_live").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
stato: JSON.parse(JSON.stringify(input.stato)),
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData(SCOUT_STATO_KEY(input.eventoId), input.stato);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancellaStatoScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (eventoId: string) => {
|
||||
const { error } = await supabase.from("scout_live").delete().eq("evento_id", eventoId);
|
||||
if (error) throw error;
|
||||
return eventoId;
|
||||
},
|
||||
onSuccess: (eventoId) => {
|
||||
queryClient.setQueryData(SCOUT_STATO_KEY(eventoId), null);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { giocatori, classifica, type Giocatore, type RigaClassifica } from "./crapp-data";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
export type Azione = {
|
||||
id: string;
|
||||
tipo: AzioneTipo;
|
||||
giocatoreId?: string;
|
||||
set: number;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type ScoutMatch = {
|
||||
id: string;
|
||||
data: string;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
setNostri: number;
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
mvp: string;
|
||||
azioni: Azione[];
|
||||
};
|
||||
|
||||
const KEY = "crapp-scout-v1";
|
||||
|
||||
let cache: ScoutMatch[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): ScoutMatch[] {
|
||||
if (cache) return cache;
|
||||
if (typeof window === "undefined") return (cache = []);
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
cache = raw ? (JSON.parse(raw) as ScoutMatch[]) : [];
|
||||
} catch {
|
||||
cache = [];
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function write(next: ScoutMatch[]) {
|
||||
cache = next;
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function salvaScoutMatch(m: ScoutMatch) {
|
||||
write([m, ...read()]);
|
||||
}
|
||||
|
||||
export function eliminaScoutMatch(id: string) {
|
||||
write(read().filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
export function useScoutMatches(): ScoutMatch[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
() => read(),
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Somma delle azioni di un match per giocatore. */
|
||||
export function totaliPerGiocatore(azioni: Azione[]) {
|
||||
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
|
||||
for (const a of azioni) {
|
||||
if (!a.giocatoreId) continue;
|
||||
const cur = out.get(a.giocatoreId) ?? { punti: 0, ace: 0, muri: 0, errori: 0 };
|
||||
if (a.tipo === "attacco") cur.punti += 1;
|
||||
if (a.tipo === "ace") {
|
||||
cur.ace += 1;
|
||||
cur.punti += 1;
|
||||
}
|
||||
if (a.tipo === "muro") {
|
||||
cur.muri += 1;
|
||||
cur.punti += 1;
|
||||
}
|
||||
if (a.tipo === "errore") cur.errori += 1;
|
||||
out.set(a.giocatoreId, cur);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Totali di squadra ricavati dallo scout (dato tecnico, non personale). */
|
||||
export function totaliSquadra(matches: ScoutMatch[]) {
|
||||
const out = { punti: 0, ace: 0, muri: 0, errori: 0 };
|
||||
for (const m of matches) {
|
||||
for (const t of totaliPerGiocatore(m.azioni).values()) {
|
||||
out.punti += t.punti;
|
||||
out.ace += t.ace;
|
||||
out.muri += t.muri;
|
||||
out.errori += t.errori;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva.
|
||||
* `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */
|
||||
export function giocatoriConScout(
|
||||
matches: ScoutMatch[],
|
||||
mvpPerMatch: Record<string, string> = {},
|
||||
): Giocatore[] {
|
||||
return giocatori.map((g) => {
|
||||
let presenze = 0;
|
||||
let mvp = 0;
|
||||
for (const m of matches) {
|
||||
if (totaliPerGiocatore(m.azioni).has(g.id)) presenze += 1;
|
||||
if (mvpPerMatch[m.id] === g.nome) mvp += 1;
|
||||
}
|
||||
return {
|
||||
...g,
|
||||
mvp: g.mvp + mvp,
|
||||
presenze: g.presenze + presenze,
|
||||
totaliEventi: g.totaliEventi + matches.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Classifica demo aggiornata con i match scoutati (solo la nostra riga). */
|
||||
export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
|
||||
if (matches.length === 0) return classifica;
|
||||
const agg = matches.reduce(
|
||||
(s, m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
s.giocate += 1;
|
||||
s.vinte += vinta ? 1 : 0;
|
||||
s.perse += vinta ? 0 : 1;
|
||||
s.setFatti += m.setNostri;
|
||||
s.setSubiti += m.setLoro;
|
||||
s.punti += vinta ? (m.setLoro <= 1 ? 3 : 2) : m.setLoro === 3 && m.setNostri === 2 ? 1 : 0;
|
||||
return s;
|
||||
},
|
||||
{ giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 },
|
||||
);
|
||||
return classifica
|
||||
.map((r) =>
|
||||
r.squadra === "CRAP Volley"
|
||||
? {
|
||||
...r,
|
||||
giocate: r.giocate + agg.giocate,
|
||||
vinte: r.vinte + agg.vinte,
|
||||
perse: r.perse + agg.perse,
|
||||
setFatti: r.setFatti + agg.setFatti,
|
||||
setSubiti: r.setSubiti + agg.setSubiti,
|
||||
punti: r.punti + agg.punti,
|
||||
}
|
||||
: r,
|
||||
)
|
||||
.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,90 @@
|
||||
import { Dumbbell, Swords, Zap } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
export type SerieTipo = "allenamenti" | "partite" | "conferme";
|
||||
|
||||
export type SerieDef = {
|
||||
tipo: SerieTipo;
|
||||
label: string;
|
||||
descrizione: string;
|
||||
icon: LucideIcon;
|
||||
/** Traguardi progressivi della serie. */
|
||||
traguardi: number[];
|
||||
valore: (g: Giocatore) => number;
|
||||
};
|
||||
|
||||
export const serieDefs: SerieDef[] = [
|
||||
{
|
||||
tipo: "allenamenti",
|
||||
label: "Allenamenti",
|
||||
descrizione: "Allenamenti consecutivi con presenza",
|
||||
icon: Dumbbell,
|
||||
traguardi: [3, 6, 10, 15],
|
||||
valore: (g) => g.serieAllenamenti,
|
||||
},
|
||||
{
|
||||
tipo: "partite",
|
||||
label: "Partite",
|
||||
descrizione: "Partite consecutive in cui c'eri",
|
||||
icon: Swords,
|
||||
traguardi: [2, 5, 8, 12],
|
||||
valore: (g) => g.seriePartite,
|
||||
},
|
||||
{
|
||||
tipo: "conferme",
|
||||
label: "Conferme 24h",
|
||||
descrizione: "Risposte date entro 24 ore dalla convocazione",
|
||||
icon: Zap,
|
||||
traguardi: [3, 8, 15, 20],
|
||||
valore: (g) => g.serieConferme,
|
||||
},
|
||||
];
|
||||
|
||||
export type SerieStato = {
|
||||
def: SerieDef;
|
||||
valore: number;
|
||||
prossimo: number | null;
|
||||
manca: number;
|
||||
progresso: number;
|
||||
messaggio: string;
|
||||
};
|
||||
|
||||
/** Regola di aggiornamento: +1 se l'impegno è stato onorato, altrimenti
|
||||
* si azzera SOLO questa serie, lasciando intatte le altre. */
|
||||
export function aggiornaSerie(valore: number, onorato: boolean) {
|
||||
return onorato ? valore + 1 : 0;
|
||||
}
|
||||
|
||||
function messaggioSerie(valore: number, prossimo: number | null, label: string) {
|
||||
if (valore === 0) return `Serie ${label.toLowerCase()} azzerata: riparti dal prossimo.`;
|
||||
if (prossimo === null) return "Serie leggendaria: sei fuori scala!";
|
||||
const manca = prossimo - valore;
|
||||
if (manca === 1) return "Manca solo una volta al prossimo traguardo!";
|
||||
if (valore >= 5) return `Che continuità: ancora ${manca} e sali di livello.`;
|
||||
return `Bella partenza: ${manca} al prossimo traguardo.`;
|
||||
}
|
||||
|
||||
export function statoSerie(def: SerieDef, g: Giocatore): SerieStato {
|
||||
const valore = def.valore(g);
|
||||
const prossimo = def.traguardi.find((t) => valore < t) ?? null;
|
||||
const manca = prossimo ? prossimo - valore : 0;
|
||||
const progresso = prossimo ? Math.min(100, Math.round((valore / prossimo) * 100)) : 100;
|
||||
return {
|
||||
def,
|
||||
valore,
|
||||
prossimo,
|
||||
manca,
|
||||
progresso,
|
||||
messaggio: messaggioSerie(valore, prossimo, def.label),
|
||||
};
|
||||
}
|
||||
|
||||
export function serieGiocatore(g: Giocatore): SerieStato[] {
|
||||
return serieDefs.map((def) => statoSerie(def, g));
|
||||
}
|
||||
|
||||
/** La serie migliore da mostrare in home. */
|
||||
export function serieMigliore(g: Giocatore): SerieStato {
|
||||
return serieGiocatore(g).sort((a, b) => b.valore - a.valore)[0]!;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import { conInfortuni, useInfortuniERitardi } from "./infortuni";
|
||||
|
||||
const KEY = "crapp-user-v1";
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: string | null | undefined = undefined;
|
||||
|
||||
function read(): string | null {
|
||||
if (cache !== undefined) return cache;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
cache = window.localStorage.getItem(KEY);
|
||||
} catch {
|
||||
cache = null;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function write(next: string | null) {
|
||||
cache = next;
|
||||
try {
|
||||
if (next) window.localStorage.setItem(KEY, next);
|
||||
else window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function impostaGiocatore(id: string) {
|
||||
write(id);
|
||||
}
|
||||
|
||||
export function resetGiocatore() {
|
||||
write(null);
|
||||
}
|
||||
|
||||
/** Solo lettura locale: nessuna dipendenza da React Query (usabile fuori dal provider). */
|
||||
export function useGiocatoreBase(): Giocatore | null {
|
||||
const id = useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
() => read(),
|
||||
() => null,
|
||||
);
|
||||
return id ? (giocatori.find((x) => x.id === id) ?? null) : null;
|
||||
}
|
||||
|
||||
export function useGiocatoreCorrente(): Giocatore | null {
|
||||
const g = useGiocatoreBase();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
return g ? conInfortuni(g, infortuni, ritardi) : null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Invio di notifiche web push (senza payload) firmate con VAPID, compatibile con il runtime edge. */
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (value.length % 4)) % 4);
|
||||
const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
async function importaChiave(publicKey: string, privateKey: string) {
|
||||
const pub = base64UrlDecode(publicKey);
|
||||
return crypto.subtle.importKey(
|
||||
"jwk",
|
||||
{
|
||||
kty: "EC",
|
||||
crv: "P-256",
|
||||
d: privateKey,
|
||||
x: base64UrlEncode(pub.slice(1, 33)),
|
||||
y: base64UrlEncode(pub.slice(33, 65)),
|
||||
ext: true,
|
||||
},
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
}
|
||||
|
||||
async function creaVapidJwt(audience: string, subject: string, publicKey: string, privateKey: string) {
|
||||
const header = encodeJson({ typ: "JWT", alg: "ES256" });
|
||||
const payload = encodeJson({
|
||||
aud: audience,
|
||||
exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
|
||||
sub: subject,
|
||||
});
|
||||
const chiave = await importaChiave(publicKey, privateKey);
|
||||
const firma = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
chiave,
|
||||
new TextEncoder().encode(`${header}.${payload}`),
|
||||
);
|
||||
return `${header}.${payload}.${base64UrlEncode(new Uint8Array(firma))}`;
|
||||
}
|
||||
|
||||
/** Invia una notifica "vuota": il service worker recupera poi il testo aggiornato. */
|
||||
export async function inviaPush(endpoint: string): Promise<number> {
|
||||
const publicKey = process.env["VAPID_PUBLIC_KEY"];
|
||||
const privateKey = process.env["VAPID_PRIVATE_KEY"];
|
||||
const subject = process.env["VAPID_SUBJECT"] ?? "mailto:crapp@crapvolley.it";
|
||||
if (!publicKey || !privateKey) throw new Error("Chiavi VAPID non configurate");
|
||||
|
||||
const audience = new URL(endpoint).origin;
|
||||
const jwt = await creaVapidJwt(audience, subject, publicKey, privateKey);
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
TTL: "86400",
|
||||
Authorization: `vapid t=${jwt}, k=${publicKey}`,
|
||||
"Content-Length": "0",
|
||||
},
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
Reference in New Issue
Block a user