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 (
<Card className="gap-2">
<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">
Invite friends, pick your sports, and start drafting.
</p>

View file

@ -21,9 +21,11 @@ export interface LeagueRowProps {
// ─── Helpers ──────────────────────────────────────────────────────────────────
function ordinal(n: number): string {
const s = ["th", "st", "nd", "rd"];
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;
}) {
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">
{label}
</p>
@ -53,10 +55,10 @@ function StatDivider() {
// ─── Season progress bar ──────────────────────────────────────────────────────
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;
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="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;
const delta = previous - current;
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 (
<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)}
</span>
);
@ -86,6 +88,7 @@ function RankChange({ current, previous }: { current: number; previous: number |
// ─── Row variants ─────────────────────────────────────────────────────────────
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
const draftUrl = `/leagues/${leagueId}/draft/${seasonId}`;
const picksLabel =
picksUntilMyTurn === 0
? "You're on the clock!"
@ -94,7 +97,7 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
: null;
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} />
<div className="flex-1 min-w-0">
<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>
)}
</div>
{seasonId && (
<Button asChild size="sm" className="mt-3 sm:hidden">
<Link to={draftUrl}>Enter Draft</Link>
</Button>
)}
</div>
{seasonId && (
<Button asChild size="sm" className="shrink-0">
<Link to={`/leagues/${leagueId}/draft/${seasonId}`}>Enter Draft</Link>
<Button asChild size="sm" className="shrink-0 hidden sm:inline-flex">
<Link to={draftUrl}>Enter Draft</Link>
</Button>
)}
</div>
@ -132,15 +140,19 @@ function ActiveRow({
return (
<Link
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} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="active" />
{/* Avatar + name */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="active" />
</div>
</div>
{/* Stats — second row on mobile, right side on desktop */}
{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 && (
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
@ -179,16 +191,20 @@ function PreDraftRow({
return (
<Link
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} />
<div className="flex-1 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>
{/* Avatar + name */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<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 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">
<span className="text-lg font-bold leading-none">{draftTimeValue}</span>
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
</StatColumn>
{draftPosition !== undefined && (
<>
@ -213,7 +229,7 @@ function CompletedRow({
return (
<Link
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} />
<div className="flex-1 min-w-0">

View file

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

View file

@ -15,7 +15,7 @@ interface SectionCardHeaderProps {
*/
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
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 gap-2">
<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;
totalPoints = standing.totalPoints ?? 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(
@ -209,17 +213,17 @@ function isValidStatus(s: string): s is ValidStatus {
return (
<div className="container mx-auto py-8 px-4">
{/*
Desktop: 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.
lg+: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
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 */}
<div className="order-1 md:col-span-2">
<div className="order-1 lg:col-span-2">
<MyLeaguesCard leagues={leagueRows} />
</div>
{/* 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
events={upcomingCalendarEvents}
limit={8}
@ -228,7 +232,7 @@ function isValidStatus(s: string): s is ValidStatus {
</div>
{/* 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 />
</div>
</div>