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 { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react"; import { motion } from "motion/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla"; import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = { export type VoceSottosezione = {
id: string; id: string;
@@ -38,7 +39,11 @@ export function BarraSottosezioni({
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? ""); const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0); const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({}); 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( const indice = Math.max(
0, 0,
voci.findIndex((v) => v.id === attiva), voci.findIndex((v) => v.id === attiva),
@@ -69,7 +74,8 @@ export function BarraSottosezioni({
<div <div
className={cn( className={cn(
"min-w-0 max-w-full 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", !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. */} {/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
@@ -136,7 +142,9 @@ export function BarraSottosezioni({
key={voce.id} key={voce.id}
role="tabpanel" role="tabpanel"
aria-label={voce.label} 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 }} dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12} dragElastic={0.12}
dragMomentum={false} dragMomentum={false}
+13 -3
View File
@@ -1,7 +1,8 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react"; 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 { molla } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
// Quattro voci e non cinque: il profilo sta in alto a destra // Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS. // nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -13,7 +14,10 @@ const items = [
] as const; ] as const;
export function BottomNav() { 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 ( return (
<nav <nav
@@ -62,7 +66,13 @@ export function BottomNav() {
*/} */}
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`: {/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */} 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 }) => ( {items.map(({ to, label, icon: Icon }) => (
<Link <Link
key={to} key={to}
+1
View File
@@ -25,6 +25,7 @@ export function TeamLogo({
alt="CRAP Volley" alt="CRAP Volley"
width={192} width={192}
height={192} height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)} 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 { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react"; import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
import { Link } from "@tanstack/react-router"; 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 { cn } from "@/lib/utils";
import { molla, proietta } from "@/lib/molla"; import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
@@ -115,7 +116,9 @@ function Calendario() {
const admin = useIsAdmin(); const admin = useIsAdmin();
const { eventi } = useEventi(); const { eventi } = useEventi();
const rosa = useRosa(); 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 { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese); const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`; const mesePrefix = `${anno}-${pad2(mese + 1)}`;
@@ -197,7 +200,7 @@ function Calendario() {
<motion.div <motion.div
key={mesePrefix} key={mesePrefix}
custom={direzione} custom={direzione}
drag="x" drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }} dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18} dragElastic={0.18}
dragMomentum={false} dragMomentum={false}
+38 -31
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react"; import { ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -48,17 +49,20 @@ function Classifica() {
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const { data: csi } = useCsi(); const { data: csi } = useCsi();
const votiMvp = useVotiMvp(); const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const { eventi } = useEventi(); const { eventi } = useEventi();
const classifica = csi?.classifica ?? []; const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
const eventoIdPerData = new Map( const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]), 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. // I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
const tuttiMatch = useMemo(() => {
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? ""; const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const tuttiMatch = csiGiocate.length const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
return csiGiocate.length
? csiGiocate.map((p) => { ? csiGiocate.map((p) => {
const m = matchDaPartitaCsi(p); const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false }; return { ...m, mvp: mvpPerData(m.data), scout: false };
@@ -74,23 +78,12 @@ function Classifica() {
mvp: mvpPerData(m.data), mvp: mvpPerData(m.data),
scout: true, scout: true,
})); }));
}, [csi, scoutMatches, mvpPerMatch, eventoIdPerData]);
return ( // 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.
<PageHeader const contenutoClassifica = useMemo(
titolo="Campionato" () => (
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
/>
<BarraSottosezioni
defaultId={tab ?? "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"> <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" /> <RefreshCw className="h-3.5 w-3.5 text-accent" />
@@ -127,9 +120,7 @@ function Classifica() {
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}> <span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
{r.squadra} {r.squadra}
</span> </span>
<span className="text-center text-xs text-muted-foreground"> <span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
{r.giocate}
</span>
<span className="text-center text-xs tabular-nums text-muted-foreground"> <span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti} {r.setFatti}:{r.setSubiti}
</span> </span>
@@ -141,11 +132,11 @@ function Classifica() {
</div> </div>
</> </>
), ),
}, [csi, classifica],
{ );
id: "storico",
label: "Storico partite", const contenutoStorico = useMemo(
contenuto: () =>
tuttiMatch.length === 0 ? ( tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card"> <p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessuna partita disponibile. Nessuna partita disponibile.
@@ -216,7 +207,23 @@ function Classifica() {
})} })}
</div> </div>
), ),
}, [tuttiMatch, eventoIdPerData],
);
return (
<>
<PageHeader
titolo="Campionato"
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
/>
<BarraSottosezioni
defaultId={tab ?? "classifica"}
variante="sottolineatura"
riempiLarghezza
voci={[
{ id: "classifica", label: "Classifica", contenuto: contenutoClassifica },
{ id: "storico", label: "Storico partite", contenuto: contenutoStorico },
]} ]}
/> />
</> </>
+61 -88
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react"; import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -103,25 +103,15 @@ function Squadra() {
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length) ? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
: 0; : 0;
return ( // Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
<> // non deve ricalcolare badge, classifica e obiettivi delle altre sezioni,
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} /> // che restano fuori schermo finché l'utente non cambia tab.
const contenutoRosa = useMemo(
<BarraSottosezioni () => (
defaultId="rosa"
variante="sottolineatura"
voci={[
{
id: "rosa",
label: "Rosa",
contenuto: (
<div className="space-y-2"> <div className="space-y-2">
{rosa.map((g) => { {rosa.map((g) => {
const stati = badgeGiocatore(g); const stati = badgeGiocatore(g);
const sbloccati = [ const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
...stati.filter((b) => b.grado !== null),
...badgeSegretiSbloccati(g),
];
const isOpen = aperto === g.id; const isOpen = aperto === g.id;
return ( return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card"> <article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
@@ -137,9 +127,7 @@ function Squadra() {
className="h-11 w-11 text-lg" className="h-11 w-11 text-lg"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight"> <span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
{g.nome}
</span>
<span className="mt-1 flex items-center gap-2"> <span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} /> <RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -152,10 +140,7 @@ function Squadra() {
{sbloccati.map((b) => { {sbloccati.map((b) => {
const Icon = b.def.icon; const Icon = b.def.icon;
return ( return (
<span <span key={b.def.id} title={`${b.def.nome}${gradoMeta[b.grado!].label}`}>
key={b.def.id}
title={`${b.def.nome}${gradoMeta[b.grado!].label}`}
>
<Icon <Icon
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)} className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
aria-hidden="true" aria-hidden="true"
@@ -204,8 +189,7 @@ function Squadra() {
</div> </div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/ Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
{collezioneBadge(g).totali}
</p> </p>
{sbloccati.length === 0 ? ( {sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground"> <p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
@@ -218,25 +202,16 @@ function Squadra() {
const meta = gradoMeta[b.grado!]; const meta = gradoMeta[b.grado!];
return ( return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}> <BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div <div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
>
<Icon className={cn("h-4 w-4", meta.text)} /> <Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight"> <p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita} {b.valore} {b.def.unita}
{b.prossimaSoglia {b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""} : ""}
</p> </p>
<p <p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
className={cn(
"mt-1.5 text-xs font-bold uppercase",
meta.text,
)}
>
{meta.label} {meta.label}
</p> </p>
</div> </div>
@@ -252,11 +227,11 @@ function Squadra() {
})} })}
</div> </div>
), ),
}, [rosa, aperto, mese],
{ );
id: "stats",
label: "Statistiche", const contenutoStats = useMemo(
contenuto: ( () => (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" /> <StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" /> <StatTile valore={matchGiocati} label="Match giocati" />
@@ -266,12 +241,11 @@ function Squadra() {
<StatTile valore={team.muri} label="Muri squadra" /> <StatTile valore={team.muri} label="Muri squadra" />
</div> </div>
), ),
}, [mediaPresenze, matchGiocati, pagelle, team],
{ );
id: "classifica-giocatori",
label: "Classifica", const contenutoClassifica = useMemo(
aTuttoLarghezza: true, () => (
contenuto: (
<> <>
<button <button
type="button" type="button"
@@ -324,9 +298,7 @@ function Squadra() {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-bold"> <p className="truncate text-sm font-bold">
{g.nome} {g.nome}
{i === 0 ? ( {i === 0 ? <Crown className="ml-1 inline h-3.5 w-3.5 text-warning" /> : null}
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive #{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
@@ -347,11 +319,11 @@ function Squadra() {
</div> </div>
</> </>
), ),
}, [filtroAperto, criterio, ordinati, max],
{ );
id: "obiettivi",
label: "Obiettivi", const contenutoObiettivi = useMemo(
contenuto: ( () => (
<> <>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card"> <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 className="flex items-end justify-between gap-3">
@@ -367,10 +339,7 @@ function Squadra() {
{completati}/{obiettivi.length} completati {completati}/{obiettivi.length} completati
</p> </p>
</div> </div>
<Barra <Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
percentuale={mediaObiettivi}
trackClassName="mt-3 bg-primary-foreground/15"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{obiettivi.map((o, i) => { {obiettivi.map((o, i) => {
@@ -382,11 +351,7 @@ function Squadra() {
indice={i} indice={i}
className={cn( className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1", "premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
? "ring-success/40"
: pct >= 90
? "ring-accent/40"
: "ring-transparent",
)} )}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@@ -411,9 +376,7 @@ function Squadra() {
{o.valore}/{o.target} {o.unita} · {pct}% {o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p> </p>
<p className="mt-1 text-xs font-semibold text-accent"> <p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
{microcopyObiettivo(o)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p> <p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal> </Reveal>
); );
@@ -421,11 +384,11 @@ function Squadra() {
</div> </div>
</> </>
), ),
}, [obiettivi, mediaObiettivi, completati],
{ );
id: "badge",
label: "Badge", const contenutoBadge = useMemo(
contenuto: ( () => (
<div className="space-y-2"> <div className="space-y-2">
{badgeDefs.map((b) => { {badgeDefs.map((b) => {
const Icon = b.icon; const Icon = b.icon;
@@ -435,9 +398,7 @@ function Squadra() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" /> <Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p> <p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground"> <p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
{descrizioneSoglie(b)}
</p>
</div> </div>
<div className="mt-2 grid grid-cols-3 gap-1.5"> <div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => { {gradiOrdine.map((grado) => {
@@ -451,18 +412,10 @@ function Squadra() {
return ( return (
<div <div
key={grado} key={grado}
className={cn( className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
"rounded-2xl p-2 text-center ring-1",
meta.bg,
meta.ring,
)}
> >
<p className={cn("text-xs font-bold uppercase", meta.text)}> <p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
{meta.label} <p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
</p>
<p className="font-display text-lg leading-none">
{b.soglie[grado]}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{quanti}/{rosa.length} {quanti}/{rosa.length}
</p> </p>
@@ -476,7 +429,27 @@ function Squadra() {
})} })}
</div> </div>
), ),
[rosa],
);
return (
<>
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
<BarraSottosezioni
defaultId="rosa"
variante="sottolineatura"
voci={[
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Statistiche", contenuto: contenutoStats },
{
id: "classifica-giocatori",
label: "Classifica",
aTuttoLarghezza: true,
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 * Vetro della barra flottante.
* 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.
* *
* Con un fondo poco denso (era 72%) l'aspetto dipendeva quasi tutto da * Su una superficie larga quanto lo schermo un fondo all'85% di card serve a
* `backdrop-filter`, ed è lì che i due motori divergono: WebKit sfoca in modo * tenere allineati WebKit e Blink, che sfocano in modo diverso (608f2ed); su
* molto più denso e "latteo" di Blink, e `saturate` amplifica lo scarto. Il * una pillola piccola come questa lo scarto non si legge, quindi si scende al
* risultato era una barra che su Safari sembrava piena e su Chrome Android * 62% e si lascia che sia il blur a fare il lavoro, condizione perché si
* trasparente, a parità di CSS. A 85% il colore fa il lavoro e il blur è la * legga come vetro invece che come plastica bianca.
* 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.
* *
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in * 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 * 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. */ /* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.vetro { .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. */ /** Padding inferiore della chrome flottante. */
@utility pad-sicura-fondo { @utility pad-sicura-fondo {
padding-bottom: var(--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.