Riduce il lag su Android: tab memoizzate, vetro/blur/drag disattivati sui device deboli

- squadra.tsx e classifica.tsx: il contenuto di ogni tab di BarraSottosezioni è ora
  memoizzato con useMemo, cosi' uno stato locale o un refetch in background non
  ricalcolano piu' anche le sezioni nascoste.
- BottomNav, BarraSottosezioni, calendario: useMotoRidotto (RAM bassa o
  prefers-reduced-motion) disattiva sui device deboli la catena di filtri SVG piu'
  pesante di .vetro, il backdrop-blur della barra tab e il drag orizzontale;
  su iOS e device performanti resta tutto invariato.
- styles.css: rimossa l'utility .materiale, orfana da quando BottomNav e' passata
  a .vetro.
- ui-bits.tsx: decoding="async" su TeamLogo.
- todo.md: traccia le cause individuate e lo stato di applicazione di ciascuna.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 09:39:40 +02:00
co-authored by Claude Sonnet 5
parent fc65914f55
commit 08b00627e5
8 changed files with 664 additions and 582 deletions
+12 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = {
id: string;
@@ -38,7 +39,11 @@ export function BarraSottosezioni({
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
// sottosezioni (Squadra, Campionato, Profilo).
const ridotto = useMotoRidotto();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
@@ -69,7 +74,8 @@ export function BarraSottosezioni({
<div
className={cn(
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
!sottolineatura &&
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
@@ -136,7 +142,9 @@ export function BarraSottosezioni({
key={voce.id}
role="tabpanel"
aria-label={voce.label}
drag="x"
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
dragMomentum={false}
+13 -3
View File
@@ -1,7 +1,8 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { molla } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
// Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -13,7 +14,10 @@ const items = [
] as const;
export function BottomNav() {
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
const ridotto = useMotoRidotto();
return (
<nav
@@ -62,7 +66,13 @@ export function BottomNav() {
*/}
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
<div
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
{...(ridotto ? { "data-leggero": "" } : {})}
>
{items.map(({ to, label, icon: Icon }) => (
<Link
key={to}
+1
View File
@@ -25,6 +25,7 @@ export function TeamLogo({
alt="CRAP Volley"
width={192}
height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
+6 -3
View File
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
@@ -115,7 +116,9 @@ function Calendario() {
const admin = useIsAdmin();
const { eventi } = useEventi();
const rosa = useRosa();
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto();
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
@@ -197,7 +200,7 @@ function Calendario() {
<motion.div
key={mesePrefix}
custom={direzione}
drag="x"
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
+159 -152
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -48,32 +49,166 @@ function Classifica() {
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const { eventi } = useEventi();
const classifica = csi?.classifica ?? [];
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
const eventoIdPerData = new Map(
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
const eventoIdPerData = useMemo(
() => new Map(eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id])),
[eventi],
);
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const tuttiMatch = csiGiocate.length
? csiGiocate.map((p) => {
const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false };
})
: scoutMatches.map((m) => ({
id: m.id,
data: m.data,
avversario: m.avversario,
casa: m.casa,
setNostri: m.setNostri,
setLoro: m.setLoro,
parziali: m.parziali,
mvp: mvpPerData(m.data),
scout: true,
}));
const tuttiMatch = useMemo(() => {
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
return csiGiocate.length
? csiGiocate.map((p) => {
const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false };
})
: scoutMatches.map((m) => ({
id: m.id,
data: m.data,
avversario: m.avversario,
casa: m.casa,
setNostri: m.setNostri,
setLoro: m.setLoro,
parziali: m.parziali,
mvp: mvpPerData(m.data),
scout: true,
}));
}, [csi, scoutMatches, mvpPerMatch, eventoIdPerData]);
// Ogni tab è memoizzata: un refetch in background di uno solo dei dati (CSI,
// scout, eventi, voti MVP) non deve ricostruire il JSX dell'altra tab.
const contenutoClassifica = useMemo(
() => (
<>
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5 text-accent" />
{csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
<span>#</span>
<span>Squadra</span>
<span className="text-center">G</span>
<span className="text-center">Set</span>
<span className="text-center">Pt</span>
</div>
{classifica.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
Classifica non ancora disponibile.
</p>
) : (
classifica.map((r) => {
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
return (
<div
key={r.pos}
className={cn(
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
noi && "bg-accent/10",
)}
>
<span className={cn("font-display text-base", noi && "text-accent")}>
{r.pos}
</span>
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
{r.squadra}
</span>
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
<span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti}
</span>
<span className="text-center font-bold tabular-nums">{r.punti}</span>
</div>
);
})
)}
</div>
</>
),
[csi, classifica],
);
const contenutoStorico = useMemo(
() =>
tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const contenuto = (
<>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
{m.casa ? m.avversario : "CRAP Volley"}
</p>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"rounded-xl px-2.5 py-1 font-display text-lg",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{m.setNostri}-{m.setLoro}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{p[0]}-{p[1]}
</span>
))}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
</span>
) : null}
</div>
</>
);
return eventoId ? (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
>
{contenuto}
</Link>
) : (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
);
})}
</div>
),
[tuttiMatch, eventoIdPerData],
);
return (
<>
@@ -87,136 +222,8 @@ function Classifica() {
variante="sottolineatura"
riempiLarghezza
voci={[
{
id: "classifica",
label: "Classifica",
contenuto: (
<>
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5 text-accent" />
{csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
<span>#</span>
<span>Squadra</span>
<span className="text-center">G</span>
<span className="text-center">Set</span>
<span className="text-center">Pt</span>
</div>
{classifica.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
Classifica non ancora disponibile.
</p>
) : (
classifica.map((r) => {
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
return (
<div
key={r.pos}
className={cn(
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
noi && "bg-accent/10",
)}
>
<span className={cn("font-display text-base", noi && "text-accent")}>
{r.pos}
</span>
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
{r.squadra}
</span>
<span className="text-center text-xs text-muted-foreground">
{r.giocate}
</span>
<span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti}
</span>
<span className="text-center font-bold tabular-nums">{r.punti}</span>
</div>
);
})
)}
</div>
</>
),
},
{
id: "storico",
label: "Storico partite",
contenuto:
tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const contenuto = (
<>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
{m.casa ? m.avversario : "CRAP Volley"}
</p>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"rounded-xl px-2.5 py-1 font-display text-lg",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{m.setNostri}-{m.setLoro}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{p[0]}-{p[1]}
</span>
))}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
</span>
) : null}
</div>
</>
);
return eventoId ? (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
>
{contenuto}
</Link>
) : (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
);
})}
</div>
),
},
{ id: "classifica", label: "Classifica", contenuto: contenutoClassifica },
{ id: "storico", label: "Storico partite", contenuto: contenutoStorico },
]}
/>
</>
+335 -362
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -103,6 +103,335 @@ function Squadra() {
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
: 0;
// Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
// non deve ricalcolare badge, classifica e obiettivi delle altre sezioni,
// che restano fuori schermo finché l'utente non cambia tab.
const contenutoRosa = useMemo(
() => (
<div className="space-y-2">
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={() => setAperto(isOpen ? null : g.id)}
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen}
>
<Avatar
id={g.id}
fallback={g.numero ? String(g.numero) : g.iniziali}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
</span>
{sbloccati.length > 0 ? (
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
{sbloccati.map((b) => {
const Icon = b.def.icon;
return (
<span key={b.def.id} title={`${b.def.nome}${gradoMeta[b.grado!].label}`}>
<Icon
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
aria-hidden="true"
/>
<span className="sr-only">
{b.def.nome}: {gradoMeta[b.grado!].label}
</span>
</span>
);
})}
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
),
[rosa, aperto, mese],
);
const contenutoStats = useMemo(
() => (
<div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" />
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
<StatTile valore={team.punti} label="Punti squadra" />
<StatTile valore={team.ace} label="Ace squadra" />
<StatTile valore={team.muri} label="Muri squadra" />
</div>
),
[mediaPresenze, matchGiocati, pagelle, team],
);
const contenutoClassifica = useMemo(
() => (
<>
<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 truncate text-right text-sm font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
)}
>
{c.label}
</button>
</DrawerClose>
))}
</div>
</DrawerContent>
</Drawer>
<div className="space-y-2 px-5 pt-3">
{ordinati.map((g, i) => (
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
{i + 1}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{g.nome}
{i === 0 ? <Crown className="ml-1 inline h-3.5 w-3.5 text-warning" /> : null}
</p>
<p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
</p>
</div>
</div>
<span className="font-display text-xl tabular-nums">
{valore(g, criterio) || "—"}
</span>
</div>
<Barra
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
altezza="h-1.5"
trackClassName="mt-2"
/>
</div>
))}
</div>
</>
),
[filtroAperto, criterio, ordinati, max],
);
const contenutoObiettivi = useMemo(
() => (
<>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
Progresso collettivo
</p>
<p className="font-display text-4xl leading-none">
<Numero valore={mediaObiettivi} suffisso="%" />
</p>
</div>
<p className="text-xs text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
</div>
<div className="space-y-2">
{obiettivi.map((o, i) => {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal
key={o.id}
indice={i}
className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
)}
>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal>
);
})}
</div>
</>
),
[obiettivi, mediaObiettivi, completati],
);
const contenutoBadge = useMemo(
() => (
<div className="space-y-2">
{badgeDefs.map((b) => {
const Icon = b.icon;
return (
<BadgeDrawer key={b.id} def={b}>
<div className="rounded-3xl bg-card p-3 shadow-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
const meta = gradoMeta[grado];
const quanti = rosa.filter((g) => {
const raggiunto = gradoRaggiunto(b, b.valore(g));
return raggiunto
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
: false;
}).length;
return (
<div
key={grado}
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
>
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
);
})}
</div>
</div>
</BadgeDrawer>
);
})}
</div>
),
[rosa],
);
return (
<>
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
@@ -111,372 +440,16 @@ function Squadra() {
defaultId="rosa"
variante="sottolineatura"
voci={[
{
id: "rosa",
label: "Rosa",
contenuto: (
<div className="space-y-2">
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [
...stati.filter((b) => b.grado !== null),
...badgeSegretiSbloccati(g),
];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={() => setAperto(isOpen ? null : g.id)}
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen}
>
<Avatar
id={g.id}
fallback={g.numero ? String(g.numero) : g.iniziali}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{g.nome}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
</span>
{sbloccati.length > 0 ? (
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
{sbloccati.map((b) => {
const Icon = b.def.icon;
return (
<span
key={b.def.id}
title={`${b.def.nome}${gradoMeta[b.grado!].label}`}
>
<Icon
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
aria-hidden="true"
/>
<span className="sr-only">
{b.def.nome}: {gradoMeta[b.grado!].label}
</span>
</span>
);
})}
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/
{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p
className={cn(
"mt-1.5 text-xs font-bold uppercase",
meta.text,
)}
>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
),
},
{
id: "stats",
label: "Statistiche",
contenuto: (
<div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" />
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
<StatTile valore={team.punti} label="Punti squadra" />
<StatTile valore={team.ace} label="Ace squadra" />
<StatTile valore={team.muri} label="Muri squadra" />
</div>
),
},
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Statistiche", contenuto: contenutoStats },
{
id: "classifica-giocatori",
label: "Classifica",
aTuttoLarghezza: true,
contenuto: (
<>
<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 truncate text-right text-sm font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
)}
>
{c.label}
</button>
</DrawerClose>
))}
</div>
</DrawerContent>
</Drawer>
<div className="space-y-2 px-5 pt-3">
{ordinati.map((g, i) => (
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
{i + 1}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{g.nome}
{i === 0 ? (
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
</p>
</div>
</div>
<span className="font-display text-xl tabular-nums">
{valore(g, criterio) || "—"}
</span>
</div>
<Barra
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
altezza="h-1.5"
trackClassName="mt-2"
/>
</div>
))}
</div>
</>
),
},
{
id: "obiettivi",
label: "Obiettivi",
contenuto: (
<>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
Progresso collettivo
</p>
<p className="font-display text-4xl leading-none">
<Numero valore={mediaObiettivi} suffisso="%" />
</p>
</div>
<p className="text-xs text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
<Barra
percentuale={mediaObiettivi}
trackClassName="mt-3 bg-primary-foreground/15"
/>
</div>
<div className="space-y-2">
{obiettivi.map((o, i) => {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal
key={o.id}
indice={i}
className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto
? "ring-success/40"
: pct >= 90
? "ring-accent/40"
: "ring-transparent",
)}
>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(o)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal>
);
})}
</div>
</>
),
},
{
id: "badge",
label: "Badge",
contenuto: (
<div className="space-y-2">
{badgeDefs.map((b) => {
const Icon = b.icon;
return (
<BadgeDrawer key={b.id} def={b}>
<div className="rounded-3xl bg-card p-3 shadow-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">
{descrizioneSoglie(b)}
</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
const meta = gradoMeta[grado];
const quanti = rosa.filter((g) => {
const raggiunto = gradoRaggiunto(b, b.valore(g));
return raggiunto
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
: false;
}).length;
return (
<div
key={grado}
className={cn(
"rounded-2xl p-2 text-center ring-1",
meta.bg,
meta.ring,
)}
>
<p className={cn("text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
<p className="font-display text-lg leading-none">
{b.soglie[grado]}
</p>
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
);
})}
</div>
</div>
</BadgeDrawer>
);
})}
</div>
),
contenuto: contenutoClassifica,
},
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
]}
/>
</>
+20 -58
View File
@@ -392,55 +392,14 @@
}
}
/* Superfici traslucide: se l'utente chiede meno trasparenza diventano piene. */
@media (prefers-reduced-transparency: reduce) {
.materiale {
background-color: var(--color-card);
backdrop-filter: none;
}
}
@media (prefers-contrast: more) {
.materiale {
background-color: var(--color-card);
backdrop-filter: none;
border-color: var(--color-foreground);
}
}
/*
* Chrome flottante in stile Apple: la barra è un materiale traslucido sotto
* cui il contenuto scorre, con una sfumatura al bordo al posto della linea
* netta (scroll edge effect).
*/
/*
* Il materiale deve reggersi sul colore, non sul blur.
* Vetro della barra flottante.
*
* Con un fondo poco denso (era 72%) l'aspetto dipendeva quasi tutto da
* `backdrop-filter`, ed è lì che i due motori divergono: WebKit sfoca in modo
* molto più denso e "latteo" di Blink, e `saturate` amplifica lo scarto. Il
* risultato era una barra che su Safari sembrava piena e su Chrome Android
* trasparente, a parità di CSS. A 85% il colore fa il lavoro e il blur è la
* rifinitura: resta la profondità, sparisce quasi tutta la differenza.
*
* `in srgb` e non `in oklab`: mescolare con `transparent` — che è nero con
* alpha 0 — dipende da come il motore premoltiplica, e in srgb è implementato
* in modo molto più uniforme.
*/
@utility materiale {
background-color: color-mix(in srgb, var(--color-card) 85%, transparent);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
}
/*
* Vetro della barra flottante. È `materiale` spinto: si vede attraverso.
*
* `materiale` sta all'85% di card perché è una superficie larga quanto lo
* schermo, dove il divario di blur fra WebKit e Blink si nota (608f2ed). Qui
* la superficie è una pillola piccola: si può scendere al 62% e lasciare che
* sia il blur a fare il lavoro, che è la condizione perché si legga come vetro
* invece che come plastica bianca.
* Su una superficie larga quanto lo schermo un fondo all'85% di card serve a
* tenere allineati WebKit e Blink, che sfocano in modo diverso (608f2ed); su
* una pillola piccola come questa lo scarto non si legge, quindi si scende al
* 62% e si lascia che sia il blur a fare il lavoro, condizione perché si
* legga come vetro invece che come plastica bianca.
*
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
@@ -475,6 +434,20 @@
}
}
/*
* `data-leggero` lo mette `BottomNav` quando `useMotoRidotto` rileva un device
* debole (RAM bassa) o `prefers-reduced-motion`: la catena di filtri SVG sopra
* (feTurbulence + feDisplacementMap, solo Blink/Android) è il costo maggiore
* della barra fissa ad ogni cambio di rotta, quindi qui si torna al semplice
* sfondo pieno. Specificità più alta di `.vetro` da sola: vince a prescindere
* dall'ordine nel file.
*/
.vetro[data-leggero] {
background-color: var(--color-card);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.vetro {
@@ -490,17 +463,6 @@
}
}
/*
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
* hardware non è disponibile: senza questo, su quei device la barra resta
* solo semitrasparente e il contenuto si legge attraverso.
*/
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.materiale {
background-color: var(--color-card);
}
}
/** Padding inferiore della chrome flottante. */
@utility pad-sicura-fondo {
padding-bottom: var(--pad-sicura-fondo);
+118
View File
@@ -0,0 +1,118 @@
# TODO — Lag su Android (analisi Squadra + BarraSottosezioni)
Contesto: lag segnalato su Android nella pagina Squadra (`src/routes/squadra.tsx`) e nel
componente `BarraSottosezioni` (`src/components/crapp/BarraSottosezioni.tsx`). Elenco delle
cause individuate, in ordine di probabile impatto, con lo stato di applicazione.
## 1. [x] Ricalcolo di tutte le tab ad ogni render — APPLICATA
`voci` in `Squadra()` costruiva il JSX di tutte e 5 le sezioni (Rosa, Statistiche, Classifica,
Obiettivi, Badge) a ogni render, incluse quelle non visibili — inclusi i calcoli per giocatore
(`badgeGiocatore`, `badgeSegretiSbloccati`, `collezioneBadge`) e l'ordinamento della classifica.
Un semplice `setAperto` (aprire una card giocatore) rifaceva tutto questo lavoro per niente.
Fix: memoizzato il contenuto di ogni tab con `useMemo`, così il lavoro di ciascuna sezione si
rifà solo quando cambiano i suoi dati effettivi, non ad ogni render del componente.
Nessun trade-off: puro guadagno di performance, identico su Android e iOS.
## 2. [x] `backdrop-blur-md` sulla barra tab sticky — APPLICATA
`BarraSottosezioni.tsx` (variante "pillole", solo Profilo — Squadra/Campionato usano
"sottolineatura" e non avevano blur qui). Costoso da compositare su Android Chrome/WebView,
specialmente con la barra visibile durante lo scroll del pannello sottostante. Su iOS Safari il
costo è molto più basso (accelerazione via Core Animation).
Fix: `BarraSottosezioni` ora usa `useMotoRidotto` (stesso heuristic di RAM bassa /
`prefers-reduced-motion` già introdotto per `BottomNav`, punto 6) al posto del solo
`useReducedMotion`. Sui device deboli il blur sparisce (sfondo pieno `bg-background`); su iOS e
device performanti resta identico a prima.
Presente anche in `CelebrazioneBadge.tsx:33` (`backdrop-blur-sm`), non toccato: non è sticky e
compare solo alla celebrazione, impatto marginale.
## 3. [x] Gesture `drag="x"` di Framer Motion sul pannello swipeabile — APPLICATA
`BarraSottosezioni.tsx` e stesso pattern in `calendario.tsx` (swipe tra mesi). Ogni `pointermove`
passa dal thread JS invece di essere gestito nativamente; su Android più pesante da instradare,
specialmente con `touch-pan-y` + `drag="x"` insieme su contenuto lungo che scrolla in verticale.
Fix: `drag` è ora `ridotto ? false : "x"` in entrambi i file, stesso `useMotoRidotto` del punto
2. Sui device deboli lo swipe orizzontale (cambio tab / cambio mese) si disattiva e resta solo
il tocco diretto sulla barra/frecce; su iOS e device performanti nulla cambia.
## 4. [ ] Coriandoli via `canvas-confetti` non sempre filtrati sui device deboli
`lib/motion.ts:14-17,29-40`. Già protetti da `prefers-reduced-motion` e da
`deviceMemory < 2GB`, ma molti Android di fascia media dichiarano 3-4GB pur avendo GPU deboli:
il check attuale non li intercetta. Impatto marginale (animazione una tantum), ma coerente con
i punti 2 e 3 se si introduce un heuristic più ampio (es. anche `hardwareConcurrency`).
## 5. [ ] Nessuna virtualizzazione sulle liste lunghe (rosa, obiettivi, badge)
Non specifico di Android, ma ogni card ha due box-shadow sovrapposte (`shadow-card`, vedi
`styles.css:160-171`) e gli `obiettivi` montano un `IntersectionObserver` per elemento
(`Reveal`). Con liste lunghe il costo si somma di più su CPU/GPU Android debole.
Da valutare solo se la rosa/obiettivi crescono molto: oggi la complessità aggiunta
(virtualizzazione) non sembra giustificata dal beneficio.
## 6. [x] Lag nel cambio pagina (Home ↔ Calendario ↔ Squadra ↔ Campionato) — APPLICATA
`BottomNav` resta montata su ogni rotta ([__root.tsx:204-210](src/routes/__root.tsx#L204-L210))
e concentrava due costi proprio nel momento del cambio pagina:
- `layoutId="capsula-nav"` di Framer Motion ([BottomNav.tsx](src/components/crapp/BottomNav.tsx))
anima la pillola attiva con una misurazione di layout sincrona (FLIP) ad ogni navigazione.
- Il "vetro" della barra usa, solo su Blink/Android (`@supports (backdrop-filter: url(...))`),
una catena di filtri SVG `feTurbulence` + `feGaussianBlur` + `feDisplacementMap` molto più
pesante del semplice `blur()` che riceve iOS Safari — e ricalcola ad ogni cambio di contenuto
sotto la barra fissa.
Fix: `BottomNav` ora usa `useMotoRidotto` (già in `lib/motion.ts`, copre sia
`prefers-reduced-motion` sia RAM bassa) al posto del solo `useReducedMotion` di Framer Motion.
Quando rileva un device debole, aggiunge `data-leggero` al contenitore `.vetro`; una nuova
regola in `styles.css` disattiva backdrop-filter su quell'attributo (fallback a sfondo pieno,
stesso trattamento già riservato a `prefers-reduced-transparency`). La molla della capsula era
già condizionata alla stessa variabile `ridotto`, quindi ora si disattiva anche lei sui device
deboli, non solo con `prefers-reduced-motion`.
Nessun impatto su iOS/device performanti: lì `ridotto` resta `false` e l'effetto vetro + la
capsula animata restano identici a prima.
## 7. [x] `classifica.tsx` (Campionato) ricalcolava entrambe le tab ad ogni render — APPLICATA
Stesso pattern di `squadra.tsx` (punto 1): `BarraSottosezioni` con le tab "Classifica" e
"Storico partite" costruiva il JSX di entrambe ad ogni render, incluso il merge tra partite CSI
e scout e il lookup MVP per data. Con `useCsi`/`useEventi`/`useVotiMvp` basati su TanStack Query,
un refetch in background di uno solo di questi dati rifaceva comunque il lavoro di tutt'e due
le tab. Meno grave del caso Squadra (qui non c'è uno stato locale tipo `aperto` che rirenderizza
il componente ad ogni tap), ma stesso spreco ad ogni mount/refetch della pagina.
Fix: stesso trattamento — `classifica`, `tuttiMatch`, `eventoIdPerData` e `mvpPerMatch` sono ora
memoizzati con `useMemo`, e il contenuto di ciascuna tab è a sua volta memoizzato sulle relative
dipendenze.
`index.tsx` (Home) non ha lo stesso problema: non usa `BarraSottosezioni`/tab multiple, quindi
non c'è contenuto "nascosto" che viene ricalcolato inutilmente — non modificata.
## 8. [x] Rifiniture minori — APPLICATA
- `TeamLogo` ([ui-bits.tsx](src/components/crapp/ui-bits.tsx)) non aveva `decoding="async"`
(a differenza di `Avatar`): aggiunto, guadagno marginale ma gratuito.
- `.materiale` in `styles.css` (backdrop-filter 20px + 3 fallback di accessibilità) non era più
referenziata da nessun componente — sostituita da `.vetro` sul `BottomNav` in un commit
precedente (c089d9e) e da allora orfana. Rimossa insieme ai suoi fallback; il commento che la
citava come confronto per `.vetro` è stato aggiornato.
## Note
- `useMotoRidotto` (`lib/motion.ts`) è ora l'heuristic condiviso di "device debole" usato in
`BottomNav.tsx`, `BarraSottosezioni.tsx` e `calendario.tsx` (punti 2, 3, 6). Se si riprende il
punto 4 (coriandoli), conviene usare lo stesso hook invece di un check separato.
- Applicati: 1, 2, 3, 6, 7. Restano da discutere/prioritizzare: 4 (coriandoli su device medi),
5 (virtualizzazione liste lunghe).
- Se il lag persistesse ancora dopo questi fix, il prossimo passo è profilare un device Android
reale (Chrome DevTools remoto o `chrome://inspect`) invece di continuare a ipotizzare: a
questo punto le cause "ovvie" lette dal codice sono coperte, e senza un trace reale si rischia
di ottimizzare a caso.