feat: highlight owned participants in standings and fix schedule event badges (#83)
- SeasonStandings: highlight rows for the logged-in user's drafted participants with electric accent (in-points) or muted (outside points) left border + star icon - Loader: derive userParticipantIds from current user's teams and draft picks - EventSchedule: hide type badge for schedule_event; show "Results Pending" only when event is past and not yet marked complete (matches admin page logic) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0960c3c30
commit
8aa1726b12
23 changed files with 1409 additions and 174 deletions
|
|
@ -14,7 +14,7 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2 } from "lucide-react";
|
import { TrendingUp, TrendingDown, Minus, Flag, CheckCircle2, Star } from "lucide-react";
|
||||||
|
|
||||||
interface SeasonStanding {
|
interface SeasonStanding {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -37,6 +37,7 @@ interface TeamOwnership {
|
||||||
interface SeasonStandingsProps {
|
interface SeasonStandingsProps {
|
||||||
standings: SeasonStanding[];
|
standings: SeasonStanding[];
|
||||||
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
|
teamOwnerships?: TeamOwnership[]; // Which teams own which participants
|
||||||
|
userParticipantIds?: string[]; // Participants drafted by the current user
|
||||||
showOwnership?: boolean;
|
showOwnership?: boolean;
|
||||||
isFinalized?: boolean; // Whether season is complete
|
isFinalized?: boolean; // Whether season is complete
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -56,11 +57,13 @@ interface SeasonStandingsProps {
|
||||||
export function SeasonStandings({
|
export function SeasonStandings({
|
||||||
standings,
|
standings,
|
||||||
teamOwnerships = [],
|
teamOwnerships = [],
|
||||||
|
userParticipantIds = [],
|
||||||
showOwnership = true,
|
showOwnership = true,
|
||||||
isFinalized = false,
|
isFinalized = false,
|
||||||
title = "Championship Standings",
|
title = "Championship Standings",
|
||||||
description,
|
description,
|
||||||
}: SeasonStandingsProps) {
|
}: SeasonStandingsProps) {
|
||||||
|
const userParticipantSet = new Set(userParticipantIds);
|
||||||
// Create ownership map for fast lookup
|
// Create ownership map for fast lookup
|
||||||
const ownershipMap = new Map<string, TeamOwnership>();
|
const ownershipMap = new Map<string, TeamOwnership>();
|
||||||
teamOwnerships.forEach((ownership) => {
|
teamOwnerships.forEach((ownership) => {
|
||||||
|
|
@ -130,45 +133,11 @@ export function SeasonStandings({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get position badge with medals for top 3
|
// Get position display
|
||||||
const getPositionBadge = (position: number, isTied: boolean) => {
|
const getPositionBadge = (position: number, isTied: boolean) => {
|
||||||
const positionText = isTied ? `T${position}` : position.toString();
|
const suffix = position === 1 ? "st" : position === 2 ? "nd" : position === 3 ? "rd" : "th";
|
||||||
|
const positionText = isTied ? `T${position}` : `${position}${suffix}`;
|
||||||
if (position === 1 && !isTied) {
|
return <span className="font-medium">{positionText}</span>;
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🥇</span>
|
|
||||||
<span className="font-bold text-lg">{positionText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (position === 2 && !isTied) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🥈</span>
|
|
||||||
<span className="font-semibold">{positionText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (position === 3 && !isTied) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🥉</span>
|
|
||||||
<span className="font-semibold">{positionText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<span className="font-medium">
|
|
||||||
{positionText}
|
|
||||||
{position === 1
|
|
||||||
? "st"
|
|
||||||
: position === 2
|
|
||||||
? "nd"
|
|
||||||
: position === 3
|
|
||||||
? "rd"
|
|
||||||
: "th"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if multiple participants share the same position
|
// Check if multiple participants share the same position
|
||||||
|
|
@ -238,9 +207,9 @@ export function SeasonStandings({
|
||||||
<TableHead className="w-20">Change</TableHead>
|
<TableHead className="w-20">Change</TableHead>
|
||||||
)}
|
)}
|
||||||
<TableHead>Participant</TableHead>
|
<TableHead>Participant</TableHead>
|
||||||
<TableHead className="text-right">Points</TableHead>
|
<TableHead className="text-right w-20">Points</TableHead>
|
||||||
{showOwnership && (
|
{showOwnership && (
|
||||||
<TableHead className="w-24 text-center">Team</TableHead>
|
<TableHead className="w-40 pl-6">Drafted By</TableHead>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -251,17 +220,23 @@ export function SeasonStandings({
|
||||||
const ownership = getOwnership(standing.participant.id);
|
const ownership = getOwnership(standing.participant.id);
|
||||||
const isTied = checkIfTied(standing);
|
const isTied = checkIfTied(standing);
|
||||||
const isTop8 = standing.position <= 8;
|
const isTop8 = standing.position <= 8;
|
||||||
|
const isOwned = userParticipantSet.has(standing.participant.id);
|
||||||
|
|
||||||
|
let rowClass = "";
|
||||||
|
if (isOwned && isTop8) {
|
||||||
|
rowClass = "bg-electric/8 border-l-2 border-l-electric font-medium";
|
||||||
|
} else if (isOwned && !isTop8) {
|
||||||
|
rowClass = "bg-muted/20 border-l-2 border-l-muted-foreground/40 opacity-80";
|
||||||
|
} else if (isTop8) {
|
||||||
|
rowClass = standing.position <= 3 ? "bg-muted/30 font-medium" : "";
|
||||||
|
} else {
|
||||||
|
rowClass = "opacity-60";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={standing.id}
|
key={standing.id}
|
||||||
className={
|
className={rowClass}
|
||||||
isTop8
|
|
||||||
? standing.position <= 3
|
|
||||||
? "bg-muted/30 font-medium"
|
|
||||||
: ""
|
|
||||||
: "opacity-60"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{getPositionBadge(standing.position, isTied)}
|
{getPositionBadge(standing.position, isTied)}
|
||||||
|
|
@ -275,7 +250,12 @@ export function SeasonStandings({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{standing.participant.name}
|
<span>{standing.participant.name}</span>
|
||||||
|
{isOwned && (
|
||||||
|
<Star
|
||||||
|
className={`inline ml-1.5 h-3 w-3 fill-current ${isTop8 ? "text-electric" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<span
|
<span
|
||||||
|
|
@ -285,17 +265,20 @@ export function SeasonStandings({
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{parseFloat(standing.championshipPoints).toFixed(1)} pts
|
{Math.round(parseFloat(standing.championshipPoints))}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showOwnership && (
|
{showOwnership && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="pl-6">
|
||||||
{ownership ? (
|
{ownership ? (
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="cursor-help inline-block"
|
|
||||||
title={`${ownership.teamName}${ownership.ownerName ? ` (${ownership.ownerName})` : ""}`}
|
|
||||||
>
|
|
||||||
{getTeamAvatar(ownership.teamName)}
|
{getTeamAvatar(ownership.teamName)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{ownership.teamName}</p>
|
||||||
|
{ownership.ownerName && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{ownership.ownerName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import { AlertCircle } from "lucide-react";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type ScoringPattern =
|
type ScoringPattern =
|
||||||
| "single_elimination_playoff"
|
| "playoff_bracket"
|
||||||
| "page_playoff"
|
|
||||||
| "season_standings"
|
| "season_standings"
|
||||||
| "qualifying_points";
|
| "qualifying_points";
|
||||||
|
|
||||||
|
|
@ -107,6 +106,7 @@ interface SportSeasonDisplayProps {
|
||||||
|
|
||||||
// Shared data
|
// Shared data
|
||||||
teamOwnerships?: TeamOwnership[];
|
teamOwnerships?: TeamOwnership[];
|
||||||
|
userParticipantIds?: string[];
|
||||||
scoringRules?: ScoringRules | null;
|
scoringRules?: ScoringRules | null;
|
||||||
showOwnership?: boolean;
|
showOwnership?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -125,13 +125,13 @@ export function SportSeasonDisplay({
|
||||||
majorsCompleted = 0,
|
majorsCompleted = 0,
|
||||||
canFinalize = false,
|
canFinalize = false,
|
||||||
teamOwnerships = [],
|
teamOwnerships = [],
|
||||||
|
userParticipantIds = [],
|
||||||
scoringRules = null,
|
scoringRules = null,
|
||||||
showOwnership = true,
|
showOwnership = true,
|
||||||
}: SportSeasonDisplayProps) {
|
}: SportSeasonDisplayProps) {
|
||||||
// Pattern detection and component selection
|
// Pattern detection and component selection
|
||||||
switch (scoringPattern) {
|
switch (scoringPattern) {
|
||||||
case "single_elimination_playoff":
|
case "playoff_bracket":
|
||||||
case "page_playoff":
|
|
||||||
// Display playoff bracket
|
// Display playoff bracket
|
||||||
return (
|
return (
|
||||||
<PlayoffBracket
|
<PlayoffBracket
|
||||||
|
|
@ -140,11 +140,7 @@ export function SportSeasonDisplay({
|
||||||
teamOwnerships={teamOwnerships}
|
teamOwnerships={teamOwnerships}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
title={`${sportName} ${sportSeasonName}`}
|
title={`${sportName} ${sportSeasonName}`}
|
||||||
description={
|
description="Playoff bracket"
|
||||||
scoringPattern === "page_playoff"
|
|
||||||
? "AFL Finals format with double-chance system"
|
|
||||||
: "Single elimination playoff bracket"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -154,6 +150,7 @@ export function SportSeasonDisplay({
|
||||||
<SeasonStandings
|
<SeasonStandings
|
||||||
standings={seasonStandings}
|
standings={seasonStandings}
|
||||||
teamOwnerships={teamOwnerships}
|
teamOwnerships={teamOwnerships}
|
||||||
|
userParticipantIds={userParticipantIds}
|
||||||
showOwnership={showOwnership}
|
showOwnership={showOwnership}
|
||||||
isFinalized={seasonIsFinalized}
|
isFinalized={seasonIsFinalized}
|
||||||
title={`${sportName} ${sportSeasonName}`}
|
title={`${sportName} ${sportSeasonName}`}
|
||||||
|
|
|
||||||
148
app/components/sport-season/EventSchedule.tsx
Normal file
148
app/components/sport-season/EventSchedule.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Calendar, CheckCircle2, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface ScheduleEvent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
eventDate: string | null;
|
||||||
|
eventType: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
completedAt?: Date | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventScheduleProps {
|
||||||
|
upcomingEvents: ScheduleEvent[];
|
||||||
|
recentEvents: ScheduleEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return "Date TBD";
|
||||||
|
try {
|
||||||
|
// eventDate is stored as a date string "YYYY-MM-DD"
|
||||||
|
return format(parseISO(dateStr), "MMM d, yyyy");
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventTypeLabel(eventType: string): string {
|
||||||
|
switch (eventType) {
|
||||||
|
case "playoff_game":
|
||||||
|
return "Bracket";
|
||||||
|
case "major_tournament":
|
||||||
|
return "Major";
|
||||||
|
case "final_standings":
|
||||||
|
return "Final Standings";
|
||||||
|
case "schedule_event":
|
||||||
|
return "Non-Scoring";
|
||||||
|
default:
|
||||||
|
return eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventSchedule({ upcomingEvents, recentEvents }: EventScheduleProps) {
|
||||||
|
const hasUpcoming = upcomingEvents.length > 0;
|
||||||
|
const hasRecent = recentEvents.length > 0;
|
||||||
|
|
||||||
|
if (!hasUpcoming && !hasRecent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
{hasUpcoming && (
|
||||||
|
<Card className="border-electric/20">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2 text-electric">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Upcoming
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{upcomingEvents.map((event, index) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className={`flex items-start justify-between gap-2 ${
|
||||||
|
index === 0 ? "" : "border-t pt-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium truncate ${
|
||||||
|
index === 0 ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric mr-2 mb-0.5" />
|
||||||
|
)}
|
||||||
|
{event.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatEventDate(event.eventDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{event.eventType === "schedule_event" ? (
|
||||||
|
!event.isComplete && event.eventDate && event.eventDate < new Date().toISOString().split("T")[0] && (
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0 border-amber-500/30 text-amber-400">
|
||||||
|
Results Pending
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{getEventTypeLabel(event.eventType)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Results */}
|
||||||
|
{hasRecent && (
|
||||||
|
<Card className="border-emerald-500/20">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2 text-emerald-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Recent Results
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{recentEvents.map((event, index) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className={`flex items-start justify-between gap-2 ${
|
||||||
|
index === 0 ? "" : "border-t pt-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground truncate">
|
||||||
|
{event.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatEventDate(event.eventDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{event.eventType !== "schedule_event" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs shrink-0 border-emerald-500/30 text-emerald-400"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -7,7 +8,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { Trophy, Target, Flag } from "lucide-react";
|
import { Trophy, Target, Flag, Calendar } from "lucide-react";
|
||||||
|
|
||||||
interface SportSeasonCardProps {
|
interface SportSeasonCardProps {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
|
|
@ -16,6 +17,16 @@ interface SportSeasonCardProps {
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
status: "upcoming" | "active" | "completed";
|
status: "upcoming" | "active" | "completed";
|
||||||
scoringPattern?: string | null;
|
scoringPattern?: string | null;
|
||||||
|
nextEvent?: { name: string; eventDate: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextEventDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
try {
|
||||||
|
return format(parseISO(dateStr), "MMM d");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SportSeasonCard({
|
export function SportSeasonCard({
|
||||||
|
|
@ -25,6 +36,7 @@ export function SportSeasonCard({
|
||||||
seasonName,
|
seasonName,
|
||||||
status,
|
status,
|
||||||
scoringPattern,
|
scoringPattern,
|
||||||
|
nextEvent,
|
||||||
}: SportSeasonCardProps) {
|
}: SportSeasonCardProps) {
|
||||||
const getStatusBadge = () => {
|
const getStatusBadge = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -56,10 +68,8 @@ export function SportSeasonCard({
|
||||||
if (!scoringPattern) return null;
|
if (!scoringPattern) return null;
|
||||||
|
|
||||||
switch (scoringPattern) {
|
switch (scoringPattern) {
|
||||||
case "single_elimination_playoff":
|
case "playoff_bracket":
|
||||||
return "Playoff Bracket";
|
return "Playoff Bracket";
|
||||||
case "page_playoff":
|
|
||||||
return "AFL Finals";
|
|
||||||
case "season_standings":
|
case "season_standings":
|
||||||
return "Season Standings";
|
return "Season Standings";
|
||||||
case "qualifying_points":
|
case "qualifying_points":
|
||||||
|
|
@ -70,7 +80,8 @@ export function SportSeasonCard({
|
||||||
};
|
};
|
||||||
|
|
||||||
const scoringLabel = getScoringPatternLabel();
|
const scoringLabel = getScoringPatternLabel();
|
||||||
const isPlayoffType = scoringPattern === "single_elimination_playoff" || scoringPattern === "page_playoff";
|
const isPlayoffType = scoringPattern === "playoff_bracket";
|
||||||
|
const nextEventDate = formatNextEventDate(nextEvent?.eventDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/leagues/${leagueId}/sports-seasons/${sportSeasonId}`}>
|
<Link to={`/leagues/${leagueId}/sports-seasons/${sportSeasonId}`}>
|
||||||
|
|
@ -96,6 +107,18 @@ export function SportSeasonCard({
|
||||||
<span>{scoringLabel}</span>
|
<span>{scoringLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{nextEvent && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Calendar className="h-4 w-4 text-electric shrink-0" />
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
<span className="text-electric font-medium">Next: </span>
|
||||||
|
{nextEvent.name}
|
||||||
|
{nextEventDate && (
|
||||||
|
<span className="text-muted-foreground"> — {nextEventDate}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ScoringPattern =
|
export type ScoringPattern =
|
||||||
| "single_elimination_playoff"
|
| "playoff_bracket"
|
||||||
| "page_playoff"
|
|
||||||
| "season_standings"
|
| "season_standings"
|
||||||
| "qualifying_points";
|
| "qualifying_points";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc } from "drizzle-orm";
|
import { eq, and, desc, asc, gte, lte } from "drizzle-orm";
|
||||||
|
|
||||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings";
|
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||||
|
|
||||||
|
export function getEventTypeLabel(eventType: string): string {
|
||||||
|
switch (eventType) {
|
||||||
|
case "playoff_game": return "Bracket";
|
||||||
|
case "major_tournament": return "Major Tournament";
|
||||||
|
case "final_standings": return "Final Standings";
|
||||||
|
case "schedule_event": return "Non-Scoring";
|
||||||
|
default: return eventType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateScoringEventData {
|
export interface CreateScoringEventData {
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
|
|
@ -202,3 +212,82 @@ export async function areAllEventsComplete(
|
||||||
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
|
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
|
||||||
return incompleteEvents.length === 0;
|
return incompleteEvents.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming (not yet complete) scoring events for a sports season,
|
||||||
|
* ordered by eventDate ascending (soonest first).
|
||||||
|
* Events without a date are included last.
|
||||||
|
*/
|
||||||
|
export async function getUpcomingScoringEvents(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
limit = 5,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
) {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
return db.query.scoringEvents.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.scoringEvents.isComplete, false)
|
||||||
|
),
|
||||||
|
orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.createdAt)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the single next upcoming event for a sports season (for card display).
|
||||||
|
*/
|
||||||
|
export async function getNextScoringEvent(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
) {
|
||||||
|
const upcoming = await getUpcomingScoringEvents(sportsSeasonId, 1, providedDb);
|
||||||
|
return upcoming[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recently completed scoring events for a sports season,
|
||||||
|
* ordered by completedAt descending (most recent first).
|
||||||
|
*/
|
||||||
|
export async function getRecentCompletedEvents(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
limit = 3,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
) {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
return db.query.scoringEvents.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.scoringEvents.isComplete, true)
|
||||||
|
),
|
||||||
|
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.eventDate)],
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk create scoring events for a sports season.
|
||||||
|
* Returns created events in order.
|
||||||
|
*/
|
||||||
|
export async function bulkCreateScoringEvents(
|
||||||
|
sportsSeasonId: string,
|
||||||
|
events: Array<{ name: string; eventDate?: Date; eventType: EventType; isQualifyingEvent?: boolean }>,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
|
) {
|
||||||
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = events.map((e) => ({
|
||||||
|
sportsSeasonId,
|
||||||
|
name: e.name,
|
||||||
|
eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined,
|
||||||
|
eventType: e.eventType,
|
||||||
|
isQualifyingEvent: e.isQualifyingEvent ?? false,
|
||||||
|
isComplete: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return db.insert(schema.scoringEvents).values(rows).returning();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ export default [
|
||||||
"sports-seasons/:id/recalculate-probabilities",
|
"sports-seasons/:id/recalculate-probabilities",
|
||||||
"routes/admin.sports-seasons.$id.recalculate-probabilities.tsx"
|
"routes/admin.sports-seasons.$id.recalculate-probabilities.tsx"
|
||||||
),
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/standings",
|
||||||
|
"routes/admin.sports-seasons.$id.standings.tsx"
|
||||||
|
),
|
||||||
route("participants", "routes/admin.participants.tsx"),
|
route("participants", "routes/admin.participants.tsx"),
|
||||||
route("templates", "routes/admin.templates.tsx"),
|
route("templates", "routes/admin.templates.tsx"),
|
||||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
import {
|
import {
|
||||||
getScoringEventById,
|
getScoringEventById,
|
||||||
completeScoringEvent,
|
completeScoringEvent,
|
||||||
|
updateScoringEvent,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
getEventResults,
|
getEventResults,
|
||||||
|
|
@ -135,6 +136,36 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "uncomplete") {
|
||||||
|
try {
|
||||||
|
await updateScoringEvent(params.eventId, { isComplete: false });
|
||||||
|
return { success: "Event marked as not updated" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uncompleting event:", error);
|
||||||
|
return { error: "Failed to update event" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "update-event") {
|
||||||
|
const name = formData.get("name");
|
||||||
|
const eventDate = formData.get("eventDate");
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Event name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateScoringEvent(params.eventId, {
|
||||||
|
name: name.trim(),
|
||||||
|
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined,
|
||||||
|
});
|
||||||
|
return { success: "Event updated" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating event:", error);
|
||||||
|
return { error: "Failed to update event" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "add-result") {
|
if (intent === "add-result") {
|
||||||
const participantId = formData.get("participantId");
|
const participantId = formData.get("participantId");
|
||||||
const placement = formData.get("placement");
|
const placement = formData.get("placement");
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
||||||
|
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -59,18 +60,121 @@ export default function EventResults({
|
||||||
)
|
)
|
||||||
: new Map();
|
: new Map();
|
||||||
|
|
||||||
const getEventTypeLabel = (type: string) => {
|
// Non-scoring events have a simple view — edit name/date and toggle updated status
|
||||||
switch (type) {
|
if (event.eventType === "schedule_event") {
|
||||||
case "playoff_game":
|
const today = new Date().toISOString().split("T")[0];
|
||||||
return "Bracket";
|
const isPast = event.eventDate && event.eventDate < today;
|
||||||
case "major_tournament":
|
|
||||||
return "Major Tournament";
|
return (
|
||||||
case "final_standings":
|
<div className="p-8">
|
||||||
return "Final Standings";
|
<div className="max-w-2xl">
|
||||||
default:
|
<div className="mb-6">
|
||||||
return type;
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||||
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Events
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{event.name}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} — {sportsSeason.name} • Non-Scoring
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{event.isComplete ? (
|
||||||
|
<Badge variant="default" className="bg-emerald-500">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Updated
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{isPast ? "Results Pending" : "Upcoming"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-4">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||||||
|
{actionData.success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Edit Event</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<input type="hidden" name="intent" value="update-event" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={event.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="eventDate">Date</Label>
|
||||||
|
<Input
|
||||||
|
id="eventDate"
|
||||||
|
name="eventDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={event.eventDate ?? ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scoring Status</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{event.isComplete
|
||||||
|
? "This event has been noted as updated in standings."
|
||||||
|
: isPast
|
||||||
|
? "This event has passed — mark it once standings have been updated."
|
||||||
|
: "This event hasn't occurred yet."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{event.isComplete ? (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="uncomplete" />
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Mark as Not Updated
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="complete" />
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Mark as Updated
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getScoringEventsForSportsSeason,
|
getScoringEventsForSportsSeason,
|
||||||
createScoringEvent,
|
createScoringEvent,
|
||||||
deleteScoringEvent,
|
deleteScoringEvent,
|
||||||
|
bulkCreateScoringEvents,
|
||||||
type CreateScoringEventData,
|
type CreateScoringEventData,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { getQPStandings } from "~/models/qualifying-points";
|
import { getQPStandings } from "~/models/qualifying-points";
|
||||||
|
|
@ -56,6 +57,59 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "bulk-create") {
|
||||||
|
const bulkText = formData.get("bulkEvents");
|
||||||
|
const bulkEventType = formData.get("bulkEventType");
|
||||||
|
|
||||||
|
if (typeof bulkText !== "string" || !bulkText.trim()) {
|
||||||
|
return { error: "Event list is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ["playoff_game", "major_tournament", "schedule_event"] as const;
|
||||||
|
type ValidEventType = typeof validTypes[number];
|
||||||
|
const eventType: ValidEventType =
|
||||||
|
validTypes.includes(bulkEventType as ValidEventType)
|
||||||
|
? (bulkEventType as ValidEventType)
|
||||||
|
: "playoff_game";
|
||||||
|
|
||||||
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
if (!sportsSeason) return { error: "Sports season not found" };
|
||||||
|
|
||||||
|
const isQualifyingDefault =
|
||||||
|
sportsSeason.scoringPattern === "qualifying_points" &&
|
||||||
|
eventType === "major_tournament";
|
||||||
|
|
||||||
|
// Parse lines: "Name, YYYY-MM-DD" or "Name\tYYYY-MM-DD" or just "Name"
|
||||||
|
const lines = bulkText
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const parsedEvents = lines.map((line) => {
|
||||||
|
const parts = line.split(/[,\t]/).map((p) => p.trim());
|
||||||
|
const name = parts[0];
|
||||||
|
const dateStr = parts[1];
|
||||||
|
const eventDate =
|
||||||
|
dateStr && /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
|
||||||
|
? new Date(dateStr)
|
||||||
|
: undefined;
|
||||||
|
return { name, eventDate, eventType, isQualifyingEvent: isQualifyingDefault };
|
||||||
|
});
|
||||||
|
|
||||||
|
const validEvents = parsedEvents.filter((e) => e.name);
|
||||||
|
if (validEvents.length === 0) {
|
||||||
|
return { error: "No valid events found. Use format: Event Name, YYYY-MM-DD" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bulkCreateScoringEvents(params.id, validEvents);
|
||||||
|
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error bulk creating events:", error);
|
||||||
|
return { error: "Failed to create events. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === "delete-event") {
|
if (intent === "delete-event") {
|
||||||
const eventId = formData.get("eventId");
|
const eventId = formData.get("eventId");
|
||||||
|
|
||||||
|
|
@ -84,7 +138,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
if (
|
if (
|
||||||
eventType !== "playoff_game" &&
|
eventType !== "playoff_game" &&
|
||||||
eventType !== "major_tournament" &&
|
eventType !== "major_tournament" &&
|
||||||
eventType !== "final_standings"
|
eventType !== "schedule_event"
|
||||||
) {
|
) {
|
||||||
return { error: "Invalid event type" };
|
return { error: "Invalid event type" };
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +157,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const eventData: CreateScoringEventData = {
|
const eventData: CreateScoringEventData = {
|
||||||
sportsSeasonId: params.id,
|
sportsSeasonId: params.id,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
eventType: eventType as "playoff_game" | "major_tournament" | "final_standings",
|
eventType: eventType as "playoff_game" | "major_tournament" | "schedule_event",
|
||||||
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined,
|
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined,
|
||||||
isQualifyingEvent,
|
isQualifyingEvent,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,22 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Calendar, Trophy, ArrowLeft, Trash2 } from "lucide-react";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
||||||
|
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||||
|
|
||||||
export { loader, action };
|
export { loader, action };
|
||||||
|
|
||||||
|
|
@ -31,27 +44,31 @@ export default function SportsSeasonEvents({
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
|
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
|
||||||
|
|
||||||
const getEventTypeLabel = (type: string) => {
|
const defaultEventType =
|
||||||
switch (type) {
|
sportsSeason.scoringPattern === "qualifying_points"
|
||||||
case "playoff_game":
|
? "major_tournament"
|
||||||
return "Bracket";
|
: sportsSeason.scoringPattern === "season_standings"
|
||||||
case "major_tournament":
|
? "schedule_event"
|
||||||
return "Major Tournament";
|
: "playoff_game";
|
||||||
case "final_standings":
|
|
||||||
return "Final Standings";
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (isComplete: boolean) => {
|
const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null) => {
|
||||||
return isComplete ? (
|
if (isComplete) {
|
||||||
|
return (
|
||||||
<Badge variant="default" className="bg-emerald-500">
|
<Badge variant="default" className="bg-emerald-500">
|
||||||
Completed
|
Completed
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">In Progress</Badge>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (eventType === "schedule_event") {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const isPast = eventDate && eventDate < today;
|
||||||
|
return isPast ? (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">Upcoming</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Badge variant="secondary">In Progress</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -132,16 +149,16 @@ export default function SportsSeasonEvents({
|
||||||
<Label htmlFor="eventType">Event Type</Label>
|
<Label htmlFor="eventType">Event Type</Label>
|
||||||
<Select
|
<Select
|
||||||
name="eventType"
|
name="eventType"
|
||||||
defaultValue={sportsSeason.scoringPattern === "qualifying_points" ? "major_tournament" : "playoff_game"}
|
defaultValue={defaultEventType}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<SelectTrigger id="eventType">
|
<SelectTrigger id="eventType">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="playoff_game">Bracket</SelectItem>
|
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
|
||||||
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||||
<SelectItem value="final_standings">Final Standings</SelectItem>
|
<SelectItem value="playoff_game">Bracket</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{sportsSeason.scoringPattern === "qualifying_points" && (
|
{sportsSeason.scoringPattern === "qualifying_points" && (
|
||||||
|
|
@ -170,6 +187,54 @@ export default function SportsSeasonEvents({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Bulk Create Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ListPlus className="h-5 w-5" />
|
||||||
|
Bulk Import Events
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Paste a schedule — one event per line. Format:{" "}
|
||||||
|
<code className="text-xs bg-muted px-1 rounded">
|
||||||
|
Event Name, YYYY-MM-DD
|
||||||
|
</code>{" "}
|
||||||
|
(date is optional).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<input type="hidden" name="intent" value="bulk-create" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bulkEventType">Event Type</Label>
|
||||||
|
<Select name="bulkEventType" defaultValue={defaultEventType}>
|
||||||
|
<SelectTrigger id="bulkEventType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
|
||||||
|
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||||
|
<SelectItem value="playoff_game">Bracket</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bulkEvents">Events</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bulkEvents"
|
||||||
|
name="bulkEvents"
|
||||||
|
rows={6}
|
||||||
|
placeholder={"Bahrain Grand Prix, 2025-03-02\nSaudi Arabian Grand Prix, 2025-03-09\nAustralian Grand Prix, 2025-03-23"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline" className="w-full">
|
||||||
|
<ListPlus className="mr-2 h-4 w-4" />
|
||||||
|
Import Events
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Events List */}
|
{/* Events List */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -195,7 +260,7 @@ export default function SportsSeasonEvents({
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-semibold">{event.name}</h3>
|
<h3 className="font-semibold">{event.name}</h3>
|
||||||
{getStatusBadge(event.isComplete)}
|
{getStatusBadge(event.isComplete, event.eventType, event.eventDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -204,28 +269,40 @@ export default function SportsSeasonEvents({
|
||||||
{event.eventDate && (
|
{event.eventDate && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
{format(new Date(event.eventDate), "MMM d, yyyy")}
|
{format(parseISO(event.eventDate), "MMM d, yyyy")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Form method="post" className="flex-shrink-0">
|
<AlertDialog>
|
||||||
<input type="hidden" name="intent" value="delete-event" />
|
<AlertDialogTrigger asChild>
|
||||||
<input type="hidden" name="eventId" value={event.id} />
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive shrink-0"
|
||||||
onClick={(e) => {
|
|
||||||
if (!confirm(`Delete event "${event.name}"? This will also delete all results.`)) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will also delete all results for this event. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete-event" />
|
||||||
|
<input type="hidden" name="eventId" value={event.id} />
|
||||||
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
</Form>
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
182
app/routes/admin.sports-seasons.$id.standings.tsx
Normal file
182
app/routes/admin.sports-seasons.$id.standings.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.standings";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
|
import { getSeasonResults, upsertSeasonResultsBulk } from "~/models/participant-season-result";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { ArrowLeft, Save, ListOrdered } from "lucide-react";
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Response("Sports season not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
const seasonResults = await getSeasonResults(params.id);
|
||||||
|
|
||||||
|
// Build a map of existing results keyed by participantId
|
||||||
|
const resultsMap = new Map(
|
||||||
|
seasonResults.map((r) => [r.participantId, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string } },
|
||||||
|
participants,
|
||||||
|
resultsMap: Object.fromEntries(resultsMap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
// Parse updates directly from form field names: position_<id> and points_<id>
|
||||||
|
const byId = new Map<string, { position?: number; points?: number }>();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
const posMatch = key.match(/^position_(.+)$/);
|
||||||
|
const ptsMatch = key.match(/^points_(.+)$/);
|
||||||
|
if (posMatch) {
|
||||||
|
const id = posMatch[1];
|
||||||
|
const position = parseInt(value as string, 10);
|
||||||
|
if (!isNaN(position)) {
|
||||||
|
byId.set(id, { ...byId.get(id), position });
|
||||||
|
}
|
||||||
|
} else if (ptsMatch) {
|
||||||
|
const id = ptsMatch[1];
|
||||||
|
const points = parseFloat(value as string);
|
||||||
|
if (!isNaN(points)) {
|
||||||
|
byId.set(id, { ...byId.get(id), points });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = Array.from(byId.entries()).map(([participantId, { position, points }]) => ({
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId: params.id,
|
||||||
|
currentPosition: position,
|
||||||
|
currentPoints: points,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upsertSeasonResultsBulk(updates);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating standings:", error);
|
||||||
|
return { error: "Failed to save standings. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManageStandings({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, participants, resultsMap } = loaderData;
|
||||||
|
|
||||||
|
// Sort participants by current position (unranked at bottom)
|
||||||
|
const sorted = [...participants].sort((a, b) => {
|
||||||
|
const posA = resultsMap[a.id]?.currentPosition ?? Infinity;
|
||||||
|
const posB = resultsMap[b.id]?.currentPosition ?? Infinity;
|
||||||
|
return posA - posB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||||
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sports Season
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold">Update Standings</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} — {sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Standings saved.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Standings table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ListOrdered className="h-5 w-5" />
|
||||||
|
Championship Standings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter each participant's current position and championship points. Leave blank to skip.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post">
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
<div className="grid grid-cols-[2rem_1fr_6rem_7rem] gap-3 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b">
|
||||||
|
<span>#</span>
|
||||||
|
<span>Participant</span>
|
||||||
|
<span>Position</span>
|
||||||
|
<span>Points</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.map((participant, index) => {
|
||||||
|
const result = resultsMap[participant.id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="grid grid-cols-[2rem_1fr_6rem_7rem] gap-3 items-center px-2 py-1.5 rounded hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
|
{result?.currentPosition ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{participant.name}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
name={`position_${participant.id}`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
defaultValue={result?.currentPosition ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name={`points_${participant.id}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
defaultValue={result?.currentPoints ?? ""}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Standings
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { Form, Link, redirect, useNavigate } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||||
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
|
import { processSeasonStandings } from "~/models/scoring-calculator";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
@ -30,7 +31,8 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Trash2, Users, Trophy, Calculator } from "lucide-react";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
@ -58,6 +60,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return redirect("/admin/sports-seasons");
|
return redirect("/admin/sports-seasons");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent === "finalize-standings") {
|
||||||
|
try {
|
||||||
|
await processSeasonStandings(params.id);
|
||||||
|
await updateSportsSeason(params.id, { status: "completed" });
|
||||||
|
return { success: true, message: "Standings finalized and fantasy placements assigned!" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error finalizing standings:", error);
|
||||||
|
return { error: "Failed to finalize standings. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const year = formData.get("year");
|
const year = formData.get("year");
|
||||||
|
|
@ -90,7 +103,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Invalid scoring type" };
|
return { error: "Invalid scoring type" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validScoringPatterns = ["single_elimination_playoff", "page_playoff", "season_standings", "qualifying_points"];
|
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
||||||
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
||||||
return { error: "Invalid scoring pattern" };
|
return { error: "Invalid scoring pattern" };
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +131,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
await updateSportsSeason(params.id, updateData);
|
await updateSportsSeason(params.id, updateData);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true, message: "Sports season updated successfully!" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating sports season:", error);
|
console.error("Error updating sports season:", error);
|
||||||
return { error: "Failed to update sports season. Please try again." };
|
return { error: "Failed to update sports season. Please try again." };
|
||||||
|
|
@ -234,8 +247,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="single_elimination_playoff">Single Elimination Playoff</SelectItem>
|
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||||
<SelectItem value="page_playoff">Page Playoff</SelectItem>
|
|
||||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -271,7 +283,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
|
|
||||||
{actionData?.success && (
|
{actionData?.success && (
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||||||
Sports season updated successfully!
|
{actionData.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -376,7 +388,7 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Scoring Events</CardTitle>
|
<CardTitle>Scoring Events</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage games, tournaments, and results
|
Manage games, tournaments, and schedule entries
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -390,11 +402,96 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create playoff games, tournaments, races, or final standings events to track participant results and calculate fantasy points.
|
Create playoff brackets, major tournaments, or import a race/game schedule.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{sportsSeason.scoringPattern === "season_standings" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Championship Standings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update participant positions and points as the season progresses
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/standings`)}
|
||||||
|
>
|
||||||
|
Update Standings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sportsSeason.scoringPattern === "season_standings" && (
|
||||||
|
<Card className={sportsSeason.status === "completed" ? "border-emerald-500/30" : "border-amber-500/30"}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Finalize Standings
|
||||||
|
{sportsSeason.status === "completed" && (
|
||||||
|
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Finalized
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sportsSeason.status === "completed"
|
||||||
|
? "Season standings have been finalized and fantasy placements assigned."
|
||||||
|
: "When the championship is over, finalize standings to assign fantasy placements (1st–8th) to participants based on their final positions."}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{sportsSeason.status !== "completed" && (
|
||||||
|
<CardContent>
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-4">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actionData?.success && actionData.message?.includes("finalized") && (
|
||||||
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||||||
|
{actionData.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="border-amber-500/50 text-amber-600 hover:bg-amber-500/10 dark:text-amber-400">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Finalize Standings
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Finalize championship standings?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will read the current participant standings and assign fantasy placements (1st through 8th place). The season will be marked as completed. This action can be re-run to correct results if needed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="finalize-standings" />
|
||||||
|
<AlertDialogAction type="submit">
|
||||||
|
Finalize Standings
|
||||||
|
</AlertDialogAction>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
return { error: "Invalid scoring type" };
|
return { error: "Invalid scoring type" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validScoringPatterns = ["single_elimination_playoff", "page_playoff", "season_standings", "qualifying_points"];
|
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
||||||
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
||||||
return { error: "Invalid scoring pattern" };
|
return { error: "Invalid scoring pattern" };
|
||||||
}
|
}
|
||||||
|
|
@ -227,8 +227,7 @@ export default function NewSportsSeason({ loaderData, actionData }: Route.Compon
|
||||||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="single_elimination_playoff">Single Elimination Playoff</SelectItem>
|
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||||||
<SelectItem value="page_playoff">Page Playoff</SelectItem>
|
|
||||||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||||||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from "~/models";
|
} from "~/models";
|
||||||
import { findCurrentSeasonWithSports } from "~/models/season";
|
import { findCurrentSeasonWithSports } from "~/models/season";
|
||||||
import { getSeasonStandings } from "~/models/standings";
|
import { getSeasonStandings } from "~/models/standings";
|
||||||
|
import { getNextScoringEvent } from "~/models/scoring-event";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
|
@ -111,14 +112,25 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
// Count teams with owners
|
// Count teams with owners
|
||||||
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
|
const teamsWithOwners = teams.filter((t) => t.ownerId !== null).length;
|
||||||
|
|
||||||
// Get sports seasons data
|
// Get sports seasons data including next upcoming event for each
|
||||||
const sportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ({
|
const rawSportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ss.sportsSeason) || [];
|
||||||
id: ss.sportsSeason.id,
|
|
||||||
name: ss.sportsSeason.name,
|
const sportsSeasons = await Promise.all(
|
||||||
status: ss.sportsSeason.status as "upcoming" | "active" | "completed",
|
rawSportsSeasons.map(async (ss) => {
|
||||||
scoringPattern: ss.sportsSeason.scoringPattern,
|
const nextEvent =
|
||||||
sport: ss.sportsSeason.sport,
|
ss.status === "active" ? await getNextScoringEvent(ss.id) : null;
|
||||||
})) || [];
|
return {
|
||||||
|
id: ss.id,
|
||||||
|
name: ss.name,
|
||||||
|
status: ss.status as "upcoming" | "active" | "completed",
|
||||||
|
scoringPattern: ss.scoringPattern,
|
||||||
|
sport: ss.sport,
|
||||||
|
nextEvent: nextEvent
|
||||||
|
? { name: nextEvent.name, eventDate: nextEvent.eventDate }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
const sportsCount = sportsSeasons.length;
|
const sportsCount = sportsSeasons.length;
|
||||||
|
|
||||||
// Check if draft order is set
|
// Check if draft order is set
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
import { getDraftPicks } from "~/models/draft-pick";
|
import { getDraftPicks } from "~/models/draft-pick";
|
||||||
import { getSeasonResults } from "~/models/participant-season-result";
|
import { getSeasonResults } from "~/models/participant-season-result";
|
||||||
import { getQPStandings } from "~/models/qualifying-points";
|
import { getQPStandings } from "~/models/qualifying-points";
|
||||||
|
import {
|
||||||
|
getUpcomingScoringEvents,
|
||||||
|
getRecentCompletedEvents,
|
||||||
|
} from "~/models/scoring-event";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
@ -117,25 +121,29 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Derive which participant IDs the current user has drafted
|
||||||
|
const userTeams = teams.filter((t) => t.ownerId === userId);
|
||||||
|
const userTeamIds = new Set(userTeams.map((t) => t.id));
|
||||||
|
const userParticipantIds = draftPicks
|
||||||
|
.filter((p) => p.teamId && userTeamIds.has(p.teamId) && p.participantId)
|
||||||
|
.map((p) => p.participantId as string);
|
||||||
|
|
||||||
// Fetch pattern-specific data based on scoring pattern
|
// Fetch pattern-specific data based on scoring pattern
|
||||||
const scoringPattern = sportsSeason.scoringPattern;
|
const scoringPattern = sportsSeason.scoringPattern;
|
||||||
|
|
||||||
|
type SeasonStanding = { id: string; position: number; championshipPoints: string; participant: { id: string; name: string } };
|
||||||
|
|
||||||
let playoffMatches: any[] = [];
|
let playoffMatches: any[] = [];
|
||||||
let playoffRounds: string[] = [];
|
let playoffRounds: string[] = [];
|
||||||
let seasonStandings: any[] = [];
|
let seasonStandings: SeasonStanding[] = [];
|
||||||
let qpStandings: any[] = [];
|
let qpStandings: any[] = [];
|
||||||
|
|
||||||
if (
|
if (scoringPattern === "playoff_bracket") {
|
||||||
scoringPattern === "single_elimination_playoff" ||
|
|
||||||
scoringPattern === "page_playoff"
|
|
||||||
) {
|
|
||||||
// Fetch playoff matches via scoring events
|
// Fetch playoff matches via scoring events
|
||||||
// First get all scoring events for this sports season
|
|
||||||
const events = await db.query.scoringEvents.findMany({
|
const events = await db.query.scoringEvents.findMany({
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then get all playoff matches for these events
|
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
const eventIds = events.map((e) => e.id);
|
const eventIds = events.map((e) => e.id);
|
||||||
const matches = await db.query.playoffMatches.findMany({
|
const matches = await db.query.playoffMatches.findMany({
|
||||||
|
|
@ -149,22 +157,38 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
});
|
});
|
||||||
playoffMatches = matches;
|
playoffMatches = matches;
|
||||||
|
|
||||||
// Get unique rounds in order
|
|
||||||
const roundSet = new Set(matches.map((m: any) => m.round));
|
const roundSet = new Set(matches.map((m: any) => m.round));
|
||||||
playoffRounds = Array.from(roundSet);
|
playoffRounds = Array.from(roundSet);
|
||||||
|
|
||||||
// TODO: Sort rounds by template order if needed
|
|
||||||
}
|
}
|
||||||
} else if (scoringPattern === "season_standings") {
|
} else if (scoringPattern === "season_standings") {
|
||||||
// Fetch F1-style championship standings
|
// Fetch F1-style championship standings and map to SeasonStanding shape
|
||||||
const results = await getSeasonResults(sportsSeasonId);
|
const results = await getSeasonResults(sportsSeasonId);
|
||||||
seasonStandings = results;
|
seasonStandings = results
|
||||||
|
.filter((r) => r.currentPosition !== null || (r.currentPoints && parseFloat(r.currentPoints) > 0))
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
position: r.currentPosition ?? 999,
|
||||||
|
championshipPoints: r.currentPoints ?? "0",
|
||||||
|
participant: {
|
||||||
|
id: r.participant.id,
|
||||||
|
name: r.participant.name,
|
||||||
|
},
|
||||||
|
}));
|
||||||
} else if (scoringPattern === "qualifying_points") {
|
} else if (scoringPattern === "qualifying_points") {
|
||||||
// Fetch qualifying points standings
|
// Fetch qualifying points standings
|
||||||
const standings = await getQPStandings(sportsSeasonId);
|
const standings = await getQPStandings(sportsSeasonId);
|
||||||
qpStandings = standings;
|
qpStandings = standings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch event schedule for all patterns (upcoming + recent)
|
||||||
|
const [upcomingEvents, recentEvents] = await Promise.all([
|
||||||
|
getUpcomingScoringEvents(sportsSeasonId, 5),
|
||||||
|
getRecentCompletedEvents(sportsSeasonId, 3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Derive seasonIsFinalized from status
|
||||||
|
const seasonIsFinalized = sportsSeason.status === "completed";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
|
|
@ -175,5 +199,9 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
seasonStandings,
|
seasonStandings,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
|
userParticipantIds,
|
||||||
|
upcomingEvents,
|
||||||
|
recentEvents,
|
||||||
|
seasonIsFinalized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,41 @@ import { Link } from "react-router";
|
||||||
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId";
|
||||||
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
import { loader } from "./$leagueId.sports-seasons.$sportsSeasonId.server";
|
||||||
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
import { SportSeasonDisplay } from "~/components/scoring/SportSeasonDisplay";
|
||||||
|
import { EventSchedule } from "~/components/sport-season/EventSchedule";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { ArrowLeft, CheckCircle2, Clock, Zap } from "lucide-react";
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
|
|
||||||
|
function getStatusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||||||
|
<Zap className="mr-1 h-3 w-3" />
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "upcoming":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-electric/15 text-electric border-electric/30">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
Upcoming
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SportSeasonDetail({
|
export default function SportSeasonDetail({
|
||||||
loaderData,
|
loaderData,
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
|
|
@ -20,6 +50,10 @@ export default function SportSeasonDetail({
|
||||||
seasonStandings,
|
seasonStandings,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
teamOwnerships,
|
teamOwnerships,
|
||||||
|
userParticipantIds,
|
||||||
|
upcomingEvents,
|
||||||
|
recentEvents,
|
||||||
|
seasonIsFinalized,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,15 +66,28 @@ export default function SportSeasonDetail({
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<h1 className="text-3xl font-bold mb-2">
|
<div>
|
||||||
{sportsSeason.sport.name} - {sportsSeason.name}
|
<h1 className="text-3xl font-bold mb-1">
|
||||||
|
{sportsSeason.sport.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{league.name} • {season.year} Season
|
{sportsSeason.name} • {league.name} • {season.year} Season
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{getStatusBadge(sportsSeason.status)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event schedule (upcoming + recent) - shown when there's event data */}
|
||||||
|
{(upcomingEvents.length > 0 || recentEvents.length > 0) && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<EventSchedule
|
||||||
|
upcomingEvents={upcomingEvents as any}
|
||||||
|
recentEvents={recentEvents as any}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SportSeasonDisplay
|
<SportSeasonDisplay
|
||||||
scoringPattern={scoringPattern as any}
|
scoringPattern={scoringPattern as any}
|
||||||
|
|
@ -49,7 +96,7 @@ export default function SportSeasonDetail({
|
||||||
playoffMatches={playoffMatches as any}
|
playoffMatches={playoffMatches as any}
|
||||||
playoffRounds={playoffRounds}
|
playoffRounds={playoffRounds}
|
||||||
seasonStandings={seasonStandings as any}
|
seasonStandings={seasonStandings as any}
|
||||||
seasonIsFinalized={false} // TODO: determine from data
|
seasonIsFinalized={seasonIsFinalized}
|
||||||
qpStandings={qpStandings as any}
|
qpStandings={qpStandings as any}
|
||||||
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
qpIsFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
||||||
totalMajors={sportsSeason.totalMajors}
|
totalMajors={sportsSeason.totalMajors}
|
||||||
|
|
@ -60,7 +107,8 @@ export default function SportSeasonDetail({
|
||||||
!sportsSeason.qualifyingPointsFinalized
|
!sportsSeason.qualifyingPointsFinalized
|
||||||
}
|
}
|
||||||
teamOwnerships={teamOwnerships}
|
teamOwnerships={teamOwnerships}
|
||||||
scoringRules={season} // Season has the scoring rules (pointsFor1st, etc.)
|
userParticipantIds={userParticipantIds}
|
||||||
|
scoringRules={season}
|
||||||
showOwnership={true}
|
showOwnership={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
seasonName={sportSeason.name}
|
seasonName={sportSeason.name}
|
||||||
status={sportSeason.status}
|
status={sportSeason.status}
|
||||||
scoringPattern={sportSeason.scoringPattern}
|
scoringPattern={sportSeason.scoringPattern}
|
||||||
|
nextEvent={sportSeason.nextEvent}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,7 @@ export const scoringTypeEnum = pgEnum("scoring_type", [
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const scoringPatternEnum = pgEnum("scoring_pattern", [
|
export const scoringPatternEnum = pgEnum("scoring_pattern", [
|
||||||
"single_elimination_playoff",
|
"playoff_bracket",
|
||||||
"page_playoff",
|
|
||||||
"season_standings",
|
"season_standings",
|
||||||
"qualifying_points",
|
"qualifying_points",
|
||||||
]);
|
]);
|
||||||
|
|
@ -50,6 +49,7 @@ export const eventTypeEnum = pgEnum("event_type", [
|
||||||
"playoff_game",
|
"playoff_game",
|
||||||
"major_tournament",
|
"major_tournament",
|
||||||
"final_standings",
|
"final_standings",
|
||||||
|
"schedule_event",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const pickedByTypeEnum = pgEnum("picked_by_type", [
|
export const pickedByTypeEnum = pgEnum("picked_by_type", [
|
||||||
|
|
|
||||||
20
drizzle/0032_consolidate_playoff_pattern.sql
Normal file
20
drizzle/0032_consolidate_playoff_pattern.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- Consolidate single_elimination_playoff and page_playoff → playoff_bracket
|
||||||
|
-- PostgreSQL does not support DROP VALUE on enums, so we recreate the type.
|
||||||
|
|
||||||
|
-- Step 1: Create new consolidated enum
|
||||||
|
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points');
|
||||||
|
|
||||||
|
-- Step 2: Migrate column data, mapping old values to playoff_bracket
|
||||||
|
ALTER TABLE "sports_seasons"
|
||||||
|
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
|
||||||
|
USING (
|
||||||
|
CASE scoring_pattern::text
|
||||||
|
WHEN 'single_elimination_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||||
|
WHEN 'page_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||||
|
ELSE scoring_pattern::text::scoring_pattern_new
|
||||||
|
END
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 3: Replace old type
|
||||||
|
DROP TYPE "scoring_pattern";
|
||||||
|
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
|
||||||
5
drizzle/0033_add_schedule_event_type.sql
Normal file
5
drizzle/0033_add_schedule_event_type.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Add 'schedule_event' to event_type enum for non-scoring calendar entries
|
||||||
|
-- (e.g. F1/IndyCar races that are shown in the schedule but don't award points)
|
||||||
|
-- PostgreSQL does not support ADD VALUE in a transaction, but it does support it directly.
|
||||||
|
|
||||||
|
ALTER TYPE "event_type" ADD VALUE IF NOT EXISTS 'schedule_event';
|
||||||
|
|
@ -225,6 +225,20 @@
|
||||||
"when": 1763283000000,
|
"when": 1763283000000,
|
||||||
"tag": "0031_add_autodraft_queue_only",
|
"tag": "0031_add_autodraft_queue_only",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741305600000,
|
||||||
|
"tag": "0032_consolidate_playoff_pattern",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1741392000000,
|
||||||
|
"tag": "0033_add_schedule_event_type",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
320
plans/sport-season-pages.md
Normal file
320
plans/sport-season-pages.md
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
# Sport Season Pages & Scoring System Enhancement
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The league sport season pages currently show basic standings/brackets but lack:
|
||||||
|
- **Schedule visibility**: No upcoming events/races/games display
|
||||||
|
- **"My team" highlighting**: Users can't easily see which participants are theirs and when they play
|
||||||
|
- **Sport-appropriate layouts**: All sports use the same minimal display regardless of type
|
||||||
|
- **EV tracking over time**: No historical EV snapshots for trend analysis
|
||||||
|
- **Regular season → playoff transitions**: No way for sports like NBA to shift from standings to bracket
|
||||||
|
- **Admin pain**: Managing scoring across 4 patterns is tedious and the workflow has too many manual steps
|
||||||
|
|
||||||
|
The user wants rich, sport-aware season pages and plans to integrate TypeScript-based EV simulation models.
|
||||||
|
|
||||||
|
## Sport Type Mental Model
|
||||||
|
|
||||||
|
| Category | Sports | How it works | Display |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Season Standings** | F1, IndyCar | Championship standings update over season; final standings = placements | Standings table + race schedule |
|
||||||
|
| **Qualifying Points** | Golf | Multiple discrete events (4 majors), QP per event, final QP rankings = placements | QP standings + event calendar + live event tracking |
|
||||||
|
| **Tournament Bracket** | Darts, Snooker | Seeded bracket, results → placements | Bracket visualization |
|
||||||
|
| **Multi-Tournament QP** | Tennis, Counter-Strike | Multiple tournament brackets, each awarding QP | QP standings + tournament bracket per event |
|
||||||
|
| **Regular Season + Playoffs** | NBA, NFL | Regular season standings → playoff bracket (two phases) | Phase-dependent: standings then bracket |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Edge Cases & Risks
|
||||||
|
|
||||||
|
These were identified during senior review and must be addressed in the relevant phase.
|
||||||
|
|
||||||
|
### P0 — Architectural Blockers
|
||||||
|
|
||||||
|
1. **Dual-mode sports (NBA: standings + bracket)**: The loader in `$leagueId.sports-seasons.$sportsSeasonId.server.ts` uses `scoringPattern` as an exclusive switch — it fetches standings OR bracket data, never both. Adding a `phase` field alone is insufficient. The loader must be refactored to fetch both data sets for dual-phase sports, with `phase` controlling which is displayed. Consider a compound scoring pattern like `"season_standings_to_playoff"` or decoupling data fetching from display via a `displayMode` derived from `(scoringPattern, phase)`.
|
||||||
|
|
||||||
|
2. **QP standings missing team ownership**: The `SportSeasonDisplay` component does NOT pass `teamOwnerships` to `QualifyingPointsStandings`. "My participants" highlighting is broken for golf/tennis. Must be fixed.
|
||||||
|
|
||||||
|
### P1 — Data Integrity & Correctness
|
||||||
|
|
||||||
|
3. **Bracket + QP events (Tennis)**: No code path handles a scoring event that is both a bracket AND a QP event. `processPlayoffEvent()` and `processQualifyingEvent()` are separate functions. Need a `processQualifyingBracketEvent()` that derives QP placements FROM bracket results automatically.
|
||||||
|
|
||||||
|
4. **`batchUpsertParticipantEVs` not transactional**: Uses `Promise.all` in batches of 50 without a wrapping transaction. If it fails midway, some participants have updated EVs and others don't. Must be wrapped in a transaction.
|
||||||
|
|
||||||
|
5. **Simulation concurrency**: If admin triggers "Run Simulation" while another admin is entering results, both write to `participantExpectedValues` simultaneously. The `upsertParticipantEV` function does SELECT-then-INSERT/UPDATE (not atomic). Need a `simulation_in_progress` flag or advisory lock.
|
||||||
|
|
||||||
|
6. **Initial EV seeding**: Participants in not-yet-started sports contribute 0 to team projections. Need to pre-populate EVs for all participants at season start, or clearly label projections as partial.
|
||||||
|
|
||||||
|
### P2 — Display & UX Gaps
|
||||||
|
|
||||||
|
7. **`seasonIsFinalized` TODO**: In `$leagueId.sports-seasons.$sportsSeasonId.tsx` line 53, this is hardcoded with a `// TODO: determine from data` comment. Derive from `sportsSeason.status === 'completed'` or existence of `participantResults`.
|
||||||
|
|
||||||
|
8. **Simulation async model**: Monte Carlo simulations could take 30+ seconds. Must run asynchronously with status tracking, not in an HTTP request.
|
||||||
|
|
||||||
|
9. **EV snapshot retention**: Define daily-only snapshots with composite unique index on `(participantId, sportsSeasonId, snapshotDate)`. Take snapshots from simulation output directly (not re-read from DB) to avoid partial-update captures.
|
||||||
|
|
||||||
|
### P3 — Tech Debt
|
||||||
|
|
||||||
|
10. **`scoringType` vs `scoringPattern` duplication**: Both columns exist on `sportsSeasons`. Only `scoringPattern` is used in loaders. Either deprecate `scoringType` or document which is authoritative. Adding `phase` as a third axis without resolving this increases confusion.
|
||||||
|
|
||||||
|
11. **No result correction workflow**: If admin enters wrong results, there's no undo. Need a "recalculate all from raw data" admin action per sports season.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: F1/IndyCar Season Pages (Start Here)
|
||||||
|
|
||||||
|
**Goal**: Rich season standings page with schedule for currently-active sports.
|
||||||
|
|
||||||
|
### 1a. Enhance Scoring Events as Schedule Data
|
||||||
|
- Ensure scoring events for F1/IndyCar have `eventDate` populated for all races
|
||||||
|
- Add admin bulk-import: paste race names + dates (one per line, tab or comma separated) to create events
|
||||||
|
- Admin route: enhance `admin.sports-seasons.$id.events` with bulk-create form
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `app/routes/admin.sports-seasons.$id.events.tsx` — add bulk event creation
|
||||||
|
- `app/models/scoring-event.ts` — add `bulkCreateScoringEvents()`, `getUpcomingScoringEvents(sportsSeasonId)`, `getRecentCompletedEvents(sportsSeasonId)`
|
||||||
|
|
||||||
|
### 1b. Enhanced Season Standings Display
|
||||||
|
- Show current championship standings (existing `SeasonStandings` component)
|
||||||
|
- Add **upcoming events section**: next 3-5 events with dates
|
||||||
|
- Add **recent results section**: last completed events
|
||||||
|
- Highlight participants owned by the current user's team with team color/badge
|
||||||
|
- Show participant EV alongside standings position
|
||||||
|
- **Fix**: resolve `seasonIsFinalized` TODO (edge case #7)
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts` — loader adds upcoming/recent events + user team ownership
|
||||||
|
- `app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx` — pass new data to components, fix finalized TODO
|
||||||
|
- `app/components/sport-season/SeasonStandings.tsx` — add schedule section + ownership highlighting
|
||||||
|
- New component: `app/components/sport-season/EventSchedule.tsx`
|
||||||
|
|
||||||
|
### 1c. League Home Enhancement
|
||||||
|
- On the league home page, show next upcoming event for each active sport season card
|
||||||
|
- Example: "Next: Monaco Grand Prix — May 25"
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `app/routes/leagues/$leagueId.tsx` — loader fetches next event per sport season
|
||||||
|
- `app/components/sport-season/SportSeasonCard.tsx` — display next event
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: EV Simulation Framework
|
||||||
|
|
||||||
|
**Goal**: Infrastructure for sport-specific EV simulations in TypeScript, with historical tracking.
|
||||||
|
|
||||||
|
### 2a. EV History Snapshots Table
|
||||||
|
New table `participant_ev_snapshots`:
|
||||||
|
- `id`, `participantId`, `sportsSeasonId`, `snapshotDate` (date)
|
||||||
|
- `probFirst` through `probEighth`, `calculatedEV`
|
||||||
|
- `source` (simulation method used)
|
||||||
|
- Composite unique index on `(participantId, sportsSeasonId, snapshotDate)` — one snapshot per participant per day
|
||||||
|
- Retention: add `deleteSnapshotsBefore(date)` for cleanup
|
||||||
|
|
||||||
|
New table `team_ev_snapshots`:
|
||||||
|
- `id`, `teamId`, `seasonId`, `snapshotDate`
|
||||||
|
- `projectedPoints`, `actualPoints`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `database/schema.ts` — add snapshot tables
|
||||||
|
- `app/models/ev-snapshot.ts` — CRUD + query by date range + retention
|
||||||
|
- Migration via `npm run db:generate`
|
||||||
|
|
||||||
|
### 2b. Simulation Runner Framework
|
||||||
|
Create `app/services/simulations/` directory:
|
||||||
|
- `types.ts` — `Simulator` interface, `SimulationResult` type
|
||||||
|
- `f1-simulator.ts` — F1/IndyCar: based on current standings + remaining races
|
||||||
|
- `bracket-simulator.ts` — Bracket sports: Monte Carlo simulation of remaining matches
|
||||||
|
- `registry.ts` — Map sport slugs → simulator classes. Return clear error for unmapped sports.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Simulator {
|
||||||
|
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
|
||||||
|
}
|
||||||
|
interface SimulationResult {
|
||||||
|
participantId: string;
|
||||||
|
probabilities: ProbabilityDistribution;
|
||||||
|
source: ProbabilitySource;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2c. Simulation Trigger & Admin UI
|
||||||
|
- Admin action button: "Run Simulation" on sports season page
|
||||||
|
- **Run asynchronously** — add `simulationStatus` field to `sportsSeasons` (`idle | running | failed`) to prevent concurrent runs (edge case #5)
|
||||||
|
- Take EV snapshot directly from simulation output (not re-read from DB) to avoid partial captures (edge case #9)
|
||||||
|
- **Fix**: wrap `batchUpsertParticipantEVs` in a transaction (edge case #4)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/routes/admin.sports-seasons.$id.simulate.tsx` — simulation trigger
|
||||||
|
- `app/models/participant-expected-value.ts` — add transaction wrapping to batch upsert
|
||||||
|
|
||||||
|
### 2d. Initial EV Seeding
|
||||||
|
- After draft completes, run initial simulations for all linked sports seasons
|
||||||
|
- This prevents 0-value projections for unstarted sports (edge case #6)
|
||||||
|
|
||||||
|
### 2e. EV Trend Display
|
||||||
|
- On sport season detail page, show EV trend chart for top participants
|
||||||
|
- On team detail page, show projected points trend over time
|
||||||
|
- Reuse existing chart pattern from standings snapshots
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Sport season detail route — add EV trends section
|
||||||
|
- Team detail route — add projected trend section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Golf/Majors-Based Sports
|
||||||
|
|
||||||
|
**Goal**: Event-aware QP display with in-progress tournament tracking.
|
||||||
|
|
||||||
|
### 3a. Fix QP Ownership Display
|
||||||
|
- **Fix**: pass `teamOwnerships` to `QualifyingPointsStandings` (edge case #2)
|
||||||
|
- Add highlighting logic to QP standings component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/components/scoring/SportSeasonDisplay.tsx` — pass ownership data for QP case
|
||||||
|
- `app/components/sport-season/QualifyingPointsStandings.tsx` — add highlighting
|
||||||
|
|
||||||
|
### 3b. Event Calendar View
|
||||||
|
- Show all QP events (majors) in a timeline/calendar format
|
||||||
|
- Status per event: upcoming / in-progress / completed
|
||||||
|
- Show dates and results for completed events
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- New component: `app/components/sport-season/EventCalendar.tsx`
|
||||||
|
- Integrate into QP standings display
|
||||||
|
|
||||||
|
### 3c. In-Progress Event Tracking
|
||||||
|
- During a major, show current leaderboard within the event
|
||||||
|
- `eventResults` can have partial/updating data before event completion
|
||||||
|
- Derive "in-progress" state from: has results but `isComplete = false`
|
||||||
|
- Admin can update leaderboard nightly
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/models/scoring-event.ts` — add `getInProgressEvents()`
|
||||||
|
- Admin event page — easier result entry UX for partial updates
|
||||||
|
|
||||||
|
### 3d. "My Participants" in Events
|
||||||
|
- When viewing a major leaderboard, highlight user's team's golfers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Sports Season Phases (NBA/NFL)
|
||||||
|
|
||||||
|
**Goal**: Support sports that transition from regular season to playoffs.
|
||||||
|
|
||||||
|
### 4a. Season Phase System
|
||||||
|
Add `phase` column to `sportsSeasons`:
|
||||||
|
- `regular_season` | `playoffs` | null (not applicable)
|
||||||
|
- Separate from `status` (upcoming/active/completed)
|
||||||
|
- **Refactor loader** to fetch both standings AND bracket data for phase-capable sports, using `phase` to control display (edge case #1)
|
||||||
|
- Migration: set `phase = null` for all existing seasons (backwards compatible)
|
||||||
|
|
||||||
|
**Also address**: clarify `scoringType` vs `scoringPattern` — consider deprecating `scoringType` (edge case #10)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `database/schema.ts` — add `phase` enum/column
|
||||||
|
- `app/models/sports-season.ts` — phase transition function
|
||||||
|
- `$leagueId.sports-seasons.$sportsSeasonId.server.ts` — refactor to dual-mode data fetching
|
||||||
|
- `SportSeasonDisplay.tsx` — phase-aware rendering
|
||||||
|
- Admin UI — phase toggle
|
||||||
|
|
||||||
|
### 4b. Regular Season Standings
|
||||||
|
- Show standings using `participantSeasonResults` during `regular_season` phase
|
||||||
|
- Conference/division groupings (future: metadata on participants)
|
||||||
|
|
||||||
|
### 4c. Playoff Bracket
|
||||||
|
- Phase transitions to `playoffs` → display switches to bracket
|
||||||
|
- Bracket seeded from regular season standings
|
||||||
|
- Round completion triggers scoring
|
||||||
|
|
||||||
|
### 4d. Upcoming Games (Future)
|
||||||
|
- Game schedule during regular season
|
||||||
|
- Highlight user's teams' games
|
||||||
|
- Requires sport-specific data source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Multi-Tournament Sports (Tennis, CS)
|
||||||
|
|
||||||
|
**Goal**: Support sports with multiple tournament brackets that each award QP.
|
||||||
|
|
||||||
|
### 5a. Qualifying Bracket Event Processing
|
||||||
|
- **Create** `processQualifyingBracketEvent()` that derives QP placements from bracket results automatically (edge case #3)
|
||||||
|
- When a bracket completes in a QP-bracket sport, translate bracket elimination → event placement → QP award
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/models/scoring-calculator.ts` — new processing function
|
||||||
|
|
||||||
|
### 5b. Tournament-as-Event Display
|
||||||
|
- Each Grand Slam is a scoring event WITH a bracket
|
||||||
|
- QP standings show overall rankings
|
||||||
|
- Click into event → see bracket for that tournament
|
||||||
|
- Active tournament shows live bracket; completed shows results summary
|
||||||
|
|
||||||
|
### 5c. Event Timeline
|
||||||
|
- Show all Grand Slams in a timeline with status and QP awarded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Admin UX & Scoring Simplification
|
||||||
|
|
||||||
|
**Goal**: Reduce manual work for commissioners/admins.
|
||||||
|
|
||||||
|
### 6a. Streamlined Result Entry
|
||||||
|
- Bracket events: click-to-advance UI
|
||||||
|
- Season standings: paste standings update (position + points per participant)
|
||||||
|
- QP events: paste leaderboard results
|
||||||
|
|
||||||
|
### 6b. Auto-Finalization
|
||||||
|
- QP sport: all events complete → prompt to finalize
|
||||||
|
- Bracket: final complete → auto-calculate placements
|
||||||
|
- Reduce manual finalize steps
|
||||||
|
|
||||||
|
### 6c. Result Correction Workflow
|
||||||
|
- Admin "Recalculate All" button per sports season (edge case #11)
|
||||||
|
- Re-derives all results from raw match/event data
|
||||||
|
- Re-runs standings calculations and probability updates
|
||||||
|
|
||||||
|
### 6d. Schedule Import
|
||||||
|
- Per-sport integration for schedule data
|
||||||
|
- Start with F1 (well-structured data)
|
||||||
|
- Design extensible interface for new sports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (F1/IndyCar) ──→ Phase 2 (EV Framework) ──→ Phase 3 (Golf)
|
||||||
|
──→ Phase 4 (NBA/NFL)
|
||||||
|
──→ Phase 5 (Tennis/CS)
|
||||||
|
──→ Phase 6 (Admin UX)
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 1 is independent and the quickest win. Phase 2 provides infrastructure used by all subsequent phases. Phases 3-6 can proceed in any order after Phase 2.
|
||||||
|
|
||||||
|
## Key Existing Code to Reuse
|
||||||
|
|
||||||
|
| Code | Location | Used For |
|
||||||
|
|---|---|---|
|
||||||
|
| `getScoringEventsForSportsSeason()` | `app/models/scoring-event.ts` | Schedule data |
|
||||||
|
| `getSeasonResults()` | `app/models/participant-season-result.ts` | F1 standings |
|
||||||
|
| `SeasonStandings` component | `app/components/sport-season/SeasonStandings.tsx` | Base standings display |
|
||||||
|
| `SportSeasonCard` component | `app/components/sport-season/SportSeasonCard.tsx` | League home cards |
|
||||||
|
| `batchUpsertParticipantEVs()` | `app/models/participant-expected-value.ts` | Simulation output |
|
||||||
|
| `calculateEV()` | `app/services/ev-calculator.ts` | EV calculation |
|
||||||
|
| `teamStandingsSnapshots` pattern | `app/models/standings.ts` | Snapshot pattern to replicate for EV |
|
||||||
|
| `processPlayoffEvent()` / `processQualifyingEvent()` | `app/models/scoring-calculator.ts` | Basis for `processQualifyingBracketEvent()` |
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
For each phase:
|
||||||
|
1. `npm run typecheck` — no type errors
|
||||||
|
2. `npm run test:run` — unit tests pass
|
||||||
|
3. `npm run build` — production build succeeds
|
||||||
|
4. Manual testing:
|
||||||
|
- Phase 1: League → sport season for F1 shows standings + schedule + ownership highlights
|
||||||
|
- Phase 2: Admin runs simulation → EVs update, snapshot created, trend chart renders
|
||||||
|
- Phase 3: Golf sport season shows QP standings with ownership + event calendar + in-progress leaderboard
|
||||||
|
- Phase 4: NBA sport season transitions from regular season standings to playoff bracket
|
||||||
|
- Phase 5: Tennis shows QP standings + per-Grand-Slam bracket drill-down
|
||||||
|
- Phase 6: Admin can bulk-enter results, auto-finalize, correct errors
|
||||||
Loading…
Add table
Reference in a new issue