Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-03 01:19:45 -07:00
parent ff208460ac
commit 7ebc590764
5 changed files with 61 additions and 36 deletions

View file

@ -8,7 +8,7 @@ export function CreateLeagueCard() {
return ( return (
<Card className="gap-2"> <Card className="gap-2">
<SectionCardHeader icon={Swords} title="Start a New League" /> <SectionCardHeader icon={Swords} title="Start a New League" />
<CardContent> <CardContent className="px-3 sm:px-6">
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Invite friends, pick your sports, and start drafting. Invite friends, pick your sports, and start drafting.
</p> </p>

View file

@ -21,9 +21,11 @@ export interface LeagueRowProps {
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function ordinal(n: number): string { function ordinal(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100; const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]); // 1113 are always "th" (e.g. 11th, 12th, 13th)
if (v >= 11 && v <= 13) return `${n}th`;
const s = ["th", "st", "nd", "rd"];
return `${n}${s[n % 10] ?? "th"}`;
} }
@ -37,7 +39,7 @@ function StatColumn({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="text-right shrink-0"> <div className="text-right shrink-0 flex-1 sm:flex-none">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label} {label}
</p> </p>
@ -53,10 +55,10 @@ function StatDivider() {
// ─── Season progress bar ────────────────────────────────────────────────────── // ─── Season progress bar ──────────────────────────────────────────────────────
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) { function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
const label = status === "completed" ? "100%" : `${pct}%`; const label = status === "completed" ? "100% Complete" : `${pct}% Complete`;
const fill = status === "completed" ? 100 : pct; const fill = status === "completed" ? 100 : pct;
return ( return (
<div className="flex items-center gap-1.5 mt-1.5 w-24"> <div className="flex items-center gap-1.5 mt-1.5 w-40">
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden"> <div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
<div <div
className="h-full rounded-full bg-electric transition-all" className="h-full rounded-full bg-electric transition-all"
@ -74,10 +76,10 @@ function RankChange({ current, previous }: { current: number; previous: number |
if (previous === undefined || previous === current) return null; if (previous === undefined || previous === current) return null;
const delta = previous - current; const delta = previous - current;
if (delta > 0) { if (delta > 0) {
return <span className="text-xs font-semibold text-primary">{delta}</span>; return <span className="text-xs font-semibold text-primary" aria-label={`ranked up ${delta}`}>{delta}</span>;
} }
return ( return (
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}> <span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }} aria-label={`ranked down ${Math.abs(delta)}`}>
{Math.abs(delta)} {Math.abs(delta)}
</span> </span>
); );
@ -86,6 +88,7 @@ function RankChange({ current, previous }: { current: number; previous: number |
// ─── Row variants ───────────────────────────────────────────────────────────── // ─── Row variants ─────────────────────────────────────────────────────────────
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) { function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
const draftUrl = `/leagues/${leagueId}/draft/${seasonId}`;
const picksLabel = const picksLabel =
picksUntilMyTurn === 0 picksUntilMyTurn === 0
? "You're on the clock!" ? "You're on the clock!"
@ -94,7 +97,7 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
: null; : null;
return ( return (
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/10 px-5 py-4"> <div className="flex items-start gap-3 rounded-lg border border-primary/40 bg-primary/10 px-3 py-3 sm:px-5 sm:py-4">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} /> <LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p> <p className="font-semibold leading-tight truncate">{leagueName}</p>
@ -109,10 +112,15 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
<span className="text-xs text-muted-foreground">{picksLabel}</span> <span className="text-xs text-muted-foreground">{picksLabel}</span>
)} )}
</div> </div>
{seasonId && (
<Button asChild size="sm" className="mt-3 sm:hidden">
<Link to={draftUrl}>Enter Draft</Link>
</Button>
)}
</div> </div>
{seasonId && ( {seasonId && (
<Button asChild size="sm" className="shrink-0"> <Button asChild size="sm" className="shrink-0 hidden sm:inline-flex">
<Link to={`/leagues/${leagueId}/draft/${seasonId}`}>Enter Draft</Link> <Link to={draftUrl}>Enter Draft</Link>
</Button> </Button>
)} )}
</div> </div>
@ -132,15 +140,19 @@ function ActiveRow({
return ( return (
<Link <Link
to={`/leagues/${leagueId}`} to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors" className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
> >
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} /> {/* Avatar + name */}
<div className="flex-1 min-w-0"> <div className="flex items-center gap-3 min-w-0 flex-1">
<p className="font-semibold leading-tight truncate">{leagueName}</p> <LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<SeasonProgress pct={completionPercentage} status="active" /> <div className="min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="active" />
</div>
</div> </div>
{/* Stats — second row on mobile, right side on desktop */}
{showStats && ( {showStats && (
<div className="flex items-center gap-4 shrink-0"> <div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
{currentRank !== undefined && ( {currentRank !== undefined && (
<StatColumn label="Ranking"> <StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">#{currentRank}</span> <span className="text-2xl font-bold leading-none">#{currentRank}</span>
@ -179,16 +191,20 @@ function PreDraftRow({
return ( return (
<Link <Link
to={`/leagues/${leagueId}`} to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors" className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
> >
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} /> {/* Avatar + name */}
<div className="flex-1 min-w-0"> <div className="flex items-center gap-3 min-w-0 flex-1">
<p className="font-semibold leading-tight truncate">{leagueName}</p> <LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p> <div className="min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p>
</div>
</div> </div>
<div className="flex items-center gap-4 shrink-0"> {/* Stats — second row on mobile, right side on desktop */}
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
<StatColumn label="Draft"> <StatColumn label="Draft">
<span className="text-lg font-bold leading-none">{draftTimeValue}</span> <span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
</StatColumn> </StatColumn>
{draftPosition !== undefined && ( {draftPosition !== undefined && (
<> <>
@ -213,7 +229,7 @@ function CompletedRow({
return ( return (
<Link <Link
to={`/leagues/${leagueId}`} to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors" className="flex items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
> >
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} /> <LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View file

@ -17,9 +17,14 @@ interface MyLeaguesCardProps {
} }
export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) { export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
const sorted = leagues.toSorted( const sorted = leagues.toSorted((a, b) => {
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
); if (statusDiff !== 0) return statusDiff;
if (a.status === "active") {
return (b.completionPercentage ?? 0) - (a.completionPercentage ?? 0);
}
return 0;
});
const right = viewAllUrl ? ( const right = viewAllUrl ? (
<Link <Link
@ -33,7 +38,7 @@ export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
return ( return (
<Card className="bg-[#181b22] gap-2"> <Card className="bg-[#181b22] gap-2">
<SectionCardHeader icon={Shield} title="My Leagues" right={right} /> <SectionCardHeader icon={Shield} title="My Leagues" right={right} />
<CardContent> <CardContent className="px-3 sm:px-6">
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<div className="py-4 text-center"> <div className="py-4 text-center">
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">

View file

@ -15,7 +15,7 @@ interface SectionCardHeaderProps {
*/ */
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) { export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
return ( return (
<CardHeader className="pb-2"> <CardHeader className="px-3 sm:px-6 pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GradientIcon icon={icon} className="h-5 w-5 shrink-0" /> <GradientIcon icon={icon} className="h-5 w-5 shrink-0" />

View file

@ -99,6 +99,10 @@ export async function loader(args: Route.LoaderArgs) {
currentRank = standing.currentRank ?? undefined; currentRank = standing.currentRank ?? undefined;
totalPoints = standing.totalPoints ?? undefined; totalPoints = standing.totalPoints ?? undefined;
previousRank = standing.previousRank ?? undefined; previousRank = standing.previousRank ?? undefined;
} else if (myTeam && season?.status === "active") {
// No standing row yet (no scoring events) — default to rank 1, 0 points
currentRank = 1;
totalPoints = 0;
} }
const perSeasonEvents = await Promise.all( const perSeasonEvents = await Promise.all(
@ -209,17 +213,17 @@ function isValidStatus(s: string): s is ValidStatus {
return ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
{/* {/*
Desktop: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1. lg+: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
Mobile: single column stacked in order: My Leagues, Upcoming Events, Create League. mobile/tablet: single column stacked in order: My Leagues, Upcoming Events, Create League.
*/} */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* My Leagues — order 1 on mobile, col-span-2 on desktop */} {/* My Leagues — order 1 on mobile, col-span-2 on desktop */}
<div className="order-1 md:col-span-2"> <div className="order-1 lg:col-span-2">
<MyLeaguesCard leagues={leagueRows} /> <MyLeaguesCard leagues={leagueRows} />
</div> </div>
{/* Upcoming Events — order 2 on mobile, right column on desktop spanning both rows */} {/* Upcoming Events — order 2 on mobile, right column on desktop spanning both rows */}
<div className="order-2 md:col-span-1 md:row-span-2"> <div className="order-2 lg:col-span-1 lg:row-span-2">
<UpcomingEventsCard <UpcomingEventsCard
events={upcomingCalendarEvents} events={upcomingCalendarEvents}
limit={8} limit={8}
@ -228,7 +232,7 @@ function isValidStatus(s: string): s is ValidStatus {
</div> </div>
{/* Create a League — order 3 on mobile, below My Leagues on desktop */} {/* Create a League — order 3 on mobile, below My Leagues on desktop */}
<div className="order-3 md:col-span-2"> <div className="order-3 lg:col-span-2">
<CreateLeagueCard /> <CreateLeagueCard />
</div> </div>
</div> </div>