Calcola davvero le serie di presenze e sblocca badge e obiettivo.

I tre contatori (serieAllenamenti, seriePartite, serieConferme) e streak erano
zero fisso in useRosa(): la sezione Serie di presenze del profilo mostrava sempre
progresso nullo, i badge legati alla costanza erano impossibili da sbloccare e
l'obiettivo Continuita di squadra restava a 0/12.

serieConsecutiva() e serieConferme() (src/lib/presenze.ts) derivano le serie dagli
eventi passati e da risposte_presenze gia in cache, senza query aggiuntive.

Migration m9: nuova colonna risposto_il con l'istante della prima risposta, resa
immutabile da un trigger, confrontata con eventi_app.creato_il per le conferme
entro 24 ore. Serviva perche aggiornato_il registra l'ultima modifica, quindi chi
rispondeva subito e cambiava idea dopo risultava lento.

Corregge anche la barra di progresso, che misurava valore/prossimo e tornava
indietro a ogni traguardo raggiunto (2/3 = 67%, poi 3/6 = 50%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:46:04 +02:00
co-authored by Claude Opus 5
parent 952f7b43d3
commit 7237e8ff39
12 changed files with 559 additions and 54 deletions
+3
View File
@@ -487,18 +487,21 @@ export type Database = {
aggiornato_il: string;
evento_id: string;
giocatore_id: string;
risposto_il: string;
stato: string;
};
Insert: {
aggiornato_il?: string;
evento_id: string;
giocatore_id: string;
risposto_il?: string;
stato: string;
};
Update: {
aggiornato_il?: string;
evento_id?: string;
giocatore_id?: string;
risposto_il?: string;
stato?: string;
};
Relationships: [];
+6 -1
View File
@@ -20,6 +20,9 @@ export type Evento = {
casa: boolean;
/** Le pagelle di questa partita non accettano più voti. */
pagelleChiuse: boolean;
/** Quando l'evento è stato creato: è l'istante della convocazione.
* Assente sugli eventi generati dal client (compleanni, bozze non salvate). */
creatoIl?: string | undefined;
};
export type RigaEvento = {
@@ -34,6 +37,7 @@ export type RigaEvento = {
campionato: boolean;
casa: boolean | null;
pagelle_chiuse: boolean;
creato_il?: string;
};
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
@@ -50,11 +54,12 @@ export function daRiga(r: RigaEvento): Evento {
campionato: !!r.campionato,
casa: r.casa ?? true,
pagelleChiuse: !!r.pagelle_chiuse,
creatoIl: r.creato_il,
};
}
const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
+91 -13
View File
@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import { aggiornaSerie } from "./serie";
export const PRESENZE_KEY = ["risposte-presenze"] as const;
/** eventoId -> giocatoreId -> stato */
export type MappaPresenze = Record<string, Record<string, Stato>>;
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
export type MappaTempiRisposta = Record<string, Record<string, string>>;
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
return eventi.filter(
@@ -36,22 +40,87 @@ export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): nu
return eventiContanoPresenze(eventi, giocatoreId).length;
}
async function fetchPresenze(): Promise<MappaPresenze> {
/**
* Serie di presenze consecutive su eventi già passati, in ordine di data:
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
* risposta — azzera la serie. Senza `tipo` conta partite e allenamenti insieme.
*/
export function serieConsecutiva(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
tipo?: "partita" | "allenamento",
oggi: string = oggiIso(),
): number {
return serieSu(giocatoreId, eventi, oggi, tipo, (e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
});
}
const ORE_24 = 24 * 60 * 60 * 1000;
/**
* Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione
* (`creatoIl` dell'evento). Gli eventi senza istante di creazione — quelli generati
* dal client, non salvati a database — non spezzano la serie: vengono saltati.
*/
export function serieConferme(
giocatoreId: string,
eventi: Evento[],
tempi: MappaTempiRisposta,
oggi: string = oggiIso(),
): number {
return serieSu(
giocatoreId,
eventi.filter((e) => e.creatoIl),
oggi,
undefined,
(e) => {
const risposto = tempi[e.id]?.[giocatoreId];
return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24;
},
);
}
function oggiIso() {
return new Date().toISOString().slice(0, 10);
}
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
function serieSu(
giocatoreId: string,
eventi: Evento[],
oggi: string,
tipo: "partita" | "allenamento" | undefined,
onorato: (e: Evento) => boolean,
): number {
return eventiContanoPresenze(eventi, giocatoreId)
.filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi)
.sort((a, b) => a.data.localeCompare(b.data))
.reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0);
}
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
async function fetchPresenze(): Promise<LetturaPresenze> {
const { data, error } = await supabase
.from("risposte_presenze")
.select("evento_id, giocatore_id, stato");
.select("evento_id, giocatore_id, stato, risposto_il");
if (error) throw error;
const mappa: MappaPresenze = {};
const presenze: MappaPresenze = {};
const tempi: MappaTempiRisposta = {};
for (const riga of data ?? []) {
(mappa[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
}
return mappa;
return { presenze, tempi };
}
/** 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 ?? {} };
return { ...query, presenze: query.data?.presenze ?? {}, tempi: query.data?.tempi ?? {} };
}
export function usePresenzeEvento(eventoId: string) {
@@ -86,13 +155,22 @@ export function useSalvaPresenza() {
},
// 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;
queryClient.setQueryData<LetturaPresenze>(PRESENZE_KEY, (prec) => {
const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) };
const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) };
const stati = { ...(presenze[input.eventoId] ?? {}) };
const istanti = { ...(tempi[input.eventoId] ?? {}) };
if (input.stato === null) {
delete stati[input.giocatoreId];
delete istanti[input.giocatoreId];
} else {
stati[input.giocatoreId] = input.stato;
// Come a database: l'istante è quello della prima risposta, non dei ripensamenti.
istanti[input.giocatoreId] ??= new Date().toISOString();
}
presenze[input.eventoId] = stati;
tempi[input.eventoId] = istanti;
return { presenze, tempi };
});
},
});
+13 -7
View File
@@ -9,7 +9,13 @@ import { useTurniPalloni } from "./palloni";
import { useInfortuniERitardi } from "./infortuni";
import { useGiocatoreId } from "./user-store";
import { useEventi } from "./eventi";
import { contaPresenzeGiocatore, totaliEventiGiocatore, useRispostePresenze } from "./presenze";
import {
contaPresenzeGiocatore,
serieConferme,
serieConsecutiva,
totaliEventiGiocatore,
useRispostePresenze,
} from "./presenze";
import { obiettiviOrdinati } from "./obiettivi";
import { useCsi } from "./csi";
import { partiteGiocate } from "./csi-core";
@@ -33,7 +39,7 @@ export function useRosa(): Giocatore[] {
const { turni } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi();
const { presenze: mappaPresenze } = useRispostePresenze();
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
const votiMvp = voti.data ?? [];
@@ -54,10 +60,10 @@ export function useRosa(): Giocatore[] {
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
palloni: palloni[g.id] ?? 0,
@@ -66,7 +72,7 @@ export function useRosa(): Giocatore[] {
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze]);
}, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]);
}
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
+6 -2
View File
@@ -67,9 +67,13 @@ function messaggioSerie(valore: number, prossimo: number | null, label: string)
export function statoSerie(def: SerieDef, g: Giocatore): SerieStato {
const valore = def.valore(g);
const prossimo = def.traguardi.find((t) => valore < t) ?? null;
const i = def.traguardi.findIndex((t) => valore < t);
const prossimo = i < 0 ? null : def.traguardi[i]!;
const manca = prossimo ? prossimo - valore : 0;
const progresso = prossimo ? Math.min(100, Math.round((valore / prossimo) * 100)) : 100;
// Progresso dentro il livello corrente: fra il traguardo già preso e il prossimo,
// altrimenti la barra tornerebbe indietro ogni volta che se ne raggiunge uno.
const base = i <= 0 ? 0 : def.traguardi[i - 1]!;
const progresso = prossimo ? Math.round(((valore - base) / (prossimo - base)) * 100) : 100;
return {
def,
valore,