From f17699e4c5600db4a30862a7d3f6844ed5dce1df Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 14 Nov 2025 20:01:21 -0800 Subject: [PATCH] feat: Implement team breakdown pages with detailed score breakdown - Created `TeamScoreBreakdown` component to display all drafted participants, their placements, and points. - Added new route for team breakdown at `/leagues/:leagueId/standings/:seasonId/teams/:teamId`. - Enhanced `getTeamScoreBreakdown` model to include `sportsSeasonId` for grouping. - Updated `StandingsTable` to include clickable team name links. - Added functionality to finalize brackets in playoff events, including error handling and recalculating standings. - Implemented tests for `TeamScoreBreakdown` component to ensure proper rendering and functionality. - Updated routing to support new team breakdown feature and ensure seamless navigation. --- app/components/standings/StandingsTable.tsx | 15 +- .../standings/TeamScoreBreakdown.tsx | 297 ++++++++++++ .../__tests__/StandingsTable.test.tsx | 52 ++- .../__tests__/TeamScoreBreakdown.test.tsx | 433 ++++++++++++++++++ app/models/standings.ts | 16 +- app/routes.ts | 4 + ...sons.$id.events.$eventId.bracket.server.ts | 91 ++++ ...ts-seasons.$id.events.$eventId.bracket.tsx | 57 ++- ...min.sports-seasons.$id.events.$eventId.tsx | 121 +++-- ...ueId.standings.$seasonId.teams.$teamId.tsx | 49 ++ .../leagues/$leagueId.standings.$seasonId.tsx | 7 +- plans/scoring-system.md | 37 +- 12 files changed, 1110 insertions(+), 69 deletions(-) create mode 100644 app/components/standings/TeamScoreBreakdown.tsx create mode 100644 app/components/standings/__tests__/TeamScoreBreakdown.test.tsx create mode 100644 app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx diff --git a/app/components/standings/StandingsTable.tsx b/app/components/standings/StandingsTable.tsx index c83b192..db5fadf 100644 --- a/app/components/standings/StandingsTable.tsx +++ b/app/components/standings/StandingsTable.tsx @@ -1,18 +1,24 @@ +import { Link } from "react-router"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table"; import { Badge } from "~/components/ui/badge"; import { type TeamStanding } from "~/types/standings"; interface StandingsTableProps { standings: TeamStanding[]; + leagueId: string; + seasonId: string; showPlacementBreakdown?: boolean; } /** * Display team standings with ranking, points, and placement breakdown * Phase 4.1: Enhanced standings table with tiebreakers + * Phase 4.3: Added clickable links to team breakdown pages */ export function StandingsTable({ standings, + leagueId, + seasonId, showPlacementBreakdown = true, }: StandingsTableProps) { return ( @@ -47,7 +53,14 @@ export function StandingsTable({ )} - {standing.teamName} + + + {standing.teamName} + + {standing.totalPoints.toFixed(1)} diff --git a/app/components/standings/TeamScoreBreakdown.tsx b/app/components/standings/TeamScoreBreakdown.tsx new file mode 100644 index 0000000..63e8544 --- /dev/null +++ b/app/components/standings/TeamScoreBreakdown.tsx @@ -0,0 +1,297 @@ +import { Link } from "react-router"; +import { Card, CardHeader, CardTitle, CardContent, CardDescription } 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; + breakdown: { + team: { + id: string; + name: string; + } | null; + picks: Array<{ + pickNumber: number; + round: number; + participant: { + id: string; + name: string; + sport: string; + sportsSeasonId: string; + }; + finalPosition: number | null; + points: number; + isComplete: boolean; + }>; + bySport: Record }>; + totalPoints: number; + completedCount: number; + totalCount: number; + }; + standing: { + currentRank: number; + placementCounts: { + first: number; + second: number; + third: number; + fourth: number; + fifth: number; + sixth: number; + seventh: number; + eighth: number; + }; + } | null; +} + +/** + * Display detailed team score breakdown with all drafted participants + * Phase 4.3: Team breakdown pages + */ +export function TeamScoreBreakdown({ + leagueId, + seasonId, + breakdown, + standing, +}: TeamScoreBreakdownProps) { + if (!breakdown.team) { + return ( +
+ Team not found +
+ ); + } + + const sportEntries = Object.entries(breakdown.bySport).sort(([a], [b]) => a.localeCompare(b)); + + return ( +
+ {/* Header with team info and summary */} +
+
+

{breakdown.team.name}

+

+ Team Score Breakdown +

+
+
+
+ {breakdown.totalPoints.toFixed(1)} +
+
+ Total Points +
+ {standing && ( + + Rank #{standing.currentRank} + + )} +
+
+ + {/* Summary stats */} +
+ + + + 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(1)}
+
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(1) : '0.0' + ) : '-'} + + + ))} + +
+
+
+ ); + })} + + {/* Navigation */} +
+ +
+
+ ); +} + +/** + * Display placement badge with color coding + */ +function PlacementBadge({ position }: { position: number }) { + const badges: Record = { + 1: { label: "1st", className: "bg-yellow-500 hover:bg-yellow-600 text-white" }, + 2: { label: "2nd", className: "bg-gray-400 hover:bg-gray-500 text-white" }, + 3: { label: "3rd", className: "bg-orange-600 hover:bg-orange-700 text-white" }, + 4: { label: "4th", className: "bg-blue-600 hover:bg-blue-700 text-white" }, + 5: { label: "5th", className: "bg-purple-600 hover:bg-purple-700 text-white" }, + 6: { label: "6th", className: "bg-green-600 hover:bg-green-700 text-white" }, + 7: { label: "7th", className: "bg-pink-600 hover:bg-pink-700 text-white" }, + 8: { label: "8th", className: "bg-indigo-600 hover:bg-indigo-700 text-white" }, + }; + + const badge = badges[position] || { label: `${position}th`, className: "" }; + + return ( + + {badge.label} + + ); +} diff --git a/app/components/standings/__tests__/StandingsTable.test.tsx b/app/components/standings/__tests__/StandingsTable.test.tsx index 2a23a83..0ea95aa 100644 --- a/app/components/standings/__tests__/StandingsTable.test.tsx +++ b/app/components/standings/__tests__/StandingsTable.test.tsx @@ -1,13 +1,23 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import { BrowserRouter } from "react-router"; import { StandingsTable } from "../StandingsTable"; import { type TeamStanding } from "~/models/standings"; +// Helper to wrap component with router context +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + /** * StandingsTable Component Tests * Phase 4.1: Test standings display with tiebreakers and placement breakdown + * Phase 4.3: Added tests for clickable team links */ describe("StandingsTable", () => { + const mockLeagueId = "league-123"; + const mockSeasonId = "season-456"; + const mockStandings: TeamStanding[] = [ { teamId: "team1", @@ -73,7 +83,7 @@ describe("StandingsTable", () => { describe("Basic Rendering", () => { it("should render standings table with all teams", () => { - render(); + renderWithRouter(); expect(screen.getByText("Champions United")).toBeInTheDocument(); expect(screen.getByText("Second Place Squad")).toBeInTheDocument(); @@ -81,7 +91,7 @@ describe("StandingsTable", () => { }); it("should display total points for each team", () => { - render(); + renderWithRouter(); expect(screen.getByText("250.5")).toBeInTheDocument(); expect(screen.getByText("245.0")).toBeInTheDocument(); @@ -89,7 +99,7 @@ describe("StandingsTable", () => { }); it("should show empty state when no standings", () => { - render(); + renderWithRouter(); expect(screen.getByText("No standings data available")).toBeInTheDocument(); }); @@ -97,19 +107,19 @@ describe("StandingsTable", () => { describe("Rank Badges", () => { it("should show trophy icon for 1st place", () => { - render(); + renderWithRouter(); expect(screen.getByText(/πŸ†.*1st/)).toBeInTheDocument(); }); it("should show silver medal icon for 2nd place", () => { - render(); + renderWithRouter(); expect(screen.getByText(/πŸ₯ˆ.*2nd/)).toBeInTheDocument(); }); it("should show bronze medal icon for 3rd place", () => { - render(); + renderWithRouter(); expect(screen.getByText(/πŸ₯‰.*3rd/)).toBeInTheDocument(); }); @@ -123,7 +133,7 @@ describe("StandingsTable", () => { }, ]; - render(); + renderWithRouter(); // Should not have emoji, just the number const badge = screen.getByText("4"); @@ -133,19 +143,19 @@ describe("StandingsTable", () => { describe("Movement Indicators", () => { it("should show up arrow for positive rank change", () => { - render(); + renderWithRouter(); expect(screen.getByText("↑1")).toBeInTheDocument(); }); it("should show down arrow for negative rank change", () => { - render(); + renderWithRouter(); expect(screen.getByText("↓1")).toBeInTheDocument(); }); it("should not show indicator for no rank change", () => { - render(); + renderWithRouter(); // Third team has no change, so no arrow const arrows = screen.queryAllByText(/↑|↓/); @@ -178,7 +188,7 @@ describe("StandingsTable", () => { }, ]; - render(); + renderWithRouter(); // Test Team: 2 firsts, 1 second, 1 fourth, 1 eighth expect(screen.getByText("1stΓ—2")).toBeInTheDocument(); @@ -188,7 +198,7 @@ describe("StandingsTable", () => { }); it("should only show non-zero placement counts", () => { - render(); + renderWithRouter(); // Should not show "3rdΓ—0" for Champions United const placements = screen.queryByText("3rdΓ—0"); @@ -196,7 +206,7 @@ describe("StandingsTable", () => { }); it("should hide placement breakdown when disabled", () => { - render(); + renderWithRouter(); expect(screen.queryByText("1stΓ—2")).not.toBeInTheDocument(); }); @@ -218,7 +228,7 @@ describe("StandingsTable", () => { }, ]; - render(); + renderWithRouter(); expect(screen.getByText("None yet")).toBeInTheDocument(); }); @@ -226,14 +236,14 @@ describe("StandingsTable", () => { describe("Participants Remaining", () => { it("should show remaining count when participants are incomplete", () => { - render(); + renderWithRouter(); expect(screen.getByText("3 remaining")).toBeInTheDocument(); expect(screen.getByText("5 remaining")).toBeInTheDocument(); }); it("should show 'Complete' badge when all participants done", () => { - render(); + renderWithRouter(); expect(screen.getByText("Complete")).toBeInTheDocument(); }); @@ -241,7 +251,7 @@ describe("StandingsTable", () => { describe("Table Structure", () => { it("should have correct table headers", () => { - render(); + renderWithRouter(); expect(screen.getByText("Rank")).toBeInTheDocument(); expect(screen.getByText("Team")).toBeInTheDocument(); @@ -251,7 +261,7 @@ describe("StandingsTable", () => { }); it("should render teams in order", () => { - const { container } = render(); + const { container } = renderWithRouter(); const rows = container.querySelectorAll("tbody tr"); expect(rows).toHaveLength(3); @@ -287,7 +297,7 @@ describe("StandingsTable", () => { }, ]; - render(); + renderWithRouter(); // Both teams should show rank 1 const firstPlaceBadges = screen.getAllByText(/πŸ†.*1st/); @@ -300,7 +310,7 @@ describe("StandingsTable", () => { describe("Accessibility", () => { it("should have title attributes for movement indicators", () => { - const { container } = render(); + const { container } = renderWithRouter(); const upArrow = container.querySelector('[title*="Up"]'); expect(upArrow).toBeInTheDocument(); @@ -312,7 +322,7 @@ describe("StandingsTable", () => { }); it("should have title attributes for placement counts", () => { - const { container } = render(); + const { container } = renderWithRouter(); const firstPlace = container.querySelector('[title*="1st place"]'); expect(firstPlace).toBeInTheDocument(); diff --git a/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx new file mode 100644 index 0000000..bf99205 --- /dev/null +++ b/app/components/standings/__tests__/TeamScoreBreakdown.test.tsx @@ -0,0 +1,433 @@ +import { describe, it, expect } from "vitest"; +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"; + + const mockBreakdown = { + team: { + id: "team-1", + name: "Test Team", + }, + picks: [ + { + pickNumber: 1, + round: 1, + participant: { + id: "p1", + name: "Team A", + sport: "NFL", + sportsSeasonId: "ss-nfl", + }, + finalPosition: 1, + points: 100, + isComplete: true, + }, + { + pickNumber: 2, + round: 1, + participant: { + id: "p2", + name: "Driver B", + sport: "F1", + sportsSeasonId: "ss-f1", + }, + finalPosition: 3, + points: 50, + isComplete: true, + }, + { + pickNumber: 3, + round: 2, + participant: { + id: "p3", + name: "Team C", + sport: "NFL", + sportsSeasonId: "ss-nfl", + }, + finalPosition: null, + points: 0, + 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, + isComplete: true, + }, + { + pickNumber: 3, + round: 2, + participant: { + id: "p3", + name: "Team C", + sport: "NFL", + sportsSeasonId: "ss-nfl", + }, + finalPosition: null, + points: 0, + 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, + isComplete: true, + }, + ], + }, + }, + totalPoints: 150, + 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, + }, + }; + + describe("Basic Rendering", () => { + it("should render team name and total points", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Test Team")).toBeInTheDocument(); + expect(screen.getByText("150.0")).toBeInTheDocument(); + }); + + it("should show team rank badge", () => { + renderWithRouter( + + ); + + 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( + + ); + + expect(screen.getByText("Team not found")).toBeInTheDocument(); + }); + }); + + describe("Sport Grouping", () => { + it("should group participants by sport", () => { + renderWithRouter( + + ); + + expect(screen.getByText("NFL")).toBeInTheDocument(); + expect(screen.getByText("F1")).toBeInTheDocument(); + }); + + it("should show correct pick counts per sport", () => { + 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(); + }); + + it("should calculate sport-specific point totals", () => { + renderWithRouter( + + ); + + // NFL total should be 100 (Team A) + // F1 total should be 50 (Driver B) + const pointCells = screen.getAllByText(/^\d+\.\d$/) + .map((el) => parseFloat(el.textContent!)); + + expect(pointCells).toContain(100.0); + expect(pointCells).toContain(50.0); + }); + }); + + describe("Participant Display", () => { + it("should display all participant names", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Team A")).toBeInTheDocument(); + expect(screen.getByText("Driver B")).toBeInTheDocument(); + expect(screen.getByText("Team C")).toBeInTheDocument(); + }); + + it("should show pick numbers and rounds", () => { + renderWithRouter( + + ); + + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + + expect(screen.getAllByText("(R1)")).toHaveLength(2); + expect(screen.getByText("(R2)")).toBeInTheDocument(); + }); + + it("should show placement badges for completed participants", () => { + renderWithRouter( + + ); + + expect(screen.getByText("1st")).toBeInTheDocument(); + expect(screen.getByText("3rd")).toBeInTheDocument(); + }); + + it("should show pending badge for incomplete participants", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Pending")).toBeInTheDocument(); + }); + + it("should display points for each participant", () => { + renderWithRouter( + + ); + + // Should have 100.0 and 50.0 for completed picks, and "-" for pending + const pointValues = screen.getAllByRole("cell") + .filter((cell) => cell.textContent?.match(/^\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, + }, + }; + + renderWithRouter( + + ); + + expect(screen.getByText("No podium finishes yet")).toBeInTheDocument(); + }); + }); + + describe("Navigation Links", () => { + 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( + + ); + + 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/")); + }); + }); + + describe("Without Standing Data", () => { + it("should render without standing prop", () => { + renderWithRouter( + + ); + + // Should still show team name and points + expect(screen.getByText("Test Team")).toBeInTheDocument(); + expect(screen.getByText("150.0")).toBeInTheDocument(); + + // But not show rank badge + expect(screen.queryByText(/Rank #/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/models/standings.ts b/app/models/standings.ts index 5305ee3..731b2fd 100644 --- a/app/models/standings.ts +++ b/app/models/standings.ts @@ -160,21 +160,27 @@ export async function getTeamScoreBreakdown( id: pick.participant.id, name: pick.participant.name, sport: pick.participant.sportsSeason.sport.name, + sportsSeasonId: pick.participant.sportsSeasonId, }, - finalPosition: result?.finalPosition || null, + finalPosition: result?.finalPosition ?? null, points, - isComplete: !!result?.finalPosition, + // A participant is complete if they have a result record (even if finalPosition is 0) + isComplete: !!result, }; }); // Group by sport for easier display - const bySport: Record = {}; + // Structure: { sportName: { sportsSeasonId, picks } } + const bySport: Record = {}; for (const pick of pickBreakdown) { const sport = pick.participant.sport; if (!bySport[sport]) { - bySport[sport] = []; + bySport[sport] = { + sportsSeasonId: pick.participant.sportsSeasonId, + picks: [], + }; } - bySport[sport].push(pick); + bySport[sport].picks.push(pick); } return { diff --git a/app/routes.ts b/app/routes.ts index 94fc199..2a2b3ce 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -22,6 +22,10 @@ export default [ "leagues/:leagueId/standings/:seasonId", "routes/leagues/$leagueId.standings.$seasonId.tsx" ), + route( + "leagues/:leagueId/standings/:seasonId/teams/:teamId", + "routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx" + ), route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"), route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"), route("api/queue/add", "routes/api/queue.add.ts"), diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 240f488..a900af1 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -384,5 +384,96 @@ export async function action({ request, params }: Route.ActionArgs) { } } + if (intent === "finalize-bracket") { + try { + // Get the event + const event = await getScoringEventById(params.eventId); + if (!event) { + return { error: "Event not found" }; + } + + // Get all matches + const matches = await findPlayoffMatchesByEventId(params.eventId); + if (matches.length === 0) { + return { error: "No bracket exists for this event" }; + } + + // Get template to determine round order + const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; + if (!template) { + return { error: "Bracket template not found" }; + } + + // Verify ALL matches are complete + const incompleteMatches = matches.filter((m) => !m.isComplete); + if (incompleteMatches.length > 0) { + return { + error: `Cannot finalize: ${incompleteMatches.length} match(es) still incomplete`, + }; + } + + // Get all participants in this sports season + const { findParticipantsBySportsSeasonId } = await import("~/models/participant"); + const allParticipants = await findParticipantsBySportsSeasonId(params.id); + + // Get participants in matches + const participantsInMatches = new Set( + matches.flatMap((m) => [m.participant1Id, m.participant2Id].filter(Boolean)) + ); + + // Process all rounds in order + const db = database(); + for (const round of template.rounds) { + const roundMatches = matches.filter((m) => m.round === round.name); + if (roundMatches.length === 0) continue; + + // Set playoffRound and process this round + await db + .update(schema.scoringEvents) + .set({ playoffRound: round.name, updatedAt: new Date() }) + .where(eq(schema.scoringEvents.id, params.eventId)); + + await processPlayoffEvent(params.eventId, db); + } + + // Assign 0 points to participants not in the bracket (Q20) + const { setParticipantResult } = await import("~/models/participant-result"); + for (const participant of allParticipants) { + if (!participantsInMatches.has(participant.id)) { + await setParticipantResult( + participant.id, + params.id, + 0 // 0 placement = 0 points + ); + } + } + + // Mark event as complete + await db + .update(schema.scoringEvents) + .set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() }) + .where(eq(schema.scoringEvents.id, params.eventId)); + + // Recalculate standings for all affected fantasy seasons + const { recalculateStandings } = await import("~/models/scoring-calculator"); + const { findSeasonSportsBySportsSeasonId } = await import("~/models/season-sport"); + const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); + + for (const seasonSport of seasonSports) { + await recalculateStandings(seasonSport.seasonId, db); + } + + return { + success: `Bracket finalized! All placements calculated and standings updated.`, + }; + } catch (error) { + console.error("Error finalizing bracket:", error); + return { + error: + error instanceof Error ? error.message : "Failed to finalize bracket", + }; + } + } + return { error: "Invalid action" }; } diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx index 11f160c..ae3c5f3 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx @@ -151,6 +151,11 @@ export default function EventBracket({ ); }, [matches, participants]); + // Check if all matches are complete + const allMatchesComplete = useMemo(() => { + return matches.length > 0 && matches.every((m: any) => m.isComplete); + }, [matches]); + return (
@@ -365,7 +370,7 @@ export default function EventBracket({ ))} {/* Complete Round Button */} - {availableRounds.length > 0 && ( + {availableRounds.length > 0 && !event.isComplete && ( Complete Round @@ -400,6 +405,56 @@ export default function EventBracket({ )} + + {/* Finalize Bracket Button */} + {allMatchesComplete && !event.isComplete && ( + + + + + Finalize Bracket + + + All matches are complete! Process all rounds and assign final placements. + + + +
+ +
+
+

This will:

+
    +
  • Process all playoff rounds in order
  • +
  • Assign fantasy placements (1st-8th) based on bracket results
  • +
  • Assign 0 points to participants not in the bracket
  • +
  • Mark the event as complete
  • +
  • Recalculate all team standings
  • +
+
+ +
+
+
+
+ )} + + {/* Event Complete Badge */} + {event.isComplete && ( + + +
+ + Event Complete + + {event.completedAt && new Date(event.completedAt).toLocaleDateString()} + +
+
+
+ )}
diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx index a9d22d9..3fb9ae2 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -364,34 +364,35 @@ export default function EventResults({ )} - {/* Current Results */} - - -
-
- Results - - {results.length} of {participants.length} participants have - results - + {/* Current Results - Hide for playoff events since they use the bracket */} + {event.eventType !== "playoff_game" && ( + + +
+
+ Results + + {results.length} of {participants.length} participants have + results + +
+ {!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && ( +
+ + +
+ )}
- {!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && ( -
- - -
- )} -
- - - {sortedResults.length === 0 ? ( -

- No results added yet. Add results using the form above. -

- ) : ( + + + {sortedResults.length === 0 ? ( +

+ No results added yet. Add results using the form above. +

+ ) : ( @@ -484,15 +485,71 @@ export default function EventResults({ )} + )} + + {/* Bracket Explanation Card for Playoff Events */} + {event.eventType === "playoff_game" && !participantResults?.length && ( + + + + + Bracket Event + + + This is a bracket/playoff event. Results are managed through the bracket interface. + + + +
+

To complete this event:

+
    +
  1. Click "Manage Bracket" above to set match winners
  2. +
  3. Once all matches are complete, click "Finalize Bracket"
  4. +
  5. Fantasy placements and points will appear below automatically
  6. +
+
+ +
+
+
+
+ )} {/* Participant Results with Fantasy Points */} {participantResults && participantResults.length > 0 && ( - + - Fantasy Points Awarded - - Points calculated from bracket placements (sorted by position) - +
+
+ + {event.eventType === "playoff_game" ? ( + <> + + Fantasy Points Awarded (from Bracket) + + ) : ( + "Fantasy Points Awarded" + )} + + + {event.eventType === "playoff_game" + ? `${participantResults.length} participants assigned placements from bracket results` + : "Points calculated from bracket placements (sorted by position)" + } + +
+ {event.eventType === "playoff_game" && event.isComplete && ( + + + Bracket Finalized + + )} +
diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx new file mode 100644 index 0000000..a2ec337 --- /dev/null +++ b/app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx @@ -0,0 +1,49 @@ +import { useLoaderData } from "react-router"; +import type { Route } from "./+types/$leagueId.standings.$seasonId.teams.$teamId"; +import { TeamScoreBreakdown } from "~/components/standings/TeamScoreBreakdown"; +import { getTeamScoreBreakdown, getTeamStanding } from "~/models/standings"; + +/** + * Team score breakdown page + * Shows all drafted participants with their placements and points + * Phase 4.3: Team breakdown pages + */ +export async function loader({ params }: Route.LoaderArgs) { + const { leagueId, seasonId, teamId } = params; + + // Get team breakdown with all picks + const breakdown = await getTeamScoreBreakdown(teamId, 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, + teamId, + breakdown: { + ...breakdown, + team: breakdown.team || null, + }, + standing, + }; +} + +export default function TeamBreakdownPage() { + const { leagueId, seasonId, breakdown, standing } = useLoaderData(); + + return ( +
+ +
+ ); +} diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx index dd10679..568e718 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -125,7 +125,12 @@ export default function LeagueStandings() {

) : ( - + )} diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 2f0c7ae..e66de49 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -1321,12 +1321,33 @@ scoring_events { - Comprehensive test suite with 15 unit tests in `app/models/__tests__/standings-snapshots.test.ts` - All 386 tests passing βœ… -- [ ] **4.3** Team breakdown pages - - [ ] `TeamScoreBreakdown` component - - [ ] List all drafted participants - - [ ] Show placement and points per participant - - [ ] Group by sport season - - [ ] Link to sport season pages +- [x] **4.3** Team breakdown pages βœ… *Completed* + - [x] `TeamScoreBreakdown` component + - [x] List all drafted participants + - [x] Show placement and points per participant + - [x] Group by sport season + - [x] Link to sport season pages + + **Implementation Notes**: + - Created `TeamScoreBreakdown` component (app/components/standings/TeamScoreBreakdown.tsx) + - Created team breakdown route at `/leagues/:leagueId/standings/:seasonId/teams/:teamId` + - Updated `getTeamScoreBreakdown` model to include sportsSeasonId in grouping + - Added clickable team name links in `StandingsTable` component + - Each sport section includes "View Details β†’" link to sport season page + - Displays summary stats: total points, rank badge, participant completion + - Shows placement breakdown with top finishes highlighted + - Groups participants by sport with per-sport totals + - All participants show pick number, round, name, placement badge, and points + - "Back to Standings" navigation link + - Comprehensive test suite with 18 tests (all passing) + - Files created/updated: + - app/components/standings/TeamScoreBreakdown.tsx (new component) + - app/routes/leagues/$leagueId.standings.$seasonId.teams.$teamId.tsx (new route) + - app/components/standings/StandingsTable.tsx (added links) + - app/models/standings.ts (enhanced bySport grouping) + - app/routes.ts (added team breakdown route) + - app/components/standings/__tests__/TeamScoreBreakdown.test.tsx (new tests) + - app/components/standings/__tests__/StandingsTable.test.tsx (updated for router context) - [x] **4.4** Sport season pages with ownership βœ… *Completed in Phase 3.4* - [x] Create sport season detail route (`/leagues/:leagueId/sports-seasons/:sportsSeasonId`) @@ -1451,6 +1472,6 @@ scoring_events { - ⏳ **Phase 5**: Expected Value - TODO - ⏳ **Phase 6**: Polish & Optimization - TODO -**Current Focus**: Phase 4 - Standings & Display (4.1 and 4.2 complete, 4.5 partially complete) +**Current Focus**: Phase 4 - Standings & Display (4.1, 4.2, and 4.3 complete, 4.5 partially complete) -**Total Test Count**: 386 tests passing βœ… +**Total Test Count**: 404 tests passing βœ