From 215c37350b666f5f568e2e9201a177567fd6b57d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 05:03:34 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EGQoRPjRycNSjwLrUwuKhw --- .../standings/TeamScoreBreakdown.tsx | 157 ++++++++++++++---- .../__tests__/TeamScoreBreakdown.test.tsx | 139 +++++++++++++--- 2 files changed, 242 insertions(+), 54 deletions(-) diff --git a/app/components/standings/TeamScoreBreakdown.tsx b/app/components/standings/TeamScoreBreakdown.tsx index 265a5f2..e2a9423 100644 --- a/app/components/standings/TeamScoreBreakdown.tsx +++ b/app/components/standings/TeamScoreBreakdown.tsx @@ -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 ? ( + Did Not Score + ) : ( + + ); + } + return Pending; +} + +/** Points value (actual, with projected underneath when incomplete). */ +function PointsValue({ pick }: { pick: Pick }) { + if (pick.isComplete && !pick.isPartialScore) { + return ( + + {pick.points > 0 ? pick.points.toFixed(2) : "0.00"} + + ); + } + return ( +
+ {pick.points.toFixed(2)} + {pick.projectedPoints !== null && ( + + {pick.projectedPoints.toFixed(2)} + + )} +
+ ); +} + /** * 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 (
{/* Header */} -
+
-

{breakdown.team.name}

+

{breakdown.team.name}

Team Score Breakdown

-
+
{breakdown.actualPoints.toFixed(2)}
@@ -164,8 +219,8 @@ export function TeamScoreBreakdown({
- {/* All picks — single flat table */} - + {/* All picks — desktop table */} + @@ -198,9 +253,7 @@ export function TeamScoreBreakdown({ {allPicks.map((pick) => ( - {numTeams > 0 - ? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}` - : `#${pick.pickNumber}`} + {pickLabel(pick, numTeams)} (#{pick.pickNumber}) @@ -215,31 +268,12 @@ export function TeamScoreBreakdown({ {pick.participant.name} - {pick.isComplete && !pick.isPartialScore ? ( - (pick.finalPosition ?? 0) === 0 ? ( - Did Not Score - ) : ( - - ) - ) : ( - Pending - )} + - {pick.isComplete && !pick.isPartialScore ? ( - - {pick.points > 0 ? pick.points.toFixed(2) : "0.00"} - - ) : ( -
- {pick.points.toFixed(2)} - {pick.projectedPoints !== null && ( - - {pick.projectedPoints.toFixed(2)} - - )} -
- )} +
+ +
))} @@ -248,6 +282,67 @@ export function TeamScoreBreakdown({ + {/* All picks — mobile cards (no horizontal scroll, all fields visible) */} +
+ {/* Sort control */} +
+ + + +
+ + {allPicks.map((pick) => ( + + +
+
+ {pickLabel(pick, numTeams)} + (#{pick.pickNumber}) +
+ +
+
+
{pick.participant.name}
+ + {pick.participant.sport} + +
+
+ Points + +
+
+
+ ))} +
+ {/* Navigation */}
{ /> ); - 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( + + ); + + 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( + + ); + + 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( + + ); + + // 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( + + ); + + 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(