Simplify team breakdown page layout and fix code quality issues (#155)
- Replace per-sport cards with a single flat table; add Sport column (linked to sport season) before Participant - Replace three summary stat cards with a compact "X remaining" line in the header - Format pick numbers as round.pick notation (e.g. 2.02 for pick 16 in a 14-team league) - Add getNumTeamsInSeason model function to compute pick-within-round correctly - Remove dead bySport/totalPoints from standings model and component interface - Narrow standing prop type to only currentRank (placementCounts was unused) - Guard against numTeams === 0 edge case in pick notation - Fix 0.0 → 0.00 decimal inconsistency - Fix unsafe finalPosition! non-null assertion (null treated as Did Not Score) - Update tests to match current UI; add coverage for new edge cases Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5a48327102
commit
33f9ad6ebe
5 changed files with 265 additions and 464 deletions
|
|
@ -1,12 +1,12 @@
|
|||
import { Link } from "react-router";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "~/components/ui/card";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface TeamScoreBreakdownProps {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
numTeams: number;
|
||||
breakdown: {
|
||||
team: {
|
||||
id: string;
|
||||
|
|
@ -26,21 +26,6 @@ interface TeamScoreBreakdownProps {
|
|||
projectedPoints: number | null;
|
||||
isComplete: boolean;
|
||||
}>;
|
||||
bySport: Record<string, { sportsSeasonId: string; picks: Array<{
|
||||
pickNumber: number;
|
||||
round: number;
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string;
|
||||
sportsSeasonId: string;
|
||||
};
|
||||
finalPosition: number | null;
|
||||
points: number;
|
||||
projectedPoints: number | null;
|
||||
isComplete: boolean;
|
||||
}> }>;
|
||||
totalPoints: number;
|
||||
actualPoints: number;
|
||||
projectedPoints: number;
|
||||
completedCount: number;
|
||||
|
|
@ -48,16 +33,6 @@ interface TeamScoreBreakdownProps {
|
|||
};
|
||||
standing: {
|
||||
currentRank: number;
|
||||
placementCounts: {
|
||||
first: number;
|
||||
second: number;
|
||||
third: number;
|
||||
fourth: number;
|
||||
fifth: number;
|
||||
sixth: number;
|
||||
seventh: number;
|
||||
eighth: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +43,7 @@ interface TeamScoreBreakdownProps {
|
|||
export function TeamScoreBreakdown({
|
||||
leagueId,
|
||||
seasonId,
|
||||
numTeams,
|
||||
breakdown,
|
||||
standing,
|
||||
}: TeamScoreBreakdownProps) {
|
||||
|
|
@ -79,31 +55,40 @@ export function TeamScoreBreakdown({
|
|||
);
|
||||
}
|
||||
|
||||
const sportEntries = Object.entries(breakdown.bySport).sort(([a], [b]) => a.localeCompare(b));
|
||||
const remaining = breakdown.totalCount - breakdown.completedCount;
|
||||
|
||||
// Flatten all picks sorted by sport name then pick number
|
||||
const allPicks = breakdown.picks
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const sportCmp = a.participant.sport.localeCompare(b.participant.sport);
|
||||
return sportCmp !== 0 ? sportCmp : a.pickNumber - b.pickNumber;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with team info and summary */}
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="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 className="text-right">
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{breakdown.actualPoints.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Actual Points
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Actual Points</div>
|
||||
{breakdown.projectedPoints > breakdown.actualPoints && (
|
||||
<div className="text-xl font-semibold text-electric mt-1">
|
||||
{breakdown.projectedPoints.toFixed(2)}
|
||||
<span className="text-xs text-muted-foreground ml-1">projected</span>
|
||||
</div>
|
||||
)}
|
||||
{remaining > 0 && (
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{remaining} participant{remaining !== 1 ? "s" : ""} remaining
|
||||
</div>
|
||||
)}
|
||||
{standing && (
|
||||
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
|
||||
Rank #{standing.currentRank}
|
||||
|
|
@ -112,214 +97,81 @@ export function TeamScoreBreakdown({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Actual Points
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{breakdown.actualPoints.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From {breakdown.completedCount} finished
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Projected Points
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{breakdown.projectedPoints.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
+{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(2)} expected value
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Participants
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{breakdown.completedCount} / {breakdown.totalCount}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{breakdown.totalCount - breakdown.completedCount} remaining
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{standing && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Top Finishes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{standing.placementCounts.first > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-amber-accent font-medium">1st place</span>
|
||||
<span className="font-bold">{standing.placementCounts.first}</span>
|
||||
</div>
|
||||
)}
|
||||
{standing.placementCounts.second > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground font-medium">2nd place</span>
|
||||
<span className="font-bold">{standing.placementCounts.second}</span>
|
||||
</div>
|
||||
)}
|
||||
{standing.placementCounts.third > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-coral-accent font-medium">3rd place</span>
|
||||
<span className="font-bold">{standing.placementCounts.third}</span>
|
||||
</div>
|
||||
)}
|
||||
{standing.placementCounts.first === 0 &&
|
||||
standing.placementCounts.second === 0 &&
|
||||
standing.placementCounts.third === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No podium finishes yet</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
All Placements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||
{[
|
||||
{ label: "4th", count: standing.placementCounts.fourth },
|
||||
{ label: "5th", count: standing.placementCounts.fifth },
|
||||
{ label: "6th", count: standing.placementCounts.sixth },
|
||||
{ label: "7th", count: standing.placementCounts.seventh },
|
||||
{ label: "8th", count: standing.placementCounts.eighth },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{item.label}:</span>
|
||||
<span className="font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participants grouped by sport */}
|
||||
{sportEntries.map(([sportName, sportData]) => {
|
||||
const { sportsSeasonId, picks: sportPicks } = sportData;
|
||||
const sportTotal = sportPicks.reduce((sum: number, p) => sum + p.points, 0);
|
||||
const sportCompleted = sportPicks.filter((p) => p.isComplete).length;
|
||||
|
||||
return (
|
||||
<Card key={sportName}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>{sportName}</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm" className="h-6 px-2 text-xs">
|
||||
<Link to={`/leagues/${leagueId}/sports-seasons/${sportsSeasonId}`}>
|
||||
View Details →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{sportPicks.length} {sportPicks.length === 1 ? 'pick' : 'picks'} · {sportCompleted} completed
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">{sportTotal.toFixed(2)}</div>
|
||||
<div className="text-xs text-muted-foreground">points</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Pick #</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-center">Position</TableHead>
|
||||
<TableHead className="text-right">Points</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sportPicks.map((pick: typeof sportPicks[number]) => (
|
||||
<TableRow key={pick.pickNumber}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
#{pick.pickNumber}
|
||||
<span className="text-xs ml-1">(R{pick.round})</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{pick.participant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{pick.isComplete ? (
|
||||
pick.finalPosition === 0 ? (
|
||||
<Badge variant="secondary">
|
||||
Did Not Score
|
||||
</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition!} />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{pick.isComplete ? (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : '0.0'}
|
||||
</span>
|
||||
) : pick.projectedPoints !== null ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">Projected:</span>
|
||||
<span className="font-semibold text-primary">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{/* All picks — single flat table */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[90px]">Pick #</TableHead>
|
||||
<TableHead>Sport</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
<TableHead className="text-center">Position</TableHead>
|
||||
<TableHead className="text-right">Points</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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}`}
|
||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
|
||||
className="text-sm font-medium hover:underline text-primary"
|
||||
>
|
||||
{pick.participant.sport}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{pick.participant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{pick.isComplete ? (
|
||||
(pick.finalPosition ?? 0) === 0 ? (
|
||||
<Badge variant="secondary">Did Not Score</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition!} />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{pick.isComplete ? (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||
</span>
|
||||
) : pick.projectedPoints !== null ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">Projected:</span>
|
||||
<span className="font-semibold text-primary">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/leagues/${leagueId}/standings/${seasonId}`}>
|
||||
← Back to Standings
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
to={`/leagues/${leagueId}/standings/${seasonId}`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to Standings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,15 @@ import { render, screen } from "@testing-library/react";
|
|||
import { BrowserRouter } from "react-router";
|
||||
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
||||
|
||||
// Helper to wrap component with router context
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamScoreBreakdown Component Tests
|
||||
* Phase 4.3: Team breakdown pages
|
||||
*/
|
||||
describe("TeamScoreBreakdown", () => {
|
||||
const mockLeagueId = "league-123";
|
||||
const mockSeasonId = "season-456";
|
||||
// 2-team league: pick 1 = 1.01, pick 2 = 1.02, pick 3 = 2.01
|
||||
const numTeams = 2;
|
||||
|
||||
const mockBreakdown = {
|
||||
team: {
|
||||
|
|
@ -65,87 +62,21 @@ describe("TeamScoreBreakdown", () => {
|
|||
isComplete: false,
|
||||
},
|
||||
],
|
||||
bySport: {
|
||||
NFL: {
|
||||
sportsSeasonId: "ss-nfl",
|
||||
picks: [
|
||||
{
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
participant: {
|
||||
id: "p1",
|
||||
name: "Team A",
|
||||
sport: "NFL",
|
||||
sportsSeasonId: "ss-nfl",
|
||||
},
|
||||
finalPosition: 1,
|
||||
points: 100,
|
||||
projectedPoints: null,
|
||||
isComplete: true,
|
||||
},
|
||||
{
|
||||
pickNumber: 3,
|
||||
round: 2,
|
||||
participant: {
|
||||
id: "p3",
|
||||
name: "Team C",
|
||||
sport: "NFL",
|
||||
sportsSeasonId: "ss-nfl",
|
||||
},
|
||||
finalPosition: null,
|
||||
points: 0,
|
||||
projectedPoints: 25,
|
||||
isComplete: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
F1: {
|
||||
sportsSeasonId: "ss-f1",
|
||||
picks: [
|
||||
{
|
||||
pickNumber: 2,
|
||||
round: 1,
|
||||
participant: {
|
||||
id: "p2",
|
||||
name: "Driver B",
|
||||
sport: "F1",
|
||||
sportsSeasonId: "ss-f1",
|
||||
},
|
||||
finalPosition: 3,
|
||||
points: 50,
|
||||
projectedPoints: null,
|
||||
isComplete: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
totalPoints: 150,
|
||||
actualPoints: 150,
|
||||
projectedPoints: 175,
|
||||
completedCount: 2,
|
||||
totalCount: 3,
|
||||
};
|
||||
|
||||
const mockStanding = {
|
||||
currentRank: 1,
|
||||
placementCounts: {
|
||||
first: 1,
|
||||
second: 0,
|
||||
third: 1,
|
||||
fourth: 0,
|
||||
fifth: 0,
|
||||
sixth: 0,
|
||||
seventh: 0,
|
||||
eighth: 0,
|
||||
},
|
||||
};
|
||||
const mockStanding = { currentRank: 1 };
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
it("should render team name and total points", () => {
|
||||
it("should render team name and actual points", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
|
|
@ -155,11 +86,41 @@ describe("TeamScoreBreakdown", () => {
|
|||
expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should show projected points when higher than actual", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("175.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("projected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show remaining participant count", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("1 participant remaining")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show team rank badge", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
|
|
@ -168,28 +129,13 @@ describe("TeamScoreBreakdown", () => {
|
|||
expect(screen.getByText("Rank #1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display participant completion stats", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 / 3")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 remaining")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null team", () => {
|
||||
const nullBreakdown = { ...mockBreakdown, team: null };
|
||||
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={nullBreakdown}
|
||||
numTeams={numTeams}
|
||||
breakdown={{ ...mockBreakdown, team: null }}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
|
@ -198,66 +144,63 @@ describe("TeamScoreBreakdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Sport Grouping", () => {
|
||||
it("should group participants by sport", () => {
|
||||
describe("Pick Number Display", () => {
|
||||
it("should format picks as round.pick notation", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("NFL")).toBeInTheDocument();
|
||||
expect(screen.getByText("F1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.01")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.02")).toBeInTheDocument();
|
||||
expect(screen.getByText("2.01")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show correct pick counts per sport", () => {
|
||||
it("should show overall pick number in parentheses", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// NFL has 2 picks
|
||||
const nflSection = screen.getByText("2 picks · 1 completed");
|
||||
expect(nflSection).toBeInTheDocument();
|
||||
|
||||
// F1 has 1 pick
|
||||
const f1Section = screen.getByText("1 pick · 1 completed");
|
||||
expect(f1Section).toBeInTheDocument();
|
||||
expect(screen.getByText("(#1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should calculate sport-specific point totals", () => {
|
||||
it("should fall back to #pickNumber when numTeams is 0", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={0}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// NFL total should be 100 (Team A)
|
||||
// F1 total should be 50 (Driver B)
|
||||
const pointCells = screen.getAllByText(/^\d+\.\d\d$/)
|
||||
.map((el) => parseFloat(el.textContent!));
|
||||
|
||||
expect(pointCells).toContain(100.0);
|
||||
expect(pointCells).toContain(50.0);
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Participant Display", () => {
|
||||
it("should display all participant names", () => {
|
||||
it("should display all participant names in a single table", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
|
|
@ -268,22 +211,29 @@ describe("TeamScoreBreakdown", () => {
|
|||
expect(screen.getByText("Team C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show pick numbers and rounds", () => {
|
||||
it("should show sport names as links to sport season pages", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
|
||||
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(nflLinks[0]).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("(R1)")).toHaveLength(2);
|
||||
expect(screen.getByText("(R2)")).toBeInTheDocument();
|
||||
const f1Link = screen.getByRole("link", { name: "F1" });
|
||||
expect(f1Link).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||
);
|
||||
});
|
||||
|
||||
it("should show placement badges for completed participants", () => {
|
||||
|
|
@ -291,6 +241,7 @@ describe("TeamScoreBreakdown", () => {
|
|||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
|
|
@ -300,11 +251,12 @@ describe("TeamScoreBreakdown", () => {
|
|||
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show pending badge for incomplete participants", () => {
|
||||
it("should show Pending badge for incomplete participants", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
|
|
@ -313,109 +265,109 @@ describe("TeamScoreBreakdown", () => {
|
|||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display points for each participant", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should have 100.00 and 50.00 for completed picks, and "-" for pending
|
||||
const pointValues = screen.getAllByRole("cell")
|
||||
.filter((cell) => cell.textContent?.match(/^\d+\.\d\d$|^-$/));
|
||||
|
||||
expect(pointValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placement Summary", () => {
|
||||
it("should show top finishes in summary card", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("1st place")).toBeInTheDocument();
|
||||
expect(screen.getByText("3rd place")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show placements with zero count", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("2nd place")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show message when no podium finishes", () => {
|
||||
const noPodiumStanding = {
|
||||
currentRank: 5,
|
||||
placementCounts: {
|
||||
first: 0,
|
||||
second: 0,
|
||||
third: 0,
|
||||
fourth: 1,
|
||||
fifth: 1,
|
||||
sixth: 0,
|
||||
seventh: 0,
|
||||
eighth: 0,
|
||||
},
|
||||
it("should show Did Not Score badge when finalPosition is 0", () => {
|
||||
const breakdownWithDNS = {
|
||||
...mockBreakdown,
|
||||
picks: [
|
||||
{
|
||||
...mockBreakdown.picks[0],
|
||||
finalPosition: 0,
|
||||
points: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={noPodiumStanding}
|
||||
numTeams={numTeams}
|
||||
breakdown={breakdownWithDNS}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No podium finishes yet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
|
||||
const breakdownWithNullPosition = {
|
||||
...mockBreakdown,
|
||||
picks: [
|
||||
{
|
||||
...mockBreakdown.picks[0],
|
||||
finalPosition: null,
|
||||
points: 0,
|
||||
isComplete: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={breakdownWithNullPosition}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show projected points for incomplete participants", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Projected:")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 0.00 for completed picks with zero points", () => {
|
||||
const breakdownWithZero = {
|
||||
...mockBreakdown,
|
||||
picks: [{ ...mockBreakdown.picks[0], finalPosition: 0, points: 0 }],
|
||||
};
|
||||
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={breakdownWithZero}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Navigation Links", () => {
|
||||
describe("Navigation", () => {
|
||||
it("should have back to standings link", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const backLink = screen.getByRole("link", { name: /back to standings/i });
|
||||
expect(backLink).toHaveAttribute("href", `/leagues/${mockLeagueId}/standings/${mockSeasonId}`);
|
||||
});
|
||||
|
||||
it("should have links to sport season pages", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
expect(backLink).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/standings/${mockSeasonId}`
|
||||
);
|
||||
|
||||
const viewDetailsLinks = screen.getAllByRole("link", { name: /view details/i });
|
||||
expect(viewDetailsLinks).toHaveLength(2); // One for NFL, one for F1
|
||||
|
||||
expect(viewDetailsLinks[0]).toHaveAttribute("href", expect.stringContaining("/sports-seasons/"));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -425,16 +377,13 @@ describe("TeamScoreBreakdown", () => {
|
|||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should still show team name and points
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// But not show rank badge
|
||||
expect(screen.queryByText(/Rank #/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { eq, count } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -126,6 +126,18 @@ export async function setDraftOrder(
|
|||
return await createManyDraftSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of teams (draft slots) in a season
|
||||
*/
|
||||
export async function getNumTeamsInSeason(seasonId: string): Promise<number> {
|
||||
const db = database();
|
||||
const [result] = await db
|
||||
.select({ count: count() })
|
||||
.from(schema.draftSlots)
|
||||
.where(eq(schema.draftSlots.seasonId, seasonId));
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Randomize the draft order for a season
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -218,20 +218,6 @@ export async function getTeamScoreBreakdown(
|
|||
})
|
||||
);
|
||||
|
||||
// Group by sport for easier display
|
||||
// Structure: { sportName: { sportsSeasonId, picks } }
|
||||
const bySport: Record<string, { sportsSeasonId: string; picks: typeof pickBreakdown }> = {};
|
||||
for (const pick of pickBreakdown) {
|
||||
const sport = pick.participant.sport;
|
||||
if (!bySport[sport]) {
|
||||
bySport[sport] = {
|
||||
sportsSeasonId: pick.participant.sportsSeasonId,
|
||||
picks: [],
|
||||
};
|
||||
}
|
||||
bySport[sport].picks.push(pick);
|
||||
}
|
||||
|
||||
const actualPoints = pickBreakdown
|
||||
.filter((p) => p.isComplete)
|
||||
.reduce((sum, p) => sum + p.points, 0);
|
||||
|
|
@ -246,8 +232,6 @@ export async function getTeamScoreBreakdown(
|
|||
where: eq(schema.teams.id, teamId),
|
||||
}),
|
||||
picks: pickBreakdown,
|
||||
bySport,
|
||||
totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0),
|
||||
actualPoints,
|
||||
projectedPoints: projectedTotalPoints,
|
||||
completedCount: pickBreakdown.filter((p) => p.isComplete).length,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Route } from "./+types/$leagueId.standings.$seasonId.teams.$teamId
|
|||
|
||||
import { TeamScoreBreakdown } from "~/components/standings/TeamScoreBreakdown";
|
||||
import { getTeamScoreBreakdown, getTeamStanding } from "~/models/standings";
|
||||
import { getNumTeamsInSeason } from "~/models/draft-slot";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `${data?.breakdown?.team?.name ?? "Team"} - Brackt` }];
|
||||
|
|
@ -17,15 +18,16 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const { leagueId, seasonId, teamId } = params;
|
||||
|
||||
// Get team breakdown with all picks
|
||||
const breakdown = await getTeamScoreBreakdown(teamId, seasonId);
|
||||
const [breakdown, standing, numTeams] = await Promise.all([
|
||||
getTeamScoreBreakdown(teamId, seasonId),
|
||||
getTeamStanding(teamId, seasonId),
|
||||
getNumTeamsInSeason(seasonId),
|
||||
]);
|
||||
|
||||
if (!breakdown) {
|
||||
throw new Response("Team not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get team standing for additional context
|
||||
const standing = await getTeamStanding(teamId, seasonId);
|
||||
|
||||
return {
|
||||
leagueId,
|
||||
seasonId,
|
||||
|
|
@ -35,11 +37,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
team: breakdown.team || null,
|
||||
},
|
||||
standing,
|
||||
numTeams,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TeamBreakdownPage() {
|
||||
const { leagueId, seasonId, breakdown, standing } = useLoaderData<typeof loader>();
|
||||
const { leagueId, seasonId, breakdown, standing, numTeams } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
|
|
@ -48,6 +51,7 @@ export default function TeamBreakdownPage() {
|
|||
seasonId={seasonId}
|
||||
breakdown={breakdown}
|
||||
standing={standing}
|
||||
numTeams={numTeams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue