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.
This commit is contained in:
parent
9d38bdf1ca
commit
f17699e4c5
12 changed files with 1110 additions and 69 deletions
|
|
@ -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({
|
|||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{standing.teamName}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
to={`/leagues/${leagueId}/standings/${seasonId}/teams/${standing.teamId}`}
|
||||
className="hover:underline hover:text-primary transition-colors"
|
||||
>
|
||||
{standing.teamName}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{standing.totalPoints.toFixed(1)}
|
||||
</TableCell>
|
||||
|
|
|
|||
297
app/components/standings/TeamScoreBreakdown.tsx
Normal file
297
app/components/standings/TeamScoreBreakdown.tsx
Normal file
|
|
@ -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<string, { sportsSeasonId: string; picks: Array<{
|
||||
pickNumber: number;
|
||||
round: number;
|
||||
participant: {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string;
|
||||
sportsSeasonId: string;
|
||||
};
|
||||
finalPosition: number | null;
|
||||
points: number;
|
||||
isComplete: boolean;
|
||||
}> }>;
|
||||
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 (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
Team not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sportEntries = Object.entries(breakdown.bySport).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with team info and summary */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{breakdown.totalPoints.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Total Points
|
||||
</div>
|
||||
{standing && (
|
||||
<Badge className="mt-2" variant={standing.currentRank <= 3 ? "default" : "outline"}>
|
||||
Rank #{standing.currentRank}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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-yellow-600 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-gray-500 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-orange-600 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(1)}</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" className="bg-gray-200 text-gray-700">
|
||||
Did Not Score
|
||||
</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition!} />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{pick.isComplete ? (
|
||||
pick.points > 0 ? pick.points.toFixed(1) : '0.0'
|
||||
) : '-'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display placement badge with color coding
|
||||
*/
|
||||
function PlacementBadge({ position }: { position: number }) {
|
||||
const badges: Record<number, { label: string; className: string }> = {
|
||||
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 className={badge.className}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
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(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
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(<StandingsTable standings={[]} />);
|
||||
renderWithRouter(<StandingsTable standings={[]} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
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(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText(/🏆.*1st/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show silver medal icon for 2nd place", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText(/🥈.*2nd/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show bronze medal icon for 3rd place", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText(/🥉.*3rd/)).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -123,7 +133,7 @@ describe("StandingsTable", () => {
|
|||
},
|
||||
];
|
||||
|
||||
render(<StandingsTable standings={standings} />);
|
||||
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
// 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(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText("↑1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show down arrow for negative rank change", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText("↓1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show indicator for no rank change", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
// Third team has no change, so no arrow
|
||||
const arrows = screen.queryAllByText(/↑|↓/);
|
||||
|
|
@ -178,7 +188,7 @@ describe("StandingsTable", () => {
|
|||
},
|
||||
];
|
||||
|
||||
render(<StandingsTable standings={singleTeam} showPlacementBreakdown />);
|
||||
renderWithRouter(<StandingsTable standings={singleTeam} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
||||
|
||||
// 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(<StandingsTable standings={mockStandings} showPlacementBreakdown />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
||||
|
||||
// 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(<StandingsTable standings={mockStandings} showPlacementBreakdown={false} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={false} />);
|
||||
|
||||
expect(screen.queryByText("1st×2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -218,7 +228,7 @@ describe("StandingsTable", () => {
|
|||
},
|
||||
];
|
||||
|
||||
render(<StandingsTable standings={standings} showPlacementBreakdown />);
|
||||
renderWithRouter(<StandingsTable standings={standings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
||||
|
||||
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(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText("3 remaining")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 remaining")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Complete' badge when all participants done", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
expect(screen.getByText("Complete")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -241,7 +251,7 @@ describe("StandingsTable", () => {
|
|||
|
||||
describe("Table Structure", () => {
|
||||
it("should have correct table headers", () => {
|
||||
render(<StandingsTable standings={mockStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
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(<StandingsTable standings={mockStandings} />);
|
||||
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
expect(rows).toHaveLength(3);
|
||||
|
|
@ -287,7 +297,7 @@ describe("StandingsTable", () => {
|
|||
},
|
||||
];
|
||||
|
||||
render(<StandingsTable standings={tiedStandings} />);
|
||||
renderWithRouter(<StandingsTable standings={tiedStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
// 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(<StandingsTable standings={mockStandings} />);
|
||||
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
|
||||
|
||||
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(<StandingsTable standings={mockStandings} showPlacementBreakdown />);
|
||||
const { container } = renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} showPlacementBreakdown={true} />);
|
||||
|
||||
const firstPlace = container.querySelector('[title*="1st place"]');
|
||||
expect(firstPlace).toBeInTheDocument();
|
||||
|
|
|
|||
433
app/components/standings/__tests__/TeamScoreBreakdown.test.tsx
Normal file
433
app/components/standings/__tests__/TeamScoreBreakdown.test.tsx
Normal file
|
|
@ -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(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("150.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show team rank badge", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Team not found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sport Grouping", () => {
|
||||
it("should group participants by sport", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("NFL")).toBeInTheDocument();
|
||||
expect(screen.getByText("F1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show correct pick counts per sport", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
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();
|
||||
});
|
||||
|
||||
it("should calculate sport-specific point totals", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("1st")).toBeInTheDocument();
|
||||
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show pending badge for incomplete participants", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
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.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(
|
||||
<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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={noPodiumStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No podium finishes yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Navigation Links", () => {
|
||||
it("should have back to standings link", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
breakdown={mockBreakdown}
|
||||
standing={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, typeof pickBreakdown> = {};
|
||||
// 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] = [];
|
||||
bySport[sport] = {
|
||||
sportsSeasonId: pick.participant.sportsSeasonId,
|
||||
picks: [],
|
||||
};
|
||||
}
|
||||
bySport[sport].push(pick);
|
||||
bySport[sport].picks.push(pick);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
|
@ -365,7 +370,7 @@ export default function EventBracket({
|
|||
))}
|
||||
|
||||
{/* Complete Round Button */}
|
||||
{availableRounds.length > 0 && (
|
||||
{availableRounds.length > 0 && !event.isComplete && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Complete Round</CardTitle>
|
||||
|
|
@ -400,6 +405,56 @@ export default function EventBracket({
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Finalize Bracket Button */}
|
||||
{allMatchesComplete && !event.isComplete && (
|
||||
<Card className="border-green-500 bg-green-50 dark:bg-green-950">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-green-600" />
|
||||
Finalize Bracket
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All matches are complete! Process all rounds and assign final placements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="finalize-bracket" />
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm space-y-2">
|
||||
<p className="font-medium">This will:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Process all playoff rounds in order</li>
|
||||
<li>Assign fantasy placements (1st-8th) based on bracket results</li>
|
||||
<li>Assign 0 points to participants not in the bracket</li>
|
||||
<li>Mark the event as complete</li>
|
||||
<li>Recalculate all team standings</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-green-600 hover:bg-green-700">
|
||||
Finalize Bracket & Update Standings
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Event Complete Badge */}
|
||||
{event.isComplete && (
|
||||
<Card className="border-blue-500 bg-blue-50 dark:bg-blue-950">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-400">
|
||||
<Trophy className="h-5 w-5" />
|
||||
<span className="font-semibold">Event Complete</span>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{event.completedAt && new Date(event.completedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -364,34 +364,35 @@ export default function EventResults({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Current Results */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Results</CardTitle>
|
||||
<CardDescription>
|
||||
{results.length} of {participants.length} participants have
|
||||
results
|
||||
</CardDescription>
|
||||
{/* Current Results - Hide for playoff events since they use the bracket */}
|
||||
{event.eventType !== "playoff_game" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Results</CardTitle>
|
||||
<CardDescription>
|
||||
{results.length} of {participants.length} participants have
|
||||
results
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="complete" />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Mark Event Complete
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
{!event.isComplete && results.length > 0 && event.eventType !== "final_standings" && (
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="complete" />
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Mark Event Complete
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedResults.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No results added yet. Add results using the form above.
|
||||
</p>
|
||||
) : (
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedResults.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No results added yet. Add results using the form above.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -484,15 +485,71 @@ export default function EventResults({
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bracket Explanation Card for Playoff Events */}
|
||||
{event.eventType === "playoff_game" && !participantResults?.length && (
|
||||
<Card className="border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-700 dark:text-blue-300">
|
||||
<Brackets className="inline mr-2 h-5 w-5" />
|
||||
Bracket Event
|
||||
</CardTitle>
|
||||
<CardDescription className="text-blue-600 dark:text-blue-400">
|
||||
This is a bracket/playoff event. Results are managed through the bracket interface.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>To complete this event:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-2">
|
||||
<li>Click "Manage Bracket" above to set match winners</li>
|
||||
<li>Once all matches are complete, click "Finalize Bracket"</li>
|
||||
<li>Fantasy placements and points will appear below automatically</li>
|
||||
</ol>
|
||||
<div className="pt-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
|
||||
<Brackets className="mr-2 h-4 w-4" />
|
||||
Go to Bracket Manager
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Participant Results with Fantasy Points */}
|
||||
{participantResults && participantResults.length > 0 && (
|
||||
<Card>
|
||||
<Card className={event.eventType === "playoff_game" ? "border-green-200 dark:border-green-800" : ""}>
|
||||
<CardHeader>
|
||||
<CardTitle>Fantasy Points Awarded</CardTitle>
|
||||
<CardDescription>
|
||||
Points calculated from bracket placements (sorted by position)
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className={event.eventType === "playoff_game" ? "text-green-700 dark:text-green-300" : ""}>
|
||||
{event.eventType === "playoff_game" ? (
|
||||
<>
|
||||
<Trophy className="inline mr-2 h-5 w-5" />
|
||||
Fantasy Points Awarded (from Bracket)
|
||||
</>
|
||||
) : (
|
||||
"Fantasy Points Awarded"
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{event.eventType === "playoff_game"
|
||||
? `${participantResults.length} participants assigned placements from bracket results`
|
||||
: "Points calculated from bracket placements (sorted by position)"
|
||||
}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{event.eventType === "playoff_game" && event.isComplete && (
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Bracket Finalized
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
|
|
|
|||
|
|
@ -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<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4">
|
||||
<TeamScoreBreakdown
|
||||
leagueId={leagueId}
|
||||
seasonId={seasonId}
|
||||
breakdown={breakdown}
|
||||
standing={standing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -125,7 +125,12 @@ export default function LeagueStandings() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<StandingsTable standings={standings} showPlacementBreakdown={true} />
|
||||
<StandingsTable
|
||||
standings={standings}
|
||||
leagueId={league.id}
|
||||
seasonId={season.id}
|
||||
showPlacementBreakdown={true}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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 ✅
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue