diff --git a/app/components/standings/TeamScoreBreakdown.tsx b/app/components/standings/TeamScoreBreakdown.tsx index 3fcec73..9c2c7be 100644 --- a/app/components/standings/TeamScoreBreakdown.tsx +++ b/app/components/standings/TeamScoreBreakdown.tsx @@ -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 }>; - 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 (
- {/* Header with team info and summary */} + {/* Header */}

{breakdown.team.name}

-

- Team Score Breakdown -

+

Team Score Breakdown

{breakdown.actualPoints.toFixed(2)}
-
- Actual Points -
+
Actual Points
{breakdown.projectedPoints > breakdown.actualPoints && (
{breakdown.projectedPoints.toFixed(2)} projected
)} + {remaining > 0 && ( +
+ {remaining} participant{remaining !== 1 ? "s" : ""} remaining +
+ )} {standing && ( Rank #{standing.currentRank} @@ -112,214 +97,81 @@ export function TeamScoreBreakdown({
- {/* Summary stats */} -
- - - - Actual Points - - - -
- {breakdown.actualPoints.toFixed(2)} -
-

- From {breakdown.completedCount} finished -

-
-
- - - - - Projected Points - - - -
- {breakdown.projectedPoints.toFixed(2)} -
-

- +{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(2)} expected value -

-
-
- - - - - Participants - - - -
- {breakdown.completedCount} / {breakdown.totalCount} -
-

- {breakdown.totalCount - breakdown.completedCount} remaining -

-
-
- - {standing && ( - <> - - - - Top Finishes - - - -
- {standing.placementCounts.first > 0 && ( -
- 1st place - {standing.placementCounts.first} -
- )} - {standing.placementCounts.second > 0 && ( -
- 2nd place - {standing.placementCounts.second} -
- )} - {standing.placementCounts.third > 0 && ( -
- 3rd place - {standing.placementCounts.third} -
- )} - {standing.placementCounts.first === 0 && - standing.placementCounts.second === 0 && - standing.placementCounts.third === 0 && ( -

No podium finishes yet

- )} -
-
-
- - - - - All Placements - - - -
- {[ - { 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) => ( -
- {item.label}: - {item.count} -
- ))} -
-
-
- - )} -
- - {/* 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 ( - - -
-
-
- {sportName} - -
- - {sportPicks.length} {sportPicks.length === 1 ? 'pick' : 'picks'} · {sportCompleted} completed - -
-
-
{sportTotal.toFixed(2)}
-
points
-
-
-
- - - - - Pick # - Participant - Position - Points - - - - {sportPicks.map((pick: typeof sportPicks[number]) => ( - - - #{pick.pickNumber} - (R{pick.round}) - - - {pick.participant.name} - - - {pick.isComplete ? ( - pick.finalPosition === 0 ? ( - - Did Not Score - - ) : ( - - ) - ) : ( - Pending - )} - - - {pick.isComplete ? ( - - {pick.points > 0 ? pick.points.toFixed(2) : '0.0'} - - ) : pick.projectedPoints !== null ? ( -
- Projected: - - {pick.projectedPoints.toFixed(2)} - -
- ) : ( - - - )} -
-
- ))} -
-
-
-
- ); - })} + {/* All picks — single flat table */} + + + + + + Pick # + Sport + Participant + Position + Points + + + + {allPicks.map((pick) => ( + + + {numTeams > 0 + ? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}` + : `#${pick.pickNumber}`} + (#{pick.pickNumber}) + + + + {pick.participant.sport} + + + + {pick.participant.name} + + + {pick.isComplete ? ( + (pick.finalPosition ?? 0) === 0 ? ( + Did Not Score + ) : ( + + ) + ) : ( + Pending + )} + + + {pick.isComplete ? ( + + {pick.points > 0 ? pick.points.toFixed(2) : "0.00"} + + ) : pick.projectedPoints !== null ? ( +
+ Projected: + + {pick.projectedPoints.toFixed(2)} + +
+ ) : ( + - + )} +
+
+ ))} +
+
+
+
{/* Navigation */}
- + + ← Back to Standings +
); diff --git a/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx index 7af6bc2..934ca60 100644 --- a/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx +++ b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx @@ -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({ui}); } -/** - * 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( @@ -155,11 +86,41 @@ describe("TeamScoreBreakdown", () => { expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1); }); + it("should show projected points when higher than actual", () => { + renderWithRouter( + + ); + + expect(screen.getByText("175.00")).toBeInTheDocument(); + expect(screen.getByText("projected")).toBeInTheDocument(); + }); + + it("should show remaining participant count", () => { + renderWithRouter( + + ); + + expect(screen.getByText("1 participant remaining")).toBeInTheDocument(); + }); + it("should show team rank badge", () => { renderWithRouter( @@ -168,28 +129,13 @@ describe("TeamScoreBreakdown", () => { expect(screen.getByText("Rank #1")).toBeInTheDocument(); }); - it("should display participant completion stats", () => { - renderWithRouter( - - ); - - expect(screen.getByText("2 / 3")).toBeInTheDocument(); - expect(screen.getByText("1 remaining")).toBeInTheDocument(); - }); - it("should handle null team", () => { - const nullBreakdown = { ...mockBreakdown, team: null }; - renderWithRouter( ); @@ -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( ); - 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( ); - // 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( ); - // 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( @@ -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( ); - 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", () => { @@ -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( @@ -313,109 +265,109 @@ describe("TeamScoreBreakdown", () => { expect(screen.getByText("Pending")).toBeInTheDocument(); }); - it("should display points for each participant", () => { - renderWithRouter( - - ); - - // 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( - - ); - - expect(screen.getByText("1st place")).toBeInTheDocument(); - expect(screen.getByText("3rd place")).toBeInTheDocument(); - }); - - it("should not show placements with zero count", () => { - renderWithRouter( - - ); - - 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( ); - 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( + + ); + + expect(screen.getByText("Did Not Score")).toBeInTheDocument(); + }); + + it("should show projected points for incomplete participants", () => { + renderWithRouter( + + ); + + 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( + + ); + + expect(screen.getByText("0.00")).toBeInTheDocument(); }); }); - describe("Navigation Links", () => { + describe("Navigation", () => { it("should have back to standings link", () => { renderWithRouter( ); 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( - + 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", () => { ); - // 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(); }); }); diff --git a/app/models/draft-slot.ts b/app/models/draft-slot.ts index db94ac7..4ace239 100644 --- a/app/models/draft-slot.ts +++ b/app/models/draft-slot.ts @@ -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 { + 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 */ diff --git a/app/models/standings.ts b/app/models/standings.ts index 99eb587..71ef7a0 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -218,20 +218,6 @@ export async function getTeamScoreBreakdown( }) ); - // Group by sport for easier display - // Structure: { sportName: { sportsSeasonId, picks } } - const bySport: Record = {}; - 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, diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx index 0d16002..b4d0aea 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx @@ -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(); + const { leagueId, seasonId, breakdown, standing, numTeams } = useLoaderData(); return (
@@ -48,6 +51,7 @@ export default function TeamBreakdownPage() { seasonId={seasonId} breakdown={breakdown} standing={standing} + numTeams={numTeams} />
);