claude/sweet-carson-46j5k6 #100

Merged
chrisp merged 2 commits from claude/sweet-carson-46j5k6 into main 2026-06-19 06:15:32 +00:00
3 changed files with 264 additions and 54 deletions

View file

@ -1,7 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react"; import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@ -97,6 +105,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 * Display detailed team score breakdown with all drafted participants
* Phase 4.3: Team breakdown pages * Phase 4.3: Team breakdown pages
@ -130,17 +186,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); const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* 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> <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> <p className="text-muted-foreground mt-1">Team Score Breakdown</p>
</div> </div>
<div className="text-right"> <div className="text-left sm:text-right">
<div className="text-4xl font-bold text-primary"> <div className="text-4xl font-bold text-primary">
{breakdown.actualPoints.toFixed(2)} {breakdown.actualPoints.toFixed(2)}
</div> </div>
@ -164,8 +226,8 @@ export function TeamScoreBreakdown({
</div> </div>
</div> </div>
{/* All picks — single flat table */} {/* All picks — desktop table */}
<Card> <Card className="hidden md:block">
<CardContent className="pt-4"> <CardContent className="pt-4">
<Table> <Table>
<TableHeader> <TableHeader>
@ -198,9 +260,7 @@ export function TeamScoreBreakdown({
{allPicks.map((pick) => ( {allPicks.map((pick) => (
<TableRow key={pick.pickNumber}> <TableRow key={pick.pickNumber}>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{numTeams > 0 {pickLabel(pick, numTeams)}
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
: `#${pick.pickNumber}`}
<span className="text-xs ml-1">(#{pick.pickNumber})</span> <span className="text-xs ml-1">(#{pick.pickNumber})</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -215,31 +275,10 @@ export function TeamScoreBreakdown({
{pick.participant.name} {pick.participant.name}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{pick.isComplete && !pick.isPartialScore ? ( <PositionCell pick={pick} />
(pick.finalPosition ?? 0) === 0 ? (
<Badge variant="secondary">Did Not Score</Badge>
) : (
<PlacementBadge position={pick.finalPosition ?? 0} />
)
) : (
<Badge variant="outline">Pending</Badge>
)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{pick.isComplete && !pick.isPartialScore ? ( <PointsValue pick={pick} />
<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>
)}
</div>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -248,6 +287,70 @@ export function TeamScoreBreakdown({
</CardContent> </CardContent>
</Card> </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">
<span id="picks-sort-label" className="text-sm text-muted-foreground shrink-0">
Sort by
</span>
<Select
value={sortColumn}
onValueChange={(v) => handleSortColumn(v as SortColumn)}
>
<SelectTrigger className="flex-1" aria-labelledby="picks-sort-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
aria-label={`Sort ${sortDirection === "asc" ? "descending" : "ascending"}`}
>
{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 */} {/* Navigation */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<Link <Link

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { TeamScoreBreakdown } from "../TeamScoreBreakdown"; import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("1.01")).toBeInTheDocument(); // Pick labels render in both the desktop table and mobile cards.
expect(screen.getByText("1.02")).toBeInTheDocument(); expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("2.01")).toBeInTheDocument(); expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
}); });
it("should show overall pick number in parentheses", () => { it("should show overall pick number in parentheses", () => {
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("(#1)")).toBeInTheDocument(); expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("(#2)")).toBeInTheDocument(); expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("(#3)")).toBeInTheDocument(); expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
}); });
it("should fall back to #pickNumber when numTeams is 0", () => { it("should fall back to #pickNumber when numTeams is 0", () => {
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("#1")).toBeInTheDocument(); expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("#2")).toBeInTheDocument(); expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("#3")).toBeInTheDocument(); expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
}); });
}); });
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("Team A")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("Driver B")).toBeInTheDocument(); expect(table.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Team C")).toBeInTheDocument(); expect(table.getByText("Driver B")).toBeInTheDocument();
expect(table.getByText("Team C")).toBeInTheDocument();
}); });
it("should show sport names as links to sport season pages", () => { 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.length).toBeGreaterThanOrEqual(1);
expect(nflLinks[0]).toHaveAttribute( expect(nflLinks[0]).toHaveAttribute(
"href", "href",
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl` `/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
); );
const f1Link = screen.getByRole("link", { name: "F1" }); const f1Link = table.getByRole("link", { name: "F1" });
expect(f1Link).toHaveAttribute( expect(f1Link).toHaveAttribute(
"href", "href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1` `/leagues/${mockLeagueId}/sports-seasons/ss-f1`
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
/> />
); );
expect(screen.getByText("1st")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("3rd")).toBeInTheDocument(); expect(table.getByText("1st")).toBeInTheDocument();
expect(table.getByText("3rd")).toBeInTheDocument();
}); });
it("should show Pending badge for incomplete participants", () => { 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", () => { 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", () => { 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", () => { 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 // Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
expect(screen.getByText("0.00")).toBeInTheDocument(); const table = within(screen.getByRole("table"));
expect(screen.getByText("25.00")).toBeInTheDocument(); expect(table.getByText("0.00")).toBeInTheDocument();
expect(table.getByText("25.00")).toBeInTheDocument();
}); });
it("should display 0.00 for completed picks with zero points", () => { 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,96 @@ 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))
);
}
async function selectSortColumn(user: ReturnType<typeof userEvent.setup>, label: string) {
await user.click(screen.getByRole("combobox", { name: /sort by/i }));
await user.click(screen.getByRole("option", { name: label }));
}
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 selectSortColumn(user, "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 selectSortColumn(user, "Points"); // desc
// While descending, the toggle's action is to sort ascending.
await user.click(screen.getByRole("button", { name: /sort ascending/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", () => { describe("Without Standing Data", () => {
it("should render without standing prop", () => { it("should render without standing prop", () => {
renderWithRouter( renderWithRouter(

View file

@ -29,6 +29,14 @@ process.env.NODE_ENV = 'test';
// jsdom does not implement scrollTo on elements // jsdom does not implement scrollTo on elements
HTMLElement.prototype.scrollTo = vi.fn(); HTMLElement.prototype.scrollTo = vi.fn();
// jsdom does not implement these APIs that Radix UI relies on for pointer
// interactions and option positioning (Select, DropdownMenu, etc.). Without
// them, opening a Radix Select in a test throws.
HTMLElement.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
// Mock BetterAuth // Mock BetterAuth
vi.mock('~/lib/auth.server', () => ({ vi.mock('~/lib/auth.server', () => ({
auth: { auth: {