Compare commits
22
Commits
ea9f17e8d2
...
fc65914f55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc65914f55 | ||
|
|
8e23f31685 | ||
|
|
f5aaa1cccb | ||
|
|
b4878b7795 | ||
|
|
9cc63b7854 | ||
|
|
e7b5082398 | ||
|
|
446403cdf2 | ||
|
|
a7ae9ed3a4 | ||
|
|
ed289b9ecd | ||
|
|
b92a167b5a | ||
|
|
a06c5ed281 | ||
|
|
10915d04fc | ||
|
|
a459579f8b | ||
|
|
a6799b91d3 | ||
|
|
c44fd8495e | ||
|
|
7ef7eef963 | ||
|
|
8cd0f1dc88 | ||
|
|
b26d382c11 | ||
|
|
9e2f83a85e | ||
|
|
a1d2599c47 | ||
|
|
ca1eee5425 | ||
|
|
6bc5e8f006 |
+3
-3
@@ -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ì
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` né
|
||||
`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".
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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: l’etichetta è 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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
* né come presenza né 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -85,6 +85,7 @@ function Classifica() {
|
||||
<BarraSottosezioni
|
||||
defaultId={tab ?? "classifica"}
|
||||
variante="sottolineatura"
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{
|
||||
id: "classifica",
|
||||
|
||||
+57
-2
@@ -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,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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user