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>
This commit is contained in:
Ivan Cacciari
2026-09-07 10:58:57 +02:00
co-authored by Cursor
parent c44fd8495e
commit a6799b91d3
4 changed files with 156 additions and 88 deletions
+2 -1
View File
@@ -38,7 +38,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 - Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio. inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato" - 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. - 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 - `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato. nessuna parte del codice attuale — probabile residuo non collegato.
+6 -2
View File
@@ -2,7 +2,7 @@
**Stato:** implementato (v1.0) **Stato:** implementato (v1.0)
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`, **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`, Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle `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 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) ↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx allenamento.$id.tsx / partita.$id.tsx
EventoCard → src/components/crapp/EventoCard.tsx
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
--- statistiche --- --- statistiche ---
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts usePresenzeUltimoMese() → src/lib/presenze-mese.ts
+146 -83
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 { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { Barra } from "@/components/motion/Barra";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store"; import { useGiocatoreCorrente } from "@/lib/user-store";
@@ -17,18 +26,40 @@ const tipoMeta = {
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" }, compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
} as const; } 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) { export function linkPerEvento(e: Evento) {
if (e.tipo === "partita") { if (e.tipo === "partita") {
return { to: "/partita/$id", params: { id: e.id }, label: "Dettagli" }; return { to: "/partita/$id" as const, params: { id: e.id }, label: "Dettaglio partita" };
} }
if (e.tipo === "allenamento") { if (e.tipo === "allenamento") {
return { to: "/allenamento/$id", params: { id: e.id }, label: "Dettagli" }; return {
to: "/allenamento/$id" as const,
params: { id: e.id },
label: "Dettaglio allenamento",
};
} }
return undefined; 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({ export function EventoCard({
evento, evento,
linkTo, linkTo,
@@ -50,39 +81,17 @@ export function EventoCard({
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" } ? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
: tipoMeta[evento.tipo]; : tipoMeta[evento.tipo];
const totale = rosa.length; const totale = rosa.length;
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
const isCompleanno = evento.tipo === "compleanno"; const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi(); const passato = evento.data < dataOggi();
const note = evento.note.trim();
function ChipPresenza({ s }: { s: Stato }) { const cliccabile = Boolean(linkTo);
const meta = statoMeta[s]; const stati =
const attivo = stato === s; evento.tipo === "evento"
return ( ? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
<button stato === "infortunato"
type="button" ? [...statiEventoExtra, "infortunato" as const]
disabled={!io || salva.isPending || passato} : statiEventoExtra
onClick={() => { : statiSportivi;
if (!io) return;
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>
);
}
if (isCompleanno) { if (isCompleanno) {
return ( return (
@@ -94,20 +103,38 @@ export function EventoCard({
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3> <h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p> <p className="text-xs text-muted-foreground">{evento.luogo}</p>
</div> </div>
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center"> <div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p> {etichettaData(evento.data)}
<p className="text-xs font-semibold uppercase text-muted-foreground">
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
</p>
</div> </div>
</Card> </Card>
); );
} }
const sottotitoloPartita =
evento.tipo === "partita"
? `${evento.casa ? "Casa" : "Trasferta"}${evento.campionato ? " · Campionato" : " · Amichevole"}`
: null;
return ( return (
<Card> <Card
<div className="flex items-start justify-between gap-3"> as="article"
<div className="min-w-0"> 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 <span
className={cn( className={cn(
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide", "inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
@@ -116,49 +143,85 @@ export function EventoCard({
> >
{tipo.label} {tipo.label}
</span> </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>
<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> <h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs font-semibold uppercase text-muted-foreground"> {sottotitoloPartita ? (
{formatData(evento.data).split(" ")[2]?.slice(0, 3)} <p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
</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> </div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> {io ? (
<span className="inline-flex items-center gap-1"> <div
<Clock className="h-3.5 w-3.5" /> {evento.ora} className="pointer-events-auto mt-3 flex w-full gap-1"
</span> role="group"
<span className="inline-flex min-w-0 items-center gap-1"> aria-label="La tua presenza"
<MapPin className="h-3.5 w-3.5 shrink-0" /> >
<span className="truncate">{evento.luogo}</span> {stati.map((s) => {
</span> const meta = statoMeta[s];
<span className="inline-flex items-center gap-1"> const Icon = iconeStato[s];
<Users className="h-3.5 w-3.5" /> {presentiVeri}/{totale} const attivo = stato === s;
</span> const mostraTesto = s === "presente" && attivo;
</div> return (
<button
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" /> key={s}
type="button"
{io || linkTo ? ( disabled={salva.isPending || passato}
<div className="mt-4 flex flex-wrap gap-2"> onClick={(e) => {
{io ? stati.slice(0, -1).map((s) => <ChipPresenza key={s} s={s} />) : null} e.preventDefault();
{/* Ultima riga: ultimo chip + Apri insieme, così non nasce una terza riga. */} e.stopPropagation();
<div className="flex w-full basis-full items-center gap-2"> salva.mutate({
{io ? <ChipPresenza s={stati[stati.length - 1]!} /> : null} eventoId: evento.id,
{linkTo ? ( giocatoreId: io.id,
<Link stato: attivo ? null : s,
to={linkTo.to} });
params={linkTo.params} }}
className="ml-auto 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" aria-pressed={attivo}
> aria-label={meta.label}
{linkTo.label} <ArrowRight className="h-3 w-3" /> title={meta.label}
</Link> className={cn(
) : null} "inline-flex min-h-11 min-w-0 flex-1 items-center justify-center gap-1 rounded-xl px-1 text-xs font-semibold 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 />
{mostraTesto ? (
<span className="truncate">{meta.label}</span>
) : (
<span className="sr-only">{meta.label}</span>
)}
</button>
);
})}
</div> </div>
</div> ) : null}
) : null}
{note ? (
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {note}</p>
) : null}
</div>
</Card> </Card>
); );
} }
+2 -2
View File
@@ -53,14 +53,14 @@ export function Card({
*/ */
export function LinkProfilo() { export function LinkProfilo() {
const g = useIo(); const g = useIo();
if (!g) return <TeamLogo className="h-11 w-11" />; if (!g) return <TeamLogo className="h-12 w-12" />;
return ( return (
<Link <Link
to="/profilo" to="/profilo"
aria-label="Il tuo profilo" aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30" 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> </Link>
); );
} }