22 Commits
Author SHA1 Message Date
Ivan CacciariandCursor fc65914f55 Fa riempire tutta la larghezza alle tab di Campionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 18:35:35 +02:00
Ivan CacciariandCursor 8e23f31685 Rinomina Classifica in Campionato e allinea i link azione in home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 18:13:10 +02:00
Ivan CacciariandCursor f5aaa1cccb Evita che le tab Squadra allarghino la pagina: scroll solo sulla barra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:49:40 +02:00
davideandClaude Sonnet 5 b4878b7795 Chiude il sondaggio cacche al fischio d'inizio, non solo il giorno dopo
sondaggioAperto() restava aperto per sempre una volta passate le 8:00 del
giorno della partita, anche nei giorni successivi. Ora chiude esattamente
all'ora di inizio (partita alle 21:00 → aperto fino alle 20:59, poi chiuso
anche nei giorni seguenti), richiedendo anche l'ora dell'evento oltre alla
data.

Aggiunta sondaggioTerminato() per distinguere nel messaggio "non ancora
aperto" da "già chiuso": altrimenti dopo la partita l'utente avrebbe
continuato a leggere "apre alle 8:00".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:50 +02:00
davideandClaude Sonnet 5 9cc63b7854 Infortuni/ritardi: non contano più gli eventi futuri nei badge
contaInfortuni()/contaRitardi() leggevano tutta la mappa presenze senza
guardare la data dell'evento: un giocatore può segnarsi infortunato o in
ritardo anche su un evento non ancora avvenuto (l'UI lo permette finché
l'evento non è passato), e quel conteggio arrivava subito ai badge segreti
"Infermeria" e "Ritardi". Ora filtrano su data < oggi, come già fa
eventiContanoPresenze() per le statistiche di presenza: con l'avanzare
della data reale l'evento prima futuro entra da solo nel conteggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:50 +02:00
davideandClaude Sonnet 5 e7b5082398 Squadra: un solo controllo per il criterio della classifica interna
La select nativa e il bottone-drawer cambiavano lo stesso criterio in due modi
contemporaneamente, effetto ridondante e poco curato. Ora un'unica riga
tappabile mostra il criterio attivo e apre il drawer di scelta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:15:17 +02:00
davideandClaude Sonnet 5 446403cdf2 Menu sottosezioni "sottolineatura": pillola piena sulla tab attiva, su tutta larghezza
La sola sottolineatura era troppo minimale sulla tab attiva. Ora è uno sfondo
pieno arrotondato come le pillole del Profilo, ma le tab restano giustificate
su tutta la larghezza dello schermo senza scroll orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:27:47 +02:00
davideandClaude Sonnet 5 a7ae9ed3a4 Rimuove dalla home gli eventi già confermati dalla lista "da confermare"
L'elenco si basava solo sull'ordine cronologico dei prossimi eventi,
ignorando la risposta di presenza già data dall'utente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:09:55 +02:00
Ivan CacciariandCursor ed289b9ecd Ingrandisce i titoli delle sezioni a 20px.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:42:08 +02:00
Ivan CacciariandCursor b92a167b5a Limita a 3 i prossimi eventi nel calendario.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:40:03 +02:00
Ivan CacciariandCursor a06c5ed281 Semplifica il calendario e mostra le note solo sulle card Evento.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:38:03 +02:00
Ivan CacciariandCursor 10915d04fc Affina EventoCard: stato sopra i bottoni, note solo in dettaglio, badge Evento.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:24:58 +02:00
Ivan CacciariandCursor a459579f8b Ingrandisce i testi del widget profilo e del link Calendario in home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:03:47 +02:00
Ivan CacciariandCursor a6799b91d3 Ridisegna EventoCard e toglie infortunato dagli eventi extra-campo.
Card cliccabile con icone presenza compatte, senza barra percentuale; avatar home leggermente più grande.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 10:58:57 +02:00
davideandClaude Sonnet 5 c44fd8495e Non conta i turni palloni finché l'evento non è passato.
conteggioTurni() contava ogni turno presente nella mappa, incluse le
assegnazioni anticipate per eventi futuri: un allenamento di lunedì con
turno già assegnato oggi (venerdì) veniva già sommato al badge "Sherpa dei
palloni" prima ancora di essersi svolto. Ora filtra su e.data < oggi, lo
stesso criterio già usato per presenze e serie — il turno resta assegnabile/
modificabile in anticipo, semplicemente non conta finché il giorno non arriva.

oggiISO() (palloni-core.ts) diventa un alias di dataOggi(): stessa correzione
di fuso della commit precedente, qui serviva anche per admin.tsx (scadenza
documenti/certificati) e cacche.ts (statistiche giornaliere), che la usano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:59:03 +02:00
davideandClaude Sonnet 5 7ef7eef963 Calcola "oggi" nel fuso di Roma ovunque, invece di UTC o del fuso del processo.
serieConsecutiva()/serieConferme() calcolavano oggi con new Date().toISOString()
(sempre UTC), mentre contaPresenzeGiocatore() usava dataOggi() con i getter
locali di Date (corretti solo se il processo gira già in fuso italiano — falso
su un server SSR in UTC). Le due statistiche potevano non essere d'accordo su
cosa fosse "oggi" nelle prime ore della giornata italiana.

dataOggi() ora usa Intl.DateTimeFormat con timeZone: "Europe/Rome": il cambio
ora legale/solare lo gestisce il database IANA dei fusi, non un offset scritto
a mano. Le funzioni di serie in presenze.ts usano lo stesso dataOggi() invece
di un oggiIso() locale, così tutte le statistiche restano coerenti fra loro.

Aggiunti test che dimostrano il fix con istanti reali a cavallo di mezzanotte
sia in CET che in CEST, per provare che lo scarto segue davvero il fuso e non
un offset fisso.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:47:02 +02:00
davideandClaude Sonnet 5 8cd0f1dc88 Congela la serie di presenze su infortunato invece di azzerarla.
L'evento in cui il giocatore risulta infortunato viene ora escluso a monte dal
calcolo di serieConsecutiva() (come già succede per gli eventi senza creatoIl
in serieConferme()): non conta né come presenza né come buco, la serie resta
al valore precedente. contaPresenzeGiocatore() non cambia: infortunato continua
a non contare come presenza nelle statistiche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:31:31 +02:00
davideandClaude Sonnet 5 b26d382c11 Sostituisce il confirm() nativo con un drawer per eliminare un evento.
Coerente con lo stile dell'app (stesso componente Drawer usato per i badge)
invece del popup del browser: mostra il titolo dell'evento e richiede un
tocco esplicito su "Elimina" prima di procedere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:11:11 +02:00
davideandClaude Sonnet 5 9e2f83a85e Corregge il rimbalzo di /eventi in home e la lista invisibile con molti eventi.
Il redirect verso /benvenuto scattava se la rosa non era ancora arrivata dalla
rete, anche con sessione e profilo validi: caricare /eventi a freddo (refresh,
link diretto, riapertura della PWA) ti riportava sempre in Home. Ora si aspetta
il caricamento della rosa e si torna alla pagina di destinazione originale.

In Reveal, la soglia del 15% dell'altezza dell'elemento per attivare l'animazione
era irraggiungibile per una lista lunga (75 eventi = oltre 5700px): la sezione
restava invisibile per sempre, a qualunque scroll. La soglia ora è "some" (basta
un pixel visibile).

Aggiunta anche la gestione dell'errore sul caricamento eventi, che prima falliva
in silenzio mostrando una pagina vuota senza alcun messaggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 08:59:10 +02:00
Ivan CacciariandCursor a1d2599c47 Rinomina il link evento in Dettagli.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:54:03 +02:00
Ivan CacciariandCursor ca1eee5425 Ripulisce profilo e tab, e mette Apri evento sull'ultima riga delle presenze.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:48:21 +02:00
Ivan CacciariandCursor 6bc5e8f006 Ingrandisce i caratteri delle tab sottosezioni e dei controlli calendario.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:24:59 +02:00
32 changed files with 756 additions and 339 deletions
+3 -3
View File
@@ -38,9 +38,9 @@ Prima versione, pre-release.
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
- Sondaggio pre-partita con apertura programmata alle 8:00 del giorno della partita e
pulsante «Avvisa tutti del sondaggio» per gli amministratori
(`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
- Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio
(poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per
gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
- Turni palloni con rotazione automatica sulle partite e assegnazione manuale per gli
allenamenti, che restano «da assegnare» finché non si sceglie (migration M10).
- Notifiche push con il testo cifrato **dentro** la push (`aes128gcm`, RFC 8291), così
+8 -1
View File
@@ -27,6 +27,12 @@ volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
altro codice.
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
profilo che mostri il numero di infortuni come statistica di superficie.
@@ -38,7 +44,8 @@ profilo che mostri il numero di infortuni come statistica di superficie.
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
invece di "assente": nessuna validazione o promemoria lo garantisce.
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato.
+4 -1
View File
@@ -34,7 +34,10 @@ compaiono.
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
confermate** (partite/eventi) — non è uno storico in tabella dedicata.
confermate** (partite/eventi) — non è uno storico in tabella dedicata. Conta solo gli
eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato
in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel
giorno non arriva.
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
+6 -2
View File
@@ -2,7 +2,7 @@
**Stato:** implementato (v1.0)
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/routes/api/public/sollecita-presenze.ts`
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
---
@@ -24,7 +24,8 @@ attuale.
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
risposto".
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
---
@@ -43,6 +44,9 @@ RosaPresenze → src/components/crapp/RosaPresenze.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx
EventoCard → src/components/crapp/EventoCard.tsx
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
--- statistiche ---
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
+16 -8
View File
@@ -137,9 +137,10 @@ Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili so
Conseguenze da conoscere prima di cambiare qualcosa:
- **`infortunato` azzera la serie**, esattamente come `assente`. Coerente con il conteggio
presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per
infortunio.
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
@@ -281,9 +282,10 @@ il costo diventerebbe per-render e andrebbe stabilizzata a monte.
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
- **Cambiare la regola di presenza** (per esempio non azzerare su `infortunato`) → il
predicato dentro `serieConsecutiva()`. Valutare se allineare anche
`contaPresenzeGiocatore()`, che oggi usa lo stesso criterio.
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
assenza ai fini statistici.
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
@@ -314,8 +316,14 @@ nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non determini
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle
prime ore della giornata italiana un evento di oggi può risultare "non ancora passato".
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date`
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
italiana potevano non essere d'accordo su cosa fosse "oggi".
---
+68 -58
View File
@@ -7,30 +7,33 @@ export type VoceSottosezione = {
id: string;
label: string;
contenuto: ReactNode;
/** Se true, non ripete il titolo della tab sopra il contenuto (es. Classifica già nella barra). */
nascondiTitolo?: boolean;
/** Se true, il pannello è a tutta larghezza (niente padding), es. barra filtri Classifica. */
aTuttoLarghezza?: boolean;
};
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
/**
* Barra di sottosezioni in un'unica fila scorrevole (swipe orizzontale) e
* pannello che mostra una sola sezione alla volta, cambiabile anche con
* swipe sul contenuto.
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
* alla volta, cambiabile anche con swipe sul contenuto.
*
* `variante="sottolineatura"`: tab a testo con sottolineatura rossa e senza titolo
* ripetuto sotto la barra (Squadra e Classifica CSI). Default `pillole`: restano le
* pill e i titoli usati dal Profilo.
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
* Default `pillole`: tab a larghezza naturale (Profilo). Il titolo ripetuto sotto la
* barra non viene mai mostrato: letichetta è già nella tab.
*/
export function BarraSottosezioni({
voci,
defaultId,
variante = "pillole",
riempiLarghezza = false,
}: {
voci: VoceSottosezione[];
defaultId?: string;
variante?: "pillole" | "sottolineatura";
/** Tab a larghezza uguale che riempiono la barra (es. Campionato). */
riempiLarghezza?: boolean;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
@@ -44,9 +47,8 @@ export function BarraSottosezioni({
const sottolineatura = variante === "sottolineatura";
useEffect(() => {
// `auto`: lo smooth competerebbe col tween del pannello sul main thread.
tabRefs.current[attiva]?.scrollIntoView({
behavior: "auto",
behavior: "smooth",
inline: "center",
block: "nearest",
});
@@ -63,62 +65,73 @@ export function BarraSottosezioni({
if (!voce) return null;
return (
<div>
<div className="min-w-0 max-w-full">
<div
className={cn(
"border-b border-border bg-background",
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
<div
role="tablist"
aria-label="Sottosezioni"
className={cn(
"flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
sottolineatura ? "gap-0 px-2" : "-mx-0 gap-1.5 px-5 pb-3",
"w-full max-w-full min-w-0 overflow-x-auto overflow-y-hidden [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
!sottolineatura && "pb-3",
)}
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
"snap-center shrink-0 grow basis-0 touch-manipulation whitespace-nowrap text-xs font-bold uppercase tracking-wide transition-colors",
"min-h-11",
sottolineatura
? cn(
"rounded-none border-b-2 px-3 py-3",
selezionata
? "border-accent text-foreground"
: "border-transparent text-muted-foreground",
)
: cn(
"rounded-full px-3.5 py-2",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
),
)}
>
{v.label}
</button>
);
})}
<div
role="tablist"
aria-label="Sottosezioni"
className={cn(
"flex flex-nowrap",
riempiLarghezza
? "w-full"
: "w-max min-w-full",
sottolineatura ? "gap-1 px-2 py-1.5" : "snap-x snap-mandatory gap-1.5 px-5",
)}
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza ? "min-w-0 flex-1" : "shrink-0",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "text-muted-foreground",
)
: cn(
"snap-center rounded-full px-3.5 py-2",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
),
)}
>
{v.label}
</button>
);
})}
</div>
</div>
</div>
<div className="relative overflow-hidden">
<div className="relative min-w-0 overflow-hidden">
<motion.div
key={voce.id}
role="tabpanel"
@@ -135,11 +148,8 @@ export function BarraSottosezioni({
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * 20 }}
animate={{ opacity: 1, x: 0 }}
transition={ridotto ? { duration: 0.1 } : transizioneTab}
className={cn("touch-pan-y", voce.nascondiTitolo ? "px-0 pt-0 pb-4" : "px-5 py-4")}
className={cn("touch-pan-y", voce.aTuttoLarghezza ? "px-0 pt-0 pb-4" : "px-5 py-4")}
>
{voce.nascondiTitolo || sottolineatura ? null : (
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
)}
{voce.contenuto}
</motion.div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ const items = [
{ to: "/", label: "Home", icon: Home },
{ to: "/calendario", label: "Calendario", icon: CalendarDays },
{ to: "/squadra", label: "Squadra", icon: Users },
{ to: "/classifica", label: "Classifica", icon: Trophy },
{ to: "/classifica", label: "Campionato", icon: Trophy },
] as const;
export function BottomNav() {
+156 -80
View File
@@ -1,10 +1,19 @@
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
import {
MapPin,
Clock,
Users,
Cake,
ChevronRight,
Check,
HelpCircle,
X,
Bandage,
} from "lucide-react";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { Barra } from "@/components/motion/Barra";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
@@ -13,22 +22,44 @@ import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
allenamento: { label: "Allenamento", className: "bg-training text-training-foreground" },
evento: { label: "Eventi", className: "bg-warning text-warning-foreground" },
evento: { label: "Evento", className: "bg-warning text-warning-foreground" },
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
} as const;
const stati: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
const iconeStato: Record<Stato, typeof Check> = {
presente: Check,
forse: HelpCircle,
ritardo: Clock,
assente: X,
/** Cerotto singolo: leggibile a 16px e allineato all'emoji 🩹. */
infortunato: Bandage,
};
export function linkPerEvento(e: Evento) {
if (e.tipo === "partita") {
return { to: "/partita/$id", params: { id: e.id }, label: "Apri partita" };
return { to: "/partita/$id" as const, params: { id: e.id }, label: "Dettaglio partita" };
}
if (e.tipo === "allenamento") {
return { to: "/allenamento/$id", params: { id: e.id }, label: "Apri allenamento" };
return {
to: "/allenamento/$id" as const,
params: { id: e.id },
label: "Dettaglio allenamento",
};
}
return undefined;
}
function etichettaData(iso: string) {
const giorno = iso.slice(8, 10);
const mese = formatData(iso).split(" ")[2]?.slice(0, 3)?.toUpperCase() ?? "";
return `${giorno} ${mese}`;
}
export function EventoCard({
evento,
linkTo,
@@ -50,9 +81,19 @@ export function EventoCard({
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
: tipoMeta[evento.tipo];
const totale = rosa.length;
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
const noteCard =
evento.tipo === "evento" ? evento.note.trim() : "";
const stati =
evento.tipo === "evento"
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
stato === "infortunato"
? [...statiEventoExtra, "infortunato" as const]
: statiEventoExtra
: statiSportivi;
if (isCompleanno) {
return (
@@ -64,20 +105,39 @@ export function EventoCard({
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
</div>
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
<p className="text-xs font-semibold uppercase text-muted-foreground">
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
</p>
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
</div>
</Card>
);
}
const sottotitoloPartita =
evento.tipo === "partita"
? `${evento.casa ? "Casa" : "Trasferta"}${evento.campionato ? " · Campionato" : " · Amichevole"}`
: null;
const metaStato = stato ? statoMeta[stato] : null;
return (
<Card>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Card
as="article"
className={cn(
"relative overflow-hidden p-0",
cliccabile && "transition-transform active:scale-[0.99]",
)}
>
{/* Link a tutta card: i controlli sopra (z-10) restano indipendenti. */}
{linkTo ? (
<Link
to={linkTo.to}
params={linkTo.params}
aria-label={linkTo.label}
className="absolute inset-0 z-0 rounded-3xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
) : null}
<div className={cn("relative z-10 p-4", linkTo && "pointer-events-none")}>
<div className="flex items-start justify-between gap-3">
<span
className={cn(
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
@@ -86,76 +146,92 @@ export function EventoCard({
>
{tipo.label}
</span>
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
</span>
</div>
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
<p className="text-xs font-semibold uppercase text-muted-foreground">
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
</p>
</div>
</div>
{linkTo && (
<div className="mt-2 flex justify-end">
<Link
to={linkTo.to}
params={linkTo.params}
className="inline-flex min-h-11 items-center gap-1 rounded-full bg-primary px-4 text-xs font-bold uppercase tracking-wide text-primary-foreground transition-transform active:scale-95"
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
{sottotitoloPartita ? (
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
) : null}
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{evento.ora}</span>
</span>
<span className="inline-flex min-w-0 items-center gap-1">
<MapPin className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate">{evento.luogo}</span>
</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>
{presentiVeri}/{totale}
</span>
</span>
</div>
{noteCard ? (
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
) : null}
{metaStato ? (
<p
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
aria-live="polite"
>
{linkTo.label} <ArrowRight className="h-3 w-3" />
</Link>
</div>
)}
<span aria-hidden>{metaStato.emoji}</span>
<span>{metaStato.label}</span>
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> {evento.ora}
</span>
<span className="inline-flex min-w-0 items-center gap-1">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{evento.luogo}</span>
</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" /> {presentiVeri}/{totale}
</span>
{io ? (
<div
className={cn(
"pointer-events-auto flex w-full gap-1",
metaStato ? "mt-2.5" : "mt-3",
)}
role="group"
aria-label="La tua presenza"
>
{stati.map((s) => {
const meta = statoMeta[s];
const Icon = iconeStato[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending || passato}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
});
}}
aria-pressed={attivo}
aria-label={meta.label}
title={meta.label}
className={cn(
"inline-flex min-h-11 min-w-0 flex-1 items-center justify-center rounded-xl px-1 transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(meta.className, "shadow-card")
: "bg-secondary text-muted-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
</button>
);
})}
</div>
) : null}
</div>
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
{io ? (
<div className="mt-4 flex flex-wrap gap-2">
{stati.map((s) => {
const meta = statoMeta[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending || passato}
onClick={() =>
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
})
}
aria-pressed={attivo}
className={cn(
// min-h-11: sono i controlli più toccati dell'app, sotto i
// 44px si sbaglia bersaglio.
"min-h-11 rounded-full border border-border px-3 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(meta.className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
)}
>
{meta.label}
</button>
);
})}
</div>
) : null}
</Card>
);
}
@@ -401,10 +401,10 @@ export function CompletaProfilo({
className="premi block rounded-3xl bg-card p-4 shadow-card"
>
<div className="flex items-center justify-between gap-3">
<span className="font-display text-sm uppercase tracking-wide">
<span className="font-display text-[15px] uppercase tracking-wide">
Completa il tuo profilo
</span>
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
@@ -412,7 +412,7 @@ export function CompletaProfilo({
style={{ width: `${perc}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
</p>
</Link>
+13 -3
View File
@@ -7,7 +7,13 @@ import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche";
import {
mediaPartita,
sondaggioAperto,
sondaggioTerminato,
useCacche,
useSalvaCacche,
} from "@/lib/cacche";
const opzioni = [0, 1, 2, 3, 4, 5];
@@ -15,9 +21,11 @@ const opzioni = [0, 1, 2, 3, 4, 5];
export function SondaggioCacche({
eventoId,
dataEvento,
oraEvento,
}: {
eventoId: string;
dataEvento: string;
oraEvento: string;
}) {
const io = useGiocatoreCorrente();
const { righe } = useCacche();
@@ -69,14 +77,16 @@ export function SondaggioCacche({
}
}
if (!sondaggioAperto(dataEvento)) {
if (!sondaggioAperto(dataEvento, oraEvento)) {
return (
<Card>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<p className="mt-1 text-xs text-muted-foreground">
Apre alle 8:00 del giorno della partita: riceverai una notifica.
{sondaggioTerminato(dataEvento, oraEvento)
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
</p>
</Card>
);
+9 -7
View File
@@ -53,14 +53,14 @@ export function Card({
*/
export function LinkProfilo() {
const g = useIo();
if (!g) return <TeamLogo className="h-11 w-11" />;
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={g.iniziali} className="h-11 w-11 text-lg" />
<Avatar id={g.id} fallback={g.iniziali} className="h-12 w-12 text-lg" />
</Link>
);
}
@@ -96,17 +96,19 @@ export function Section({
children,
indice = 0,
}: {
titolo: string;
titolo?: string;
azione?: ReactNode;
children: ReactNode;
indice?: number;
}) {
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
{azione}
</div>
{titolo || azione ? (
<div className="mb-3 flex items-center justify-between gap-3">
{titolo ? <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2> : <span />}
{azione}
</div>
) : null}
{children}
</Reveal>
);
+1 -1
View File
@@ -37,7 +37,7 @@ export function Reveal({
{...(style ? { style } : {})}
initial={ridotto ? { opacity: 0 } : { opacity: 0, y: 10 }}
whileInView={ridotto ? { opacity: 1 } : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15, margin: "0px 0px -10% 0px" }}
viewport={{ once: true, amount: "some", margin: "0px 0px -10% 0px" }}
transition={ridotto ? { duration: 0.2, delay: ritardo } : { ...molla.ui, delay: ritardo }}
>
{children}
+33 -4
View File
@@ -5,11 +5,40 @@ import { oggiISO } from "./palloni-core";
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
export const ORA_APERTURA_SONDAGGIO = 8;
/** Il sondaggio apre alle 8:00 del giorno della partita e da lì resta aperto. */
export function sondaggioAperto(dataEvento: string, adesso = new Date()): boolean {
/** Istante del fischio d'inizio: `oraEvento` mancante equivale a fine giornata (non chiude mai prima). */
function inizioPartita(dataEvento: string, oraEvento: string): number {
return new Date(`${dataEvento}T${oraEvento || "23:59"}:00`).getTime();
}
/** Il sondaggio apre alle 8:00 del giorno della partita e chiude al fischio d'inizio. */
export function sondaggioAperto(
dataEvento: string,
oraEvento: string,
adesso = new Date(),
): boolean {
if (dataEvento !== oggiISO(adesso)) return false;
const apertura = new Date(`${dataEvento}T00:00:00`);
apertura.setHours(ORA_APERTURA_SONDAGGIO, 0, 0, 0);
return (
adesso.getTime() >= apertura.getTime() &&
adesso.getTime() < inizioPartita(dataEvento, oraEvento)
);
}
/**
* True se il fischio d'inizio è già passato (oggi o in un giorno precedente): serve a
* distinguere, nel messaggio mostrato quando il sondaggio è chiuso, "non ancora aperto"
* da "già chiuso" altrimenti dopo la partita si continuerebbe a dire "apre alle 8:00".
*/
export function sondaggioTerminato(
dataEvento: string,
oraEvento: string,
adesso = new Date(),
): boolean {
const oggi = oggiISO(adesso);
if (dataEvento !== oggi) return dataEvento < oggi;
return adesso.getHours() >= ORA_APERTURA_SONDAGGIO;
if (dataEvento < oggi) return true;
if (dataEvento > oggi) return false;
return adesso.getTime() >= inizioPartita(dataEvento, oraEvento);
}
/** Sondaggio goliardico pre-partita: quante cacche prima del match di campionato. */
+45 -13
View File
@@ -1,13 +1,31 @@
import { useMemo } from "react";
import type { Giocatore } from "./crapp-data";
import type { Evento } from "./eventi";
import { useEventi } from "./eventi";
import { dataOggi } from "./scout-live";
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 {
/**
* Un giocatore può segnarsi infortunato o in ritardo anche su un evento futuro
* (l'UI lo permette finché l'evento non è passato): finché quell'evento non è
* avvenuto davvero non deve contare per i badge, altrimenti si sbloccherebbero
* in anticipo. Un evento non più in `eventi` (es. cancellato) non viene contato:
* non potendo verificarne la data, si esclude per prudenza.
*/
function contaStato(
presenze: MappaPresenze,
stato: string,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
const dataPerEvento = new Map(eventi.map((e) => [e.id, e.data]));
const out: ContoInfortuni = {};
for (const eventoId of Object.keys(presenze)) {
const dataEvento = dataPerEvento.get(eventoId);
if (dataEvento === undefined || dataEvento >= oggi) continue;
const evento = presenze[eventoId] ?? {};
for (const giocatoreId of Object.keys(evento)) {
if (evento[giocatoreId] === stato) out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
@@ -16,14 +34,22 @@ function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
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 gli infortuni dalla mappa presenze già in cache: ogni evento passato vale una volta sola. */
export function contaInfortuni(
presenze: MappaPresenze,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
return contaStato(presenze, "infortunato", eventi, oggi);
}
/** Conta i ritardi dalla stessa mappa presenze: ogni evento vale una volta sola. */
export function contaRitardi(presenze: MappaPresenze): ContoInfortuni {
return contaStato(presenze, "ritardo");
/** Conta i ritardi dalla stessa mappa presenze: ogni evento passato vale una volta sola. */
export function contaRitardi(
presenze: MappaPresenze,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
return contaStato(presenze, "ritardo", eventi, oggi);
}
export function conInfortuni<T extends Giocatore>(
@@ -38,23 +64,29 @@ export function conInfortuni<T extends Giocatore>(
};
}
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
export function useInfortuni(): ContoInfortuni {
const { presenze } = useRispostePresenze();
return useMemo(() => contaInfortuni(presenze), [presenze]);
const { eventi } = useEventi();
return useMemo(() => contaInfortuni(presenze, eventi), [presenze, eventi]);
}
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
export function useRitardi(): ContoInfortuni {
const { presenze } = useRispostePresenze();
return useMemo(() => contaRitardi(presenze), [presenze]);
const { eventi } = useEventi();
return useMemo(() => contaRitardi(presenze, eventi), [presenze, eventi]);
}
/** Un solo hook per entrambi i conteggi: evita hook extra nei componenti. */
export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: ContoInfortuni } {
const { presenze } = useRispostePresenze();
const { eventi } = useEventi();
return useMemo(
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
[presenze],
() => ({
infortuni: contaInfortuni(presenze, eventi),
ritardi: contaRitardi(presenze, eventi),
}),
[presenze, eventi],
);
}
+19 -6
View File
@@ -1,5 +1,6 @@
import { formatData } from "./crapp-data";
import type { Evento } from "./eventi";
import { dataOggi } from "./scout-live";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
@@ -60,10 +61,23 @@ export function completaTurni(
return risultato;
}
/** Quante volte ciascun giocatore è incaricato dei palloni. */
export function conteggioTurni(turni: Record<string, string>): Record<string, number> {
/**
* Quante volte ciascun giocatore è incaricato dei palloni, solo per eventi già passati:
* un turno assegnato in anticipo per un allenamento futuro non è ancora "portato", quindi
* non deve contare finché quell'allenamento non è terminato (stesso criterio `e.data < oggi`
* usato per le presenze, così la conta non cambia da sola col passare della giornata).
*/
export function conteggioTurni(
turni: Record<string, string>,
eventi: Evento[],
oggi: string = dataOggi(),
): Record<string, number> {
const passati = new Set(eventi.filter((e) => e.data < oggi).map((e) => e.id));
const out: Record<string, number> = {};
for (const id of Object.values(turni)) out[id] = (out[id] ?? 0) + 1;
for (const [eventoId, giocatoreId] of Object.entries(turni)) {
if (!passati.has(eventoId)) continue;
out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
}
return out;
}
@@ -84,10 +98,9 @@ export function eventoSuccessivo(eventi: Evento[], eventoId: string): Evento | u
return i >= 0 ? lista[i + 1] : undefined;
}
/** Alias di `dataOggi()`, nel fuso di Roma: qui per non toccare gli import esistenti. */
export function oggiISO(d = new Date()): string {
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${mm}-${dd}`;
return dataOggi(d);
}
/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */
+15 -10
View File
@@ -68,18 +68,27 @@ export function destinatariSollecito(
* 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.
*
* Chi risulta infortunato non ci ha rinunciato: quell'evento è saltato, non conta
* come presenza come assenza, e la serie resta congelata al valore di prima.
*/
export function serieConsecutiva(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
tipo?: "partita" | "allenamento",
oggi: string = oggiIso(),
oggi: string = dataOggi(),
): number {
return serieSu(giocatoreId, eventi, oggi, tipo, (e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
});
return serieSu(
giocatoreId,
eventi.filter((e) => presenze[e.id]?.[giocatoreId] !== "infortunato"),
oggi,
tipo,
(e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
},
);
}
const ORE_24 = 24 * 60 * 60 * 1000;
@@ -93,7 +102,7 @@ export function serieConferme(
giocatoreId: string,
eventi: Evento[],
tempi: MappaTempiRisposta,
oggi: string = oggiIso(),
oggi: string = dataOggi(),
): number {
return serieSu(
giocatoreId,
@@ -107,10 +116,6 @@ export function serieConferme(
);
}
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,
+1 -1
View File
@@ -46,7 +46,7 @@ export function useRosa(): Giocatore[] {
return useMemo(() => {
const medie = mediePagelle(pagelle);
const statCacche = statisticheCacche(cacche);
const palloni = conteggioTurni(turni);
const palloni = conteggioTurni(turni, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
return squadra
+9 -3
View File
@@ -6,9 +6,15 @@ 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")}`;
/**
* Data di oggi in Italia (`Europe/Rome`, con cambio ora legale/solare gestito dal database
* IANA dei fusi orari). Non i getter locali di `Date` quelli seguono il fuso del processo
* che esegue il codice: sul client di chi è in Italia coincide, ma lato server (SSR, dev
* container) è spesso UTC, e `toISOString()` lo è sempre. Sbagliare qui sposta la mezzanotte:
* nella prima ora o due della giornata italiana, eventi già passati risulterebbero "di domani".
*/
export function dataOggi(adesso: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso);
}
/** La partita in programma oggi, se c'è. */
+10 -5
View File
@@ -18,6 +18,7 @@ import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
import { Toaster } from "../components/ui/sonner";
import { TeamLogo } from "../components/crapp/ui-bits";
import { useGiocatoreBase } from "../lib/user-store";
import { useGiocatoriSquadra } from "../lib/giocatori-squadra";
import { useSessione } from "../lib/auth";
import { mantieniWorkerPushAggiornato } from "../lib/push-client";
@@ -172,6 +173,7 @@ function AppShell() {
const navigate = useNavigate();
const location = useLocation();
const giocatore = useGiocatoreBase();
const { isPending: squadraInCorso } = useGiocatoriSquadra();
const { pronta, utenteId } = useSessione();
const [mounted, setMounted] = useState(false);
const isBenvenuto = location.pathname === "/benvenuto";
@@ -180,15 +182,18 @@ function AppShell() {
// Senza sessione Google non si entra: l'identità la dà il login, non la scelta del nome
// (DD-011). Si aspetta `pronta`, altrimenti il primo render sloggato rimbalzerebbe fuori
// chi ha già la sessione in localStorage.
// chi ha già la sessione in localStorage. Si aspetta anche `squadraInCorso`: finché la
// rosa non è arrivata, `giocatore` risulta nullo anche per chi è già collegato (la rosa
// di riserva usa id diversi da quelli veri), e rimbalzerebbe su /benvenuto chi stava solo
// ricaricando una pagina profonda come /eventi.
useEffect(() => {
setMounted(true);
if (pronta && (!giocatore || !utenteId) && !isBenvenuto) {
navigate({ to: "/benvenuto" });
if (pronta && !squadraInCorso && (!giocatore || !utenteId) && !isBenvenuto) {
navigate({ to: "/benvenuto", search: { next: location.pathname } });
}
}, [giocatore, utenteId, pronta, isBenvenuto, navigate]);
}, [giocatore, utenteId, pronta, squadraInCorso, isBenvenuto, navigate, location.pathname]);
if (!mounted || !pronta) {
if (!mounted || !pronta || squadraInCorso) {
return (
<div className="grid min-h-dvh place-items-center bg-background">
<TeamLogo className="h-16 w-16 animate-pulse" />
+8 -2
View File
@@ -13,6 +13,11 @@ import {
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({
validateSearch: (search: Record<string, unknown>): { next?: string } => {
const next = search["next"];
// Solo un percorso interno: mai un URL assoluto, per non aprire un redirect esterno.
return typeof next === "string" && next.startsWith("/") ? { next } : {};
},
head: () => ({
meta: [
{ title: "Benvenuto — CrAPP" },
@@ -32,6 +37,7 @@ export const Route = createFileRoute("/benvenuto")({
function Benvenuto() {
const navigate = useNavigate();
const { next } = Route.useSearch();
const giocatore = useGiocatoreCorrente();
const { pronta, utenteId, emailUtente } = useSessione();
const { righe, daDatabase } = useGiocatoriSquadra();
@@ -45,8 +51,8 @@ function Benvenuto() {
const puoEntrare = !!giocatore && !!utenteId;
useEffect(() => {
if (puoEntrare) navigate({ to: "/" });
}, [puoEntrare, navigate]);
if (puoEntrare) navigate({ to: next ?? "/" });
}, [puoEntrare, navigate, next]);
// Chi sei lo dice lo slot collegato all'account, non quello che c'è in localStorage:
// senza slot la scelta salvata dalla vecchia selezione libera va buttata.
+29 -53
View File
@@ -9,7 +9,6 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import {
Drawer,
@@ -25,12 +24,12 @@ export const Route = createFileRoute("/calendario")({
{ title: "Calendario squadra — CrAPP" },
{
name: "description",
content: "Allenamenti, partite ed eventi extra del CRAP Volley con vista mensile e lista.",
content: "Allenamenti, partite ed eventi extra del CRAP Volley con vista mensile.",
},
{ property: "og:title", content: "Calendario squadra — CrAPP" },
{
property: "og:description",
content: "Vista mensile e lista eventi con promemoria per la squadra.",
content: "Vista mensile e prossimi eventi con promemoria per la squadra.",
},
],
}),
@@ -105,7 +104,6 @@ function pad2(n: number) {
}
function Calendario() {
const [vista, setVista] = useState<"mese" | "lista">("mese");
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
useEffect(() => {
@@ -114,7 +112,6 @@ function Calendario() {
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const { eventi } = useEventi();
const rosa = useRosa();
@@ -145,63 +142,43 @@ function Calendario() {
? (eventiPerGiorno.get(giornoSelezionato) ?? [])
: [];
// Prossimi 4 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 4) : [];
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
return (
<>
<PageHeader titolo="Calendario" sottotitolo={`${mesiIT[mese]} ${anno} · Stagione 2026/27`} />
<div className="px-5 pt-4">
<div className="flex rounded-full bg-secondary p-1">
{(["mese", "lista"] as const).map((v) => (
<Section>
<Card>
<div className="mb-3 flex items-center justify-between">
<button
key={v}
type="button"
onClick={() => setVista(v)}
aria-pressed={vista === v}
className={cn(
"min-h-11 flex-1 rounded-full text-xs font-bold uppercase tracking-wide transition-colors",
vista === v ? "bg-card shadow-card text-foreground" : "text-muted-foreground",
)}
onClick={precedente}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese precedente"
>
{v === "mese" ? "Vista mese" : "Lista eventi"}
<ChevronLeft className="h-5 w-5" />
</button>
))}
</div>
</div>
<span className="font-display-sm text-xl uppercase">
{mesiIT[mese]} {anno}
</span>
<button
type="button"
onClick={successivo}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese successivo"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
{vista === "mese" ? (
<Section titolo={mesiIT[mese]!}>
<Card>
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={precedente}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese precedente"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="font-display-sm text-xl uppercase">
{mesiIT[mese]} {anno}
</span>
<button
type="button"
onClick={successivo}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese successivo"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
{/*
Il mese si cambia anche con lo swipe: il punto d'arrivo si
decide proiettando la velocità di rilascio (come la
@@ -321,13 +298,12 @@ function Calendario() {
</div>
</Card>
</Section>
) : null}
{admin ? (
<div className="px-5 pt-4">
<Link
to="/eventi"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[14px] font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
</Link>
+1
View File
@@ -85,6 +85,7 @@ function Classifica() {
<BarraSottosezioni
defaultId={tab ?? "classifica"}
variante="sottolineatura"
riempiLarghezza
voci={[
{
id: "classifica",
+57 -2
View File
@@ -4,6 +4,15 @@ import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { formatData } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import {
@@ -50,11 +59,12 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
function GestioneEventi() {
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const { eventi, isPending } = useEventi();
const { eventi, isPending, isError, error, refetch } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento();
const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null);
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const rosa = squadra.filter((g) => g.attivo);
if (!io || !admin) {
@@ -96,6 +106,8 @@ function GestioneEventi() {
if (bozza?.id === id) setBozza(null);
} catch {
toast.error("Non sono riuscito a eliminare l'evento");
} finally {
setDaEliminare(null);
}
}
@@ -275,6 +287,20 @@ function GestioneEventi() {
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
Carico gli eventi
</p>
) : isError ? (
<div className="space-y-2 rounded-3xl bg-card p-4 text-center text-sm shadow-card">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<div className="space-y-2">
{eventi.map((e) => (
@@ -298,7 +324,7 @@ function GestioneEventi() {
</button>
<button
type="button"
onClick={() => rimuovi(e.id)}
onClick={() => setDaEliminare(e)}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
aria-label={`Elimina ${e.titolo}`}
>
@@ -309,6 +335,35 @@ function GestioneEventi() {
</div>
)}
</Section>
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Eliminare questo evento?</DrawerTitle>
<DrawerDescription>
{daEliminare ? `"${daEliminare.titolo}" — l'operazione non si può annullare.` : ""}
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<button
type="button"
onClick={() => daEliminare && rimuovi(daEliminare.id)}
disabled={elimina.isPending}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50"
>
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
+9 -5
View File
@@ -9,6 +9,7 @@ import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
@@ -38,10 +39,13 @@ export const Route = createFileRoute("/")({
function Index() {
const giocatore = useIo();
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null;
const daConfermare = prossimi.slice(1);
const daConfermare = prossimi
.slice(1)
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]);
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const { data: csi } = useCsi();
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
@@ -112,7 +116,7 @@ function Index() {
azione={
<Link
to="/calendario"
className="inline-flex items-center text-xs font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Calendario <ChevronRight className="h-4 w-4" />
</Link>
@@ -149,7 +153,7 @@ function Index() {
<Link
to="/classifica"
search={{ tab: "storico" }}
className="inline-flex items-center text-xs font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Storico <ChevronRight className="h-4 w-4" />
</Link>
@@ -208,9 +212,9 @@ function Index() {
azione={
<Link
to="/squadra"
className="inline-flex items-center text-xs font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-3 w-3" />
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
+1 -1
View File
@@ -197,7 +197,7 @@ function PartitaDetail() {
</Section>
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} />
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
{match ? (
+10 -20
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
import { Flame, Trash2, Bell, LogOut, ShieldCheck, Bug, Lightbulb } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, PageHeader, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
@@ -140,24 +140,14 @@ function Profilo() {
<Reveal className="-mt-6 px-5">
<Card>
<div className="flex flex-col items-center gap-4">
<div className="relative">
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFile}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="absolute -bottom-1 -right-1 grid h-11 w-11 place-items-center rounded-full bg-accent-grad text-accent-foreground shadow-pop"
aria-label="Cambia foto"
>
<Camera className="h-4 w-4" />
</button>
</div>
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFile}
/>
</div>
<div className="mt-4 flex gap-2">
<button
@@ -181,7 +171,7 @@ function Profilo() {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-muted-foreground"
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
+13 -26
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown, SlidersHorizontal, Trophy } from "lucide-react";
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data";
@@ -270,35 +270,22 @@ function Squadra() {
{
id: "classifica-giocatori",
label: "Classifica",
nascondiTitolo: true,
aTuttoLarghezza: true,
contenuto: (
<>
<div className="flex min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5">
<button
type="button"
onClick={() => setFiltroAperto(true)}
aria-haspopup="dialog"
className="flex w-full min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5 text-left"
>
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1">
<Select
value={criterio}
onChange={(e) => setCriterio(e.target.value as Criterio)}
aria-label="Criterio della classifica interna"
className="h-9 rounded-lg border-border bg-background font-semibold shadow-none"
>
{criteri.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</Select>
<span className="min-w-0 flex-1 truncate text-right text-sm font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<button
type="button"
onClick={() => setFiltroAperto(true)}
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground"
aria-label="Scegli criterio classifica"
>
<SlidersHorizontal className="h-4 w-4" />
</button>
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
<DrawerContent>
+63 -6
View File
@@ -5,6 +5,7 @@ import {
mediaStagione,
recordStagione,
sondaggioAperto,
sondaggioTerminato,
statisticheCacche,
type RigaCacche,
} from "@/lib/cacche";
@@ -54,11 +55,67 @@ assert.deepEqual(
);
// --- sondaggioAperto ---------------------------------------------------------
const alle = (h: number, m = 0) => new Date(2026, 8, 5, h, m); // 5 settembre 2026
assert.equal(sondaggioAperto("2026-09-05", alle(7, 59)), false, "prima delle 8 è chiuso");
assert.equal(sondaggioAperto("2026-09-05", alle(8)), true, "alle 8 in punto apre");
assert.equal(sondaggioAperto("2026-09-05", alle(23)), true);
assert.equal(sondaggioAperto("2026-09-06", alle(23)), false, "partita di domani: chiuso");
assert.equal(sondaggioAperto("2026-09-04", alle(0, 1)), true, "partita passata: resta aperto");
// Partita del 5 settembre 2026 alle 21:00.
const alle = (h: number, m = 0) => new Date(2026, 8, 5, h, m);
const ORA_PARTITA = "21:00";
assert.equal(
sondaggioAperto("2026-09-05", ORA_PARTITA, alle(7, 59)),
false,
"prima delle 8 è chiuso",
);
assert.equal(sondaggioAperto("2026-09-05", ORA_PARTITA, alle(8)), true, "alle 8 in punto apre");
assert.equal(
sondaggioAperto("2026-09-05", ORA_PARTITA, alle(20, 59)),
true,
"un minuto prima del fischio d'inizio è ancora aperto",
);
assert.equal(
sondaggioAperto("2026-09-05", ORA_PARTITA, alle(21, 0)),
false,
"al fischio d'inizio si chiude",
);
assert.equal(
sondaggioAperto("2026-09-05", ORA_PARTITA, alle(23)),
false,
"dopo l'inizio resta chiuso per il resto della giornata",
);
assert.equal(
sondaggioAperto("2026-09-06", ORA_PARTITA, alle(23)),
false,
"partita di domani: chiuso",
);
assert.equal(
sondaggioAperto("2026-09-04", ORA_PARTITA, alle(0, 1)),
false,
"partita passata: chiuso anche nei giorni successivi",
);
// --- sondaggioTerminato: distingue "non ancora aperto" da "già chiuso" -------
assert.equal(
sondaggioTerminato("2026-09-05", ORA_PARTITA, alle(7, 59)),
false,
"prima dell'apertura: non ancora aperto, non terminato",
);
assert.equal(
sondaggioTerminato("2026-09-05", ORA_PARTITA, alle(20, 59)),
false,
"in corso: non ancora terminato",
);
assert.equal(
sondaggioTerminato("2026-09-05", ORA_PARTITA, alle(21, 0)),
true,
"al fischio d'inizio: terminato",
);
assert.equal(
sondaggioTerminato("2026-09-06", ORA_PARTITA, alle(23)),
false,
"partita di domani: non ancora, non terminato",
);
assert.equal(
sondaggioTerminato("2026-09-04", ORA_PARTITA, alle(0, 1)),
true,
"partita passata: terminato",
);
console.log("cacche: ok");
+75 -6
View File
@@ -2,8 +2,27 @@
import assert from "node:assert/strict";
import { conInfortuni, contaInfortuni, contaRitardi } from "@/lib/infortuni";
import { giocatori } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import type { MappaPresenze } from "@/lib/presenze";
const ev = (id: string, data: string): Evento => ({
id,
tipo: "allenamento",
titolo: id,
luogo: "",
data,
ora: "20:00",
note: "",
convocati: [],
campionato: false,
casa: true,
pagelleChiuse: false,
});
const OGGI = "2026-09-04";
const eventi: Evento[] = [ev("e1", "2026-08-20"), ev("e2", "2026-08-27"), ev("e3", "2026-09-03")];
const presenze: MappaPresenze = {
e1: { g1: "infortunato", g2: "presente", g3: "ritardo" },
e2: { g1: "infortunato", g2: "ritardo", g3: "assente" },
@@ -11,14 +30,60 @@ const presenze: MappaPresenze = {
};
// --- conteggi ----------------------------------------------------------------
assert.deepEqual(contaInfortuni(presenze), { g1: 2 }, "ogni evento vale una volta sola");
assert.deepEqual(contaRitardi(presenze), { g3: 1, g2: 1 });
assert.deepEqual(contaInfortuni({}), {});
assert.deepEqual(contaRitardi({ e1: {} }), {}, "evento senza risposte: nessun conteggio");
assert.deepEqual(
contaInfortuni(presenze, eventi, OGGI),
{ g1: 2 },
"ogni evento passato vale una volta sola",
);
assert.deepEqual(contaRitardi(presenze, eventi, OGGI), { g3: 1, g2: 1 });
assert.deepEqual(contaInfortuni({}, eventi, OGGI), {});
assert.deepEqual(
contaRitardi({ e1: {} }, eventi, OGGI),
{},
"evento senza risposte: nessun conteggio",
);
// --- eventi futuri: non contano finché non sono passati -----------------------
const eventiConFuturo: Evento[] = [...eventi, ev("f1", "2026-09-10")];
const presenzeConFuturo: MappaPresenze = {
...presenze,
f1: { g1: "infortunato", g3: "ritardo" },
};
assert.deepEqual(
contaInfortuni(presenzeConFuturo, eventiConFuturo, OGGI),
{ g1: 2 },
"l'infortunio su un evento futuro non conta ancora",
);
assert.deepEqual(
contaRitardi(presenzeConFuturo, eventiConFuturo, OGGI),
{ g3: 1, g2: 1 },
"il ritardo su un evento futuro non conta ancora",
);
// Con l'avanzare della data, l'evento prima futuro entra nel conteggio.
assert.deepEqual(
contaInfortuni(presenzeConFuturo, eventiConFuturo, "2026-09-11"),
{ g1: 3 },
"una volta passata la data dell'evento, l'infortunio viene conteggiato",
);
assert.deepEqual(
contaRitardi(presenzeConFuturo, eventiConFuturo, "2026-09-11"),
{ g3: 2, g2: 1 },
"una volta passata la data dell'evento, il ritardo viene conteggiato",
);
// Evento non più presente in `eventi` (es. cancellato): escluso per prudenza.
assert.deepEqual(
contaInfortuni({ ...presenze, sconosciuto: { g1: "infortunato" } }, eventi, OGGI),
{ g1: 2 },
"un evento senza data nota non viene contato",
);
// --- conInfortuni: arricchisce il giocatore senza perdere gli altri campi ----
const base = giocatori.find((g) => g.id === "g1")!;
const arricchito = conInfortuni(base, contaInfortuni(presenze), contaRitardi(presenze));
const arricchito = conInfortuni(
base,
contaInfortuni(presenze, eventi, OGGI),
contaRitardi(presenze, eventi, OGGI),
);
assert.equal(arricchito.infortuni, 2);
assert.equal(arricchito.ritardi, 0, "g1 non ha ritardi");
assert.equal(arricchito.nome, base.nome, "il resto del giocatore resta intatto");
@@ -28,6 +93,10 @@ const senzaDati = conInfortuni(base, {});
assert.deepEqual([senzaDati.infortuni, senzaDati.ritardi], [0, 0], "assenza di dati = zero");
const g3 = giocatori.find((g) => g.id === "g3")!;
assert.equal(conInfortuni(g3, contaInfortuni(presenze), contaRitardi(presenze)).ritardi, 1);
assert.equal(
conInfortuni(g3, contaInfortuni(presenze, eventi, OGGI), contaRitardi(presenze, eventi, OGGI))
.ritardi,
1,
);
console.log("infortuni: ok");
+32 -6
View File
@@ -74,7 +74,8 @@ assert.equal(conAllenamentoSalvato["e2"], "g5");
const molti = Array.from({ length: giocatori.length + 2 }, (_, i) =>
evento(`x${i}`, `2026-10-${String(i + 1).padStart(2, "0")}`, "partita"),
);
const rotazione = conteggioTurni(completaTurni({}, molti, rosa));
// Tutte "passate" rispetto a questa data, altrimenti conteggioTurni le scarterebbe.
const rotazione = conteggioTurni(completaTurni({}, molti, rosa), molti, "2026-11-01");
const carichi = Object.values(rotazione);
assert.equal(
Math.max(...carichi) - Math.min(...carichi),
@@ -102,13 +103,38 @@ assert.equal(conFantasma["e1"], "gXX", "il turno storico resta com'è");
assert.ok(conFantasma["e2"], "la partita riceve comunque una proposta");
assert.equal(conFantasma["e3"], undefined, "l'allenamento senza salvataggio resta vuoto");
// --- conteggioTurni ----------------------------------------------------------
assert.deepEqual(conteggioTurni({ a: "g1", b: "g1", c: "g2" }), { g1: 2, g2: 1 });
assert.deepEqual(conteggioTurni({}), {});
// --- conteggioTurni: solo eventi già passati ----------------------------------
const eventiConteggio: Evento[] = [
evento("c1", "2026-09-01", "partita"),
evento("c2", "2026-09-05", "partita"),
evento("c3", "2026-09-10", "allenamento"), // futuro rispetto a OGGI_CONTEGGIO
];
const OGGI_CONTEGGIO = "2026-09-08";
assert.deepEqual(
conteggioTurni({ c1: "g1", c2: "g1", c3: "g2" }, eventiConteggio, OGGI_CONTEGGIO),
{ g1: 2 },
"il turno di un evento futuro non è ancora contato, anche se già assegnato",
);
assert.deepEqual(
conteggioTurni({}, eventiConteggio, OGGI_CONTEGGIO),
{},
"nessun turno assegnato: conteggio vuoto",
);
// --- oggiISO -----------------------------------------------------------------
// --- oggiISO -------------------------------------------------------------------
assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/);
assert.equal(oggiISO(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC");
// Stesso controllo di dataOggi() in scout-live.test.ts: oggiISO() ne è un alias, il fuso
// deve restare Europe/Rome anche passando da qui.
assert.equal(
oggiISO(new Date("2026-01-15T23:30:00Z")),
"2026-01-16",
"CET: mezzanotte italiana precede quella UTC di un'ora",
);
assert.equal(
oggiISO(new Date("2026-07-15T22:30:00Z")),
"2026-07-16",
"CEST: il cambio ora legale porta lo scarto a due ore, non resta fisso a uno",
);
// --- destinatariPromemoriaPalloni --------------------------------------------
const eventiPush: Evento[] = [
+22
View File
@@ -62,6 +62,28 @@ assert.equal(serieConsecutiva("g2", eventi, presenze, "allenamento", OGGI), 0);
const conConvocati = [...eventi, ev("a6", "allenamento", "2026-08-30", ["g9"])];
assert.equal(serieConsecutiva("g1", conConvocati, presenze, "allenamento", OGGI), 2);
// L'infortunio congela la serie invece di azzerarla come farebbe un'assenza.
const eventiInfortunio: Evento[] = [
ev("i1", "allenamento", "2026-08-06"),
ev("i2", "allenamento", "2026-08-13"),
ev("i3", "allenamento", "2026-08-20"),
];
const presenzeInfortunio: MappaPresenze = {
i1: { g1: "presente" },
i2: { g1: "infortunato" },
i3: { g1: "presente" },
};
assert.equal(
serieConsecutiva("g1", eventiInfortunio, presenzeInfortunio, "allenamento", OGGI),
2,
"l'infortunio è saltato: i1 e i3 restano consecutivi",
);
assert.equal(
serieConsecutiva("g1", eventiInfortunio.slice(0, 2), presenzeInfortunio, "allenamento", OGGI),
1,
"l'infortunio come ultimo evento lascia la serie congelata, non la azzera",
);
// --- conteggio presenze: numeratore e denominatore delle statistiche ----------
// a1 presente, a2 assente, a3 ritardo, a4 presente, p1 presente: 4 su 5 passati.
assert.equal(contaPresenzeGiocatore("g1", eventi, presenze, OGGI), 4, "il ritardo conta presente");
+16 -2
View File
@@ -23,9 +23,23 @@ const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({
pagelleChiuse: false,
});
// --- dataOggi ----------------------------------------------------------------
// --- dataOggi ------------------------------------------------------------------
assert.match(dataOggi(), /^\d{4}-\d{2}-\d{2}$/);
assert.equal(dataOggi(), new Date().toLocaleDateString("sv-SE"), "data locale, non UTC");
// CET (gennaio, UTC+1): dopo le 23:00 UTC del 15, a Roma è già il 16. L'ISO in UTC
// direbbe ancora 15: se `dataOggi()` tornasse a farlo, questo test lo scoprirebbe.
assert.equal(
dataOggi(new Date("2026-01-15T23:30:00Z")),
"2026-01-16",
"CET: mezzanotte italiana precede quella UTC di un'ora",
);
// CEST (luglio, UTC+2): lo scarto raddoppia, la mezzanotte italiana anticipa quella UTC
// di due ore. Se il calcolo usasse un offset fisso invece del fuso Europe/Rome, questo
// secondo caso lo tradirebbe anche se il primo passasse per caso.
assert.equal(
dataOggi(new Date("2026-07-15T22:30:00Z")),
"2026-07-16",
"CEST: il cambio ora legale porta lo scarto a due ore, non resta fisso a uno",
);
// --- partitaDiOggi -----------------------------------------------------------
const eventi = [