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:
parent
f078eab492
commit
215c37350b
2 changed files with 242 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue