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:
Chris Parsons 2026-03-17 09:20:41 -07:00 committed by GitHub
parent 5a48327102
commit 33f9ad6ebe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 265 additions and 464 deletions

View file

@ -1,12 +1,12 @@
import { Link } from "react-router"; 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 { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
interface TeamScoreBreakdownProps { interface TeamScoreBreakdownProps {
leagueId: string; leagueId: string;
seasonId: string; seasonId: string;
numTeams: number;
breakdown: { breakdown: {
team: { team: {
id: string; id: string;
@ -26,21 +26,6 @@ interface TeamScoreBreakdownProps {
projectedPoints: number | null; projectedPoints: number | null;
isComplete: boolean; 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; actualPoints: number;
projectedPoints: number; projectedPoints: number;
completedCount: number; completedCount: number;
@ -48,16 +33,6 @@ interface TeamScoreBreakdownProps {
}; };
standing: { standing: {
currentRank: number; currentRank: number;
placementCounts: {
first: number;
second: number;
third: number;
fourth: number;
fifth: number;
sixth: number;
seventh: number;
eighth: number;
};
} | null; } | null;
} }
@ -68,6 +43,7 @@ interface TeamScoreBreakdownProps {
export function TeamScoreBreakdown({ export function TeamScoreBreakdown({
leagueId, leagueId,
seasonId, seasonId,
numTeams,
breakdown, breakdown,
standing, standing,
}: TeamScoreBreakdownProps) { }: 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header with team info and summary */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1> <h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">Team Score Breakdown</p>
Team Score Breakdown
</p>
</div> </div>
<div className="text-right"> <div className="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>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">Actual Points</div>
Actual Points
</div>
{breakdown.projectedPoints > breakdown.actualPoints && ( {breakdown.projectedPoints > breakdown.actualPoints && (
<div className="text-xl font-semibold text-electric mt-1"> <div className="text-xl font-semibold text-electric mt-1">
{breakdown.projectedPoints.toFixed(2)} {breakdown.projectedPoints.toFixed(2)}
<span className="text-xs text-muted-foreground ml-1">projected</span> <span className="text-xs text-muted-foreground ml-1">projected</span>
</div> </div>
)} )}
{remaining > 0 && (
<div className="text-sm text-muted-foreground mt-1">
{remaining} participant{remaining !== 1 ? "s" : ""} remaining
</div>
)}
{standing && ( {standing && (
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}> <Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
Rank #{standing.currentRank} Rank #{standing.currentRank}
@ -112,214 +97,81 @@ export function TeamScoreBreakdown({
</div> </div>
</div> </div>
{/* Summary stats */} {/* All picks — single flat table */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <Card>
<Card> <CardContent className="pt-4">
<CardHeader className="pb-3"> <Table>
<CardTitle className="text-sm font-medium text-muted-foreground"> <TableHeader>
Actual Points <TableRow>
</CardTitle> <TableHead className="w-[90px]">Pick #</TableHead>
</CardHeader> <TableHead>Sport</TableHead>
<CardContent> <TableHead>Participant</TableHead>
<div className="text-2xl font-bold"> <TableHead className="text-center">Position</TableHead>
{breakdown.actualPoints.toFixed(2)} <TableHead className="text-right">Points</TableHead>
</div> </TableRow>
<p className="text-xs text-muted-foreground mt-1"> </TableHeader>
From {breakdown.completedCount} finished <TableBody>
</p> {allPicks.map((pick) => (
</CardContent> <TableRow key={pick.pickNumber}>
</Card> <TableCell className="text-muted-foreground">
{numTeams > 0
<Card> ? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
<CardHeader className="pb-3"> : `#${pick.pickNumber}`}
<CardTitle className="text-sm font-medium text-muted-foreground"> <span className="text-xs ml-1">(#{pick.pickNumber})</span>
Projected Points </TableCell>
</CardTitle> <TableCell>
</CardHeader> <Link
<CardContent> to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
<div className="text-2xl font-bold text-primary"> className="text-sm font-medium hover:underline text-primary"
{breakdown.projectedPoints.toFixed(2)} >
</div> {pick.participant.sport}
<p className="text-xs text-muted-foreground mt-1"> </Link>
+{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(2)} expected value </TableCell>
</p> <TableCell className="font-medium">
</CardContent> {pick.participant.name}
</Card> </TableCell>
<TableCell className="text-center">
<Card> {pick.isComplete ? (
<CardHeader className="pb-3"> (pick.finalPosition ?? 0) === 0 ? (
<CardTitle className="text-sm font-medium text-muted-foreground"> <Badge variant="secondary">Did Not Score</Badge>
Participants ) : (
</CardTitle> <PlacementBadge position={pick.finalPosition!} />
</CardHeader> )
<CardContent> ) : (
<div className="text-2xl font-bold"> <Badge variant="outline">Pending</Badge>
{breakdown.completedCount} / {breakdown.totalCount} )}
</div> </TableCell>
<p className="text-xs text-muted-foreground mt-1"> <TableCell className="text-right">
{breakdown.totalCount - breakdown.completedCount} remaining {pick.isComplete ? (
</p> <span className="font-semibold">
</CardContent> {pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
</Card> </span>
) : pick.projectedPoints !== null ? (
{standing && ( <div className="flex flex-col items-end">
<> <span className="text-xs text-muted-foreground">Projected:</span>
<Card> <span className="font-semibold text-primary">
<CardHeader className="pb-3"> {pick.projectedPoints.toFixed(2)}
<CardTitle className="text-sm font-medium text-muted-foreground"> </span>
Top Finishes </div>
</CardTitle> ) : (
</CardHeader> <span className="text-muted-foreground">-</span>
<CardContent> )}
<div className="space-y-1"> </TableCell>
{standing.placementCounts.first > 0 && ( </TableRow>
<div className="flex justify-between text-sm"> ))}
<span className="text-amber-accent font-medium">1st place</span> </TableBody>
<span className="font-bold">{standing.placementCounts.first}</span> </Table>
</div> </CardContent>
)} </Card>
{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>
);
})}
{/* Navigation */} {/* Navigation */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<Button asChild variant="outline"> <Link
<Link to={`/leagues/${leagueId}/standings/${seasonId}`}> to={`/leagues/${leagueId}/standings/${seasonId}`}
Back to Standings className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
</Link> >
</Button> Back to Standings
</Link>
</div> </div>
</div> </div>
); );

View file

@ -3,18 +3,15 @@ import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import { TeamScoreBreakdown } from "../TeamScoreBreakdown"; import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
// Helper to wrap component with router context
function renderWithRouter(ui: React.ReactElement) { function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>); return render(<BrowserRouter>{ui}</BrowserRouter>);
} }
/**
* TeamScoreBreakdown Component Tests
* Phase 4.3: Team breakdown pages
*/
describe("TeamScoreBreakdown", () => { describe("TeamScoreBreakdown", () => {
const mockLeagueId = "league-123"; const mockLeagueId = "league-123";
const mockSeasonId = "season-456"; const mockSeasonId = "season-456";
// 2-team league: pick 1 = 1.01, pick 2 = 1.02, pick 3 = 2.01
const numTeams = 2;
const mockBreakdown = { const mockBreakdown = {
team: { team: {
@ -65,87 +62,21 @@ describe("TeamScoreBreakdown", () => {
isComplete: false, 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, actualPoints: 150,
projectedPoints: 175, projectedPoints: 175,
completedCount: 2, completedCount: 2,
totalCount: 3, totalCount: 3,
}; };
const mockStanding = { const mockStanding = { currentRank: 1 };
currentRank: 1,
placementCounts: {
first: 1,
second: 0,
third: 1,
fourth: 0,
fifth: 0,
sixth: 0,
seventh: 0,
eighth: 0,
},
};
describe("Basic Rendering", () => { describe("Basic Rendering", () => {
it("should render team name and total points", () => { it("should render team name and actual points", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
@ -155,11 +86,41 @@ describe("TeamScoreBreakdown", () => {
expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1); 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", () => { it("should show team rank badge", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
@ -168,28 +129,13 @@ describe("TeamScoreBreakdown", () => {
expect(screen.getByText("Rank #1")).toBeInTheDocument(); 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", () => { it("should handle null team", () => {
const nullBreakdown = { ...mockBreakdown, team: null };
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
breakdown={nullBreakdown} numTeams={numTeams}
breakdown={{ ...mockBreakdown, team: null }}
standing={mockStanding} standing={mockStanding}
/> />
); );
@ -198,66 +144,63 @@ describe("TeamScoreBreakdown", () => {
}); });
}); });
describe("Sport Grouping", () => { describe("Pick Number Display", () => {
it("should group participants by sport", () => { it("should format picks as round.pick notation", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
); );
expect(screen.getByText("NFL")).toBeInTheDocument(); expect(screen.getByText("1.01")).toBeInTheDocument();
expect(screen.getByText("F1")).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( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
); );
// NFL has 2 picks expect(screen.getByText("(#1)")).toBeInTheDocument();
const nflSection = screen.getByText("2 picks · 1 completed"); expect(screen.getByText("(#2)")).toBeInTheDocument();
expect(nflSection).toBeInTheDocument(); expect(screen.getByText("(#3)")).toBeInTheDocument();
// F1 has 1 pick
const f1Section = screen.getByText("1 pick · 1 completed");
expect(f1Section).toBeInTheDocument();
}); });
it("should calculate sport-specific point totals", () => { it("should fall back to #pickNumber when numTeams is 0", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={0}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
); );
// NFL total should be 100 (Team A) expect(screen.getByText("#1")).toBeInTheDocument();
// F1 total should be 50 (Driver B) expect(screen.getByText("#2")).toBeInTheDocument();
const pointCells = screen.getAllByText(/^\d+\.\d\d$/) expect(screen.getByText("#3")).toBeInTheDocument();
.map((el) => parseFloat(el.textContent!));
expect(pointCells).toContain(100.0);
expect(pointCells).toContain(50.0);
}); });
}); });
describe("Participant Display", () => { describe("Participant Display", () => {
it("should display all participant names", () => { it("should display all participant names in a single table", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
@ -268,22 +211,29 @@ describe("TeamScoreBreakdown", () => {
expect(screen.getByText("Team C")).toBeInTheDocument(); 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( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
); );
expect(screen.getByText("#1")).toBeInTheDocument(); const nflLinks = screen.getAllByRole("link", { name: "NFL" });
expect(screen.getByText("#2")).toBeInTheDocument(); expect(nflLinks.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("#3")).toBeInTheDocument(); expect(nflLinks[0]).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
);
expect(screen.getAllByText("(R1)")).toHaveLength(2); const f1Link = screen.getByRole("link", { name: "F1" });
expect(screen.getByText("(R2)")).toBeInTheDocument(); expect(f1Link).toHaveAttribute(
"href",
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
);
}); });
it("should show placement badges for completed participants", () => { it("should show placement badges for completed participants", () => {
@ -291,6 +241,7 @@ describe("TeamScoreBreakdown", () => {
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
@ -300,11 +251,12 @@ describe("TeamScoreBreakdown", () => {
expect(screen.getByText("3rd")).toBeInTheDocument(); expect(screen.getByText("3rd")).toBeInTheDocument();
}); });
it("should show pending badge for incomplete participants", () => { it("should show Pending badge for incomplete participants", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
@ -313,109 +265,109 @@ describe("TeamScoreBreakdown", () => {
expect(screen.getByText("Pending")).toBeInTheDocument(); expect(screen.getByText("Pending")).toBeInTheDocument();
}); });
it("should display points for each participant", () => { it("should show Did Not Score badge when finalPosition is 0", () => {
renderWithRouter( const breakdownWithDNS = {
<TeamScoreBreakdown ...mockBreakdown,
leagueId={mockLeagueId} picks: [
seasonId={mockSeasonId} {
breakdown={mockBreakdown} ...mockBreakdown.picks[0],
standing={mockStanding} finalPosition: 0,
/> points: 0,
); },
],
// 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,
},
}; };
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
breakdown={mockBreakdown} numTeams={numTeams}
standing={noPodiumStanding} 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", () => { it("should have back to standings link", () => {
renderWithRouter( renderWithRouter(
<TeamScoreBreakdown <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={mockStanding} standing={mockStanding}
/> />
); );
const backLink = screen.getByRole("link", { name: /back to standings/i }); const backLink = screen.getByRole("link", { name: /back to standings/i });
expect(backLink).toHaveAttribute("href", `/leagues/${mockLeagueId}/standings/${mockSeasonId}`); 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}
/>
); );
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 <TeamScoreBreakdown
leagueId={mockLeagueId} leagueId={mockLeagueId}
seasonId={mockSeasonId} seasonId={mockSeasonId}
numTeams={numTeams}
breakdown={mockBreakdown} breakdown={mockBreakdown}
standing={null} standing={null}
/> />
); );
// Should still show team name and points
expect(screen.getByText("Test Team")).toBeInTheDocument(); 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(); expect(screen.queryByText(/Rank #/)).not.toBeInTheDocument();
}); });
}); });

View file

@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { eq, count } from "drizzle-orm";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -126,6 +126,18 @@ export async function setDraftOrder(
return await createManyDraftSlots(slots); 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 * Randomize the draft order for a season
*/ */

View file

@ -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 const actualPoints = pickBreakdown
.filter((p) => p.isComplete) .filter((p) => p.isComplete)
.reduce((sum, p) => sum + p.points, 0); .reduce((sum, p) => sum + p.points, 0);
@ -246,8 +232,6 @@ export async function getTeamScoreBreakdown(
where: eq(schema.teams.id, teamId), where: eq(schema.teams.id, teamId),
}), }),
picks: pickBreakdown, picks: pickBreakdown,
bySport,
totalPoints: pickBreakdown.reduce((sum, p) => sum + p.points, 0),
actualPoints, actualPoints,
projectedPoints: projectedTotalPoints, projectedPoints: projectedTotalPoints,
completedCount: pickBreakdown.filter((p) => p.isComplete).length, completedCount: pickBreakdown.filter((p) => p.isComplete).length,

View file

@ -3,6 +3,7 @@ import type { Route } from "./+types/$leagueId.standings.$seasonId.teams.$teamId
import { TeamScoreBreakdown } from "~/components/standings/TeamScoreBreakdown"; import { TeamScoreBreakdown } from "~/components/standings/TeamScoreBreakdown";
import { getTeamScoreBreakdown, getTeamStanding } from "~/models/standings"; import { getTeamScoreBreakdown, getTeamStanding } from "~/models/standings";
import { getNumTeamsInSeason } from "~/models/draft-slot";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `${data?.breakdown?.team?.name ?? "Team"} - Brackt` }]; return [{ title: `${data?.breakdown?.team?.name ?? "Team"} - Brackt` }];
@ -17,15 +18,16 @@ export async function loader({ params }: Route.LoaderArgs) {
const { leagueId, seasonId, teamId } = params; const { leagueId, seasonId, teamId } = params;
// Get team breakdown with all picks // 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) { if (!breakdown) {
throw new Response("Team not found", { status: 404 }); throw new Response("Team not found", { status: 404 });
} }
// Get team standing for additional context
const standing = await getTeamStanding(teamId, seasonId);
return { return {
leagueId, leagueId,
seasonId, seasonId,
@ -35,11 +37,12 @@ export async function loader({ params }: Route.LoaderArgs) {
team: breakdown.team || null, team: breakdown.team || null,
}, },
standing, standing,
numTeams,
}; };
} }
export default function TeamBreakdownPage() { export default function TeamBreakdownPage() {
const { leagueId, seasonId, breakdown, standing } = useLoaderData<typeof loader>(); const { leagueId, seasonId, breakdown, standing, numTeams } = useLoaderData<typeof loader>();
return ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
@ -48,6 +51,7 @@ export default function TeamBreakdownPage() {
seasonId={seasonId} seasonId={seasonId}
breakdown={breakdown} breakdown={breakdown}
standing={standing} standing={standing}
numTeams={numTeams}
/> />
</div> </div>
); );