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 { 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>
);

View file

@ -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();
});
});

View file

@ -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
*/

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
.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,

View file

@ -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>
);