Make team score breakdown page mobile-friendly

The team breakdown page rendered a 5-column table that overflowed on
phones, forcing horizontal scroll. Add a responsive card layout for
mobile that shows every field (pick #, sport, participant, position,
points) with no horizontal scroll, while keeping the existing sortable
table on desktop (md+).

- Header now stacks on mobile (flex-col -> sm:flex-row).
- Mobile renders one card per pick with a compact sort control (field
  select + asc/desc toggle) instead of clickable column headers.
- Extract shared PositionCell / PointsValue / pickLabel helpers so the
  table and cards stay in sync.
- Update tests to scope table assertions and cover the mobile view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGQoRPjRycNSjwLrUwuKhw
This commit is contained in:
Claude 2026-06-19 05:03:34 +00:00
parent f078eab492
commit 215c37350b
No known key found for this signature in database
2 changed files with 242 additions and 54 deletions

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { Link } from "react-router";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
@ -97,6 +98,54 @@ function sortPicks(
});
}
type Pick = TeamScoreBreakdownProps["breakdown"]["picks"][number];
const SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [
{ value: "pick", label: "Pick #" },
{ value: "sport", label: "Sport" },
{ value: "participant", label: "Participant" },
{ value: "points", label: "Points" },
];
function pickLabel(pick: Pick, numTeams: number): string {
return numTeams > 0
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`;
}
/** Position badge shared by the desktop table and the mobile cards. */
function PositionCell({ pick }: { pick: Pick }) {
if (pick.isComplete && !pick.isPartialScore) {
return (pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
);
}
return <Badge variant="outline">Pending</Badge>;
}
/** Points value (actual, with projected underneath when incomplete). */
function PointsValue({ pick }: { pick: Pick }) {
if (pick.isComplete && !pick.isPartialScore) {
return (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</span>
);
}
return (
<div className="flex flex-col items-end">
<span className="font-semibold">{pick.points.toFixed(2)}</span>
{pick.projectedPoints !== null && (
<span className="text-xs text-muted-foreground">
{pick.projectedPoints.toFixed(2)}
</span>
)}
</div>
);
}
/**
* Display detailed team score breakdown with all drafted participants
* Phase 4.3: Team breakdown pages
@ -130,17 +179,23 @@ export function TeamScoreBreakdown({
}
}
// Mobile sort control: picking a column applies its default direction.
function handleSortColumn(column: SortColumn) {
setSortColumn(column);
setSortDirection(column === "points" ? "desc" : "asc");
}
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
</div>
<div className="text-right">
<div className="text-left sm:text-right">
<div className="text-4xl font-bold text-primary">
{breakdown.actualPoints.toFixed(2)}
</div>
@ -164,8 +219,8 @@ export function TeamScoreBreakdown({
</div>
</div>
{/* All picks — single flat table */}
<Card>
{/* All picks — desktop table */}
<Card className="hidden md:block">
<CardContent className="pt-4">
<Table>
<TableHeader>
@ -198,9 +253,7 @@ export function TeamScoreBreakdown({
{allPicks.map((pick) => (
<TableRow key={pick.pickNumber}>
<TableCell className="text-muted-foreground">
{numTeams > 0
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`}
{pickLabel(pick, numTeams)}
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
</TableCell>
<TableCell>
@ -215,31 +268,12 @@ export function TeamScoreBreakdown({
{pick.participant.name}
</TableCell>
<TableCell className="text-center">
{pick.isComplete && !pick.isPartialScore ? (
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
)
) : (
<Badge variant="outline">Pending</Badge>
)}
<PositionCell pick={pick} />
</TableCell>
<TableCell className="text-right">
{pick.isComplete && !pick.isPartialScore ? (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</span>
) : (
<div className="flex flex-col items-end">
<span className="font-semibold">{pick.points.toFixed(2)}</span>
{pick.projectedPoints !== null && (
<span className="text-xs text-muted-foreground">
{pick.projectedPoints.toFixed(2)}
</span>
)}
<PointsValue pick={pick} />
</div>
)}
</TableCell>
</TableRow>
))}
@ -248,6 +282,67 @@ export function TeamScoreBreakdown({
</CardContent>
</Card>
{/* All picks — mobile cards (no horizontal scroll, all fields visible) */}
<div className="md:hidden space-y-3" data-testid="picks-mobile">
{/* Sort control */}
<div className="flex items-center gap-2">
<label htmlFor="picks-sort" className="text-sm text-muted-foreground shrink-0">
Sort by
</label>
<select
id="picks-sort"
value={sortColumn}
onChange={(e) => handleSortColumn(e.target.value as SortColumn)}
className="flex-1 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="outline"
size="icon"
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
aria-label={`Sort ${sortDirection === "asc" ? "ascending" : "descending"}`}
>
{sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)}
</Button>
</div>
{allPicks.map((pick) => (
<Card key={pick.pickNumber} className="py-0" data-testid="pick-card">
<CardContent className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-sm text-muted-foreground">
{pickLabel(pick, numTeams)}
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
</div>
<PositionCell pick={pick} />
</div>
<div className="min-w-0">
<div className="font-medium break-words">{pick.participant.name}</div>
<Link
to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
className="text-sm font-medium hover:underline text-primary"
>
{pick.participant.sport}
</Link>
</div>
<div className="flex items-baseline justify-between border-t border-border/30 pt-2">
<span className="text-sm text-muted-foreground">Points</span>
<PointsValue pick={pick} />
</div>
</CardContent>
</Card>
))}
</div>
{/* Navigation */}
<div className="flex justify-between pt-4">
<Link

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router";
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("1.01")).toBeInTheDocument();
expect(screen.getByText("1.02")).toBeInTheDocument();
expect(screen.getByText("2.01")).toBeInTheDocument();
// Pick labels render in both the desktop table and mobile cards.
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
});
it("should show overall pick number in parentheses", () => {
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("(#1)")).toBeInTheDocument();
expect(screen.getByText("(#2)")).toBeInTheDocument();
expect(screen.getByText("(#3)")).toBeInTheDocument();
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
});
it("should fall back to #pickNumber when numTeams is 0", () => {
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("#1")).toBeInTheDocument();
expect(screen.getByText("#2")).toBeInTheDocument();
expect(screen.getByText("#3")).toBeInTheDocument();
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
});
});
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Driver B")).toBeInTheDocument();
expect(screen.getByText("Team C")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Team A")).toBeInTheDocument();
expect(table.getByText("Driver B")).toBeInTheDocument();
expect(table.getByText("Team C")).toBeInTheDocument();
});
it("should show sport names as links to sport season pages", () => {
@ -226,14 +228,15 @@ describe("TeamScoreBreakdown", () => {
/>
);
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
const table = within(screen.getByRole("table"));
const nflLinks = table.getAllByRole("link", { name: "NFL" });
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
expect(nflLinks[0]).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
);
const f1Link = screen.getByRole("link", { name: "F1" });
const f1Link = table.getByRole("link", { name: "F1" });
expect(f1Link).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("1st")).toBeInTheDocument();
expect(screen.getByText("3rd")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("1st")).toBeInTheDocument();
expect(table.getByText("3rd")).toBeInTheDocument();
});
it("should show Pending badge for incomplete participants", () => {
@ -266,7 +270,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Pending")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Pending")).toBeInTheDocument();
});
it("should show Did Not Score badge when finalPosition is 0", () => {
@ -291,7 +296,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Did Not Score")).toBeInTheDocument();
});
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
@ -318,7 +324,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("Did Not Score")).toBeInTheDocument();
});
it("should show projected EV for incomplete participants", () => {
@ -333,8 +340,9 @@ describe("TeamScoreBreakdown", () => {
);
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
expect(screen.getByText("0.00")).toBeInTheDocument();
expect(screen.getByText("25.00")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("0.00")).toBeInTheDocument();
expect(table.getByText("25.00")).toBeInTheDocument();
});
it("should display 0.00 for completed picks with zero points", () => {
@ -353,7 +361,8 @@ describe("TeamScoreBreakdown", () => {
/>
);
expect(screen.getByText("0.00")).toBeInTheDocument();
const table = within(screen.getByRole("table"));
expect(table.getByText("0.00")).toBeInTheDocument();
});
});
@ -501,6 +510,90 @@ describe("TeamScoreBreakdown", () => {
});
});
describe("Mobile view", () => {
function getCardOrder() {
const mobile = screen.getByTestId("picks-mobile");
const cards = within(mobile).getAllByTestId("pick-card");
return cards.map((card) =>
["Team A", "Driver B", "Team C"].find((n) => card.textContent?.includes(n))
);
}
it("renders a card per pick with all fields visible", () => {
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
const mobile = within(screen.getByTestId("picks-mobile"));
expect(mobile.getAllByTestId("pick-card")).toHaveLength(3);
// Participant names, sports, positions all visible without hiding data
expect(mobile.getByText("Team A")).toBeInTheDocument();
expect(mobile.getByText("Driver B")).toBeInTheDocument();
expect(mobile.getByText("Team C")).toBeInTheDocument();
expect(mobile.getByText("1st")).toBeInTheDocument();
expect(mobile.getByText("Pending")).toBeInTheDocument();
expect(mobile.getByRole("link", { name: "F1" })).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
);
});
it("defaults to pick order", () => {
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
});
it("reorders cards when sorting by points (desc) via the select", async () => {
const user = userEvent.setup();
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
// picks: Team A=100, Driver B=50, Team C=0; points defaults to descending
await user.selectOptions(screen.getByLabelText(/sort by/i), "points");
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
});
it("toggles sort direction with the direction button", async () => {
const user = userEvent.setup();
renderWithRouter(
<TeamScoreBreakdown
leagueId={mockLeagueId}
seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown}
standing={mockStanding}
/>
);
await user.selectOptions(screen.getByLabelText(/sort by/i), "points"); // desc
await user.click(screen.getByRole("button", { name: /sort descending/i }));
// now ascending: Team C (0), Driver B (50), Team A (100)
expect(getCardOrder()).toEqual(["Team C", "Driver B", "Team A"]);
});
});
describe("Without Standing Data", () => {
it("should render without standing prop", () => {
renderWithRouter(