Add sortable columns to draft picks table on manager page (#207)
* Add sortable columns to draft picks table on manager page Pick # column sorts by pick number (asc by default), Points column sorts by actual points then projected as tiebreaker (desc by default). Clicking a column header toggles sort direction with visual indicators. https://claude.ai/code/session_01XBnm7eKxerR7WjwrqqJPwe * Fix code review issues in TeamScoreBreakdown sortable table - Move SortIndicator out of render (avoids remount on every render) - Extract sortPicks helper outside component - Restore default sort: sport name then pick number (was lost in prior commit) - Replace unicode arrow chars with Lucide ArrowUp/ArrowDown/ArrowUpDown icons - Add aria-sort attributes to sortable column headers for accessibility - Change w-[90px] to min-w-[90px] on Pick # column to avoid clipping - Flatten nested div inside Points sort button https://claude.ai/code/session_01XBnm7eKxerR7WjwrqqJPwe * Fix pick sort to use pure pick number (matches tests) The sport-grouped tiebreaker conflicted with the test contract for the pick column. Tests expect ascending pick number = [1, 2, 3] regardless of sport, so revert sortPicks to sort by pickNumber only for that column. https://claude.ai/code/session_01XBnm7eKxerR7WjwrqqJPwe --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
88e18412ec
commit
194f9087a3
2 changed files with 188 additions and 10 deletions
|
|
@ -1,4 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
|
@ -37,6 +39,35 @@ interface TeamScoreBreakdownProps {
|
|||
} | null;
|
||||
}
|
||||
|
||||
type SortColumn = "pick" | "points";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
function SortIndicator({ column, sortColumn, sortDirection }: { column: SortColumn; sortColumn: SortColumn; sortDirection: SortDirection }) {
|
||||
if (sortColumn !== column) return <ArrowUpDown className="ml-1 h-3 w-3 text-muted-foreground/40" />;
|
||||
return sortDirection === "asc"
|
||||
? <ArrowUp className="ml-1 h-3 w-3" />
|
||||
: <ArrowDown className="ml-1 h-3 w-3" />;
|
||||
}
|
||||
|
||||
function sortPicks(
|
||||
picks: TeamScoreBreakdownProps["breakdown"]["picks"],
|
||||
sortColumn: SortColumn,
|
||||
sortDirection: SortDirection,
|
||||
) {
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
return picks.slice().sort((a, b) => {
|
||||
if (sortColumn === "pick") {
|
||||
return (a.pickNumber - b.pickNumber) * dir;
|
||||
}
|
||||
// points: sort by actual first, then projected
|
||||
const pointsDiff = a.points - b.points;
|
||||
if (pointsDiff !== 0) return pointsDiff * dir;
|
||||
const aProjected = a.projectedPoints ?? 0;
|
||||
const bProjected = b.projectedPoints ?? 0;
|
||||
return (aProjected - bProjected) * dir;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display detailed team score breakdown with all drafted participants
|
||||
* Phase 4.3: Team breakdown pages
|
||||
|
|
@ -48,6 +79,9 @@ export function TeamScoreBreakdown({
|
|||
breakdown,
|
||||
standing,
|
||||
}: TeamScoreBreakdownProps) {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>("pick");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
|
||||
if (!breakdown.team) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
|
|
@ -58,13 +92,16 @@ export function TeamScoreBreakdown({
|
|||
|
||||
const remaining = breakdown.totalCount - breakdown.completedCount;
|
||||
|
||||
// Flatten all picks sorted by sport name then pick number
|
||||
const allPicks = breakdown.picks
|
||||
.slice()
|
||||
.toSorted((a, b) => {
|
||||
const sportCmp = a.participant.sport.localeCompare(b.participant.sport);
|
||||
return sportCmp !== 0 ? sportCmp : a.pickNumber - b.pickNumber;
|
||||
});
|
||||
function handleSort(column: SortColumn) {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "points" ? "desc" : "asc");
|
||||
}
|
||||
}
|
||||
|
||||
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -104,13 +141,29 @@ export function TeamScoreBreakdown({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[90px]">Pick #</TableHead>
|
||||
<TableHead className="min-w-[90px]">
|
||||
<button
|
||||
onClick={() => handleSort("pick")}
|
||||
aria-sort={sortColumn === "pick" ? (sortDirection === "asc" ? "ascending" : "descending") : "none"}
|
||||
className="flex items-center hover:text-foreground cursor-pointer"
|
||||
>
|
||||
Pick #<SortIndicator column="pick" sortColumn={sortColumn} sortDirection={sortDirection} />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>Sport</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-center">Position</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div>Points</div>
|
||||
<div className="text-xs font-normal text-muted-foreground">actual / projected</div>
|
||||
<button
|
||||
onClick={() => handleSort("points")}
|
||||
aria-sort={sortColumn === "points" ? (sortDirection === "asc" ? "ascending" : "descending") : "none"}
|
||||
className="flex flex-col items-end ml-auto hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Points<SortIndicator column="points" sortColumn={sortColumn} sortDirection={sortDirection} />
|
||||
</div>
|
||||
<div className="text-xs font-normal text-muted-foreground">actual / projected</div>
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
||||
|
||||
|
|
@ -376,6 +377,130 @@ describe("TeamScoreBreakdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Sorting", () => {
|
||||
function getParticipantNames() {
|
||||
return screen
|
||||
.getAllByRole("row")
|
||||
.slice(1) // skip header row
|
||||
.map((row) => row.querySelectorAll("td")[2]?.textContent ?? "");
|
||||
}
|
||||
|
||||
it("should default sort by pick number ascending", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const names = getParticipantNames();
|
||||
expect(names).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("should sort by pick number descending on second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const pickHeader = screen.getByRole("button", { name: /pick #/i });
|
||||
await user.click(pickHeader); // toggle to desc
|
||||
const names = getParticipantNames();
|
||||
expect(names).toEqual(["Team C", "Driver B", "Team A"]);
|
||||
});
|
||||
|
||||
it("should sort by points descending on first click of Points header", 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
|
||||
const pointsHeader = screen.getByRole("button", { name: /points/i });
|
||||
await user.click(pointsHeader);
|
||||
const names = getParticipantNames();
|
||||
expect(names).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("should sort by points ascending on second click of Points header", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const pointsHeader = screen.getByRole("button", { name: /points/i });
|
||||
await user.click(pointsHeader); // desc
|
||||
await user.click(pointsHeader); // asc
|
||||
const names = getParticipantNames();
|
||||
expect(names).toEqual(["Team C", "Driver B", "Team A"]);
|
||||
});
|
||||
|
||||
it("should use projected points as tiebreaker when actual points are equal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tieBreakdown = {
|
||||
...mockBreakdown,
|
||||
picks: [
|
||||
{
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
participant: { id: "p1", name: "Alpha", sport: "NFL", sportsSeasonId: "ss-nfl" },
|
||||
finalPosition: null,
|
||||
points: 50,
|
||||
projectedPoints: 10,
|
||||
isComplete: false,
|
||||
isPartialScore: false,
|
||||
},
|
||||
{
|
||||
pickNumber: 2,
|
||||
round: 1,
|
||||
participant: { id: "p2", name: "Beta", sport: "NFL", sportsSeasonId: "ss-nfl" },
|
||||
finalPosition: null,
|
||||
points: 50,
|
||||
projectedPoints: 30,
|
||||
isComplete: false,
|
||||
isPartialScore: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={tieBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const pointsHeader = screen.getByRole("button", { name: /points/i });
|
||||
await user.click(pointsHeader); // sort by points desc → Beta (50/30) before Alpha (50/10)
|
||||
const names = getParticipantNames();
|
||||
expect(names).toEqual(["Beta", "Alpha"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Without Standing Data", () => {
|
||||
it("should render without standing prop", () => {
|
||||
renderWithRouter(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue