- SnookerSimulator: Monte Carlo simulation of the 32-player World Championship bracket using per-frame Bernoulli win probabilities derived from Elo ratings (ELO_DIVISOR=700). Two paths: bracket- populated (respects completed matches) and pre-bracket (simulates qualifying, then seeds full draw). - Admin route /sports-seasons/:id/elo-ratings: bulk-import and per- player Elo entry with fuzzy name matching; saves ratings and auto- runs the simulation in one action. - schema: add snooker_bracket simulator type, source_elo column on participant_expected_values, unique index on (participant_id, sports_season_id). - Fix batchSaveSourceElos to use INSERT ... ON CONFLICT DO UPDATE instead of N+1 SELECT+UPDATE loop. - Fix existingElos returned as plain Record (Map doesn't survive JSON serialization through useLoaderData). - Fix "How It Works" formula to show correct divisor (700, not 400). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
877 lines
36 KiB
TypeScript
877 lines
36 KiB
TypeScript
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
||
import { getAuth } from "@clerk/react-router/server";
|
||
import { isUserAdminByClerkId } from "~/models/user";
|
||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||
|
||
import { logger } from "~/lib/logger";
|
||
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewSportsSeason } from "~/models/sports-season";
|
||
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||
import { createDailySnapshot } from "~/models/standings";
|
||
import { database } from "~/database/context";
|
||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||
import { eq, desc } from "drizzle-orm";
|
||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||
import { syncStandings } from "~/services/standings-sync/index";
|
||
import {
|
||
getPendingStandingsMappings,
|
||
deletePendingStandingsMapping,
|
||
} from "~/models/pending-standings-mappings";
|
||
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/participant";
|
||
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Input } from "~/components/ui/input";
|
||
import { Label } from "~/components/ui/label";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "~/components/ui/card";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "~/components/ui/select";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from "~/components/ui/alert-dialog";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import { Checkbox } from "~/components/ui/checkbox";
|
||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw } from "lucide-react";
|
||
import { useState } from "react";
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||
}
|
||
|
||
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);
|
||
|
||
// Get the most recent snapshot date (if any)
|
||
const db = database();
|
||
const lastSnapshot = await db
|
||
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
|
||
.from(participantEvSnapshots)
|
||
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
|
||
.orderBy(desc(participantEvSnapshots.snapshotDate))
|
||
.limit(1);
|
||
|
||
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
|
||
|
||
const simulatorInfo = sportsSeason.sport?.simulatorType
|
||
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
||
: null;
|
||
|
||
const lastStandingsSyncedAt =
|
||
sportsSeason.sport?.type === "team"
|
||
? await getLastSyncedAt(params.id)
|
||
: null;
|
||
|
||
const pendingMappings =
|
||
sportsSeason.sport?.type === "team"
|
||
? await getPendingStandingsMappings(params.id)
|
||
: [];
|
||
|
||
return {
|
||
sportsSeason,
|
||
participants,
|
||
lastSimulatedDate,
|
||
simulatorInfo,
|
||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||
pendingMappings,
|
||
};
|
||
}
|
||
|
||
export async function action(args: Route.ActionArgs) {
|
||
const { request, params } = args;
|
||
const { userId } = await getAuth(args);
|
||
const isAdmin = userId ? await isUserAdminByClerkId(userId) : false;
|
||
if (!isAdmin) {
|
||
throw new Response("Forbidden", { status: 403 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const intent = formData.get("intent");
|
||
|
||
if (intent === "delete") {
|
||
await deleteSportsSeason(params.id);
|
||
return redirect("/admin/sports-seasons");
|
||
}
|
||
|
||
if (intent === "rescore") {
|
||
try {
|
||
const db = database();
|
||
const links = await db.query.seasonSports.findMany({
|
||
where: eq(seasonSports.sportsSeasonId, params.id),
|
||
});
|
||
if (links.length === 0) {
|
||
return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." };
|
||
}
|
||
await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db)));
|
||
await Promise.all(links.map((link) => createDailySnapshot(link.seasonId, db)));
|
||
return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` };
|
||
} catch (error) {
|
||
logger.error("Error rescoring:", error);
|
||
return { error: "Failed to rescore. Please try again." };
|
||
}
|
||
}
|
||
|
||
if (intent === "sync-standings") {
|
||
try {
|
||
const result = await syncStandings(params.id);
|
||
return { success: true, intent: "sync-standings", syncResult: result };
|
||
} catch (error) {
|
||
logger.error("Error syncing standings:", error);
|
||
return {
|
||
syncError:
|
||
error instanceof Error ? error.message : "Failed to sync standings. Please try again.",
|
||
};
|
||
}
|
||
}
|
||
|
||
if (intent === "resolve-mapping") {
|
||
const externalTeamId = formData.get("externalTeamId");
|
||
const participantId = formData.get("participantId");
|
||
const standingDataRaw = formData.get("standingData");
|
||
|
||
if (
|
||
typeof externalTeamId !== "string" ||
|
||
!externalTeamId.trim() ||
|
||
typeof participantId !== "string" ||
|
||
!participantId.trim() ||
|
||
typeof standingDataRaw !== "string"
|
||
) {
|
||
return { error: "Invalid resolve-mapping payload." };
|
||
}
|
||
|
||
try {
|
||
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
|
||
|
||
// Write externalId onto the participant for future ID-first matching
|
||
await updateParticipant(participantId, { externalId: externalTeamId });
|
||
|
||
// Upsert the standing record from the stored standingData
|
||
await upsertRegularSeasonStandings([
|
||
{
|
||
participantId,
|
||
sportsSeasonId: params.id,
|
||
wins: (standingData.wins as number) ?? 0,
|
||
losses: (standingData.losses as number) ?? 0,
|
||
otLosses: (standingData.otLosses as number | null) ?? null,
|
||
ties: (standingData.ties as number | null) ?? null,
|
||
winPct: (standingData.winPct as number) ?? 0,
|
||
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
|
||
gamesBack: (standingData.gamesBack as number | null) ?? null,
|
||
conference: (standingData.conference as string | null) ?? null,
|
||
division: (standingData.division as string | null) ?? null,
|
||
conferenceRank: (standingData.conferenceRank as number | null) ?? null,
|
||
divisionRank: (standingData.divisionRank as number | null) ?? null,
|
||
leagueRank: (standingData.leagueRank as number) ?? 0,
|
||
streak: (standingData.streak as string | null) ?? null,
|
||
lastTen: (standingData.lastTen as string | null) ?? null,
|
||
homeRecord: (standingData.homeRecord as string | null) ?? null,
|
||
awayRecord: (standingData.awayRecord as string | null) ?? null,
|
||
externalTeamId,
|
||
syncedAt: new Date(),
|
||
},
|
||
]);
|
||
|
||
// Remove from pending queue
|
||
await deletePendingStandingsMapping(params.id, externalTeamId);
|
||
|
||
return { success: true, intent: "resolve-mapping", resolvedTeam: String(standingData.teamName ?? "") };
|
||
} catch (error) {
|
||
logger.error("Error resolving mapping:", error);
|
||
return { error: "Failed to resolve mapping. Please try again." };
|
||
}
|
||
}
|
||
|
||
if (intent === "finalize-standings") {
|
||
try {
|
||
await processSeasonStandings(params.id);
|
||
await updateSportsSeason(params.id, { status: "completed" });
|
||
return { success: true, intent: "finalize-standings", message: "Standings finalized and fantasy placements assigned!" };
|
||
} catch (error) {
|
||
logger.error("Error finalizing standings:", error);
|
||
return { error: "Failed to finalize standings. Please try again." };
|
||
}
|
||
}
|
||
|
||
// Update
|
||
const name = formData.get("name");
|
||
const year = formData.get("year");
|
||
const startDate = formData.get("startDate");
|
||
const endDate = formData.get("endDate");
|
||
const status = formData.get("status");
|
||
const scoringType = formData.get("scoringType");
|
||
const scoringPattern = formData.get("scoringPattern");
|
||
const totalMajors = formData.get("totalMajors");
|
||
const isDraftable = formData.get("isDraftable") === "true";
|
||
|
||
// Validation
|
||
if (typeof name !== "string" || !name.trim()) {
|
||
return { error: "Season name is required" };
|
||
}
|
||
|
||
if (typeof year !== "string") {
|
||
return { error: "Year is required" };
|
||
}
|
||
|
||
const yearNum = parseInt(year, 10);
|
||
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||
return { error: "Year must be between 2000 and 2100" };
|
||
}
|
||
|
||
if (status !== "upcoming" && status !== "active" && status !== "completed") {
|
||
return { error: "Invalid status" };
|
||
}
|
||
|
||
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
||
return { error: "Invalid scoring type" };
|
||
}
|
||
|
||
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
||
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
||
return { error: "Invalid scoring pattern" };
|
||
}
|
||
|
||
try {
|
||
const updateData: Partial<NewSportsSeason> = {
|
||
name: name.trim(),
|
||
year: yearNum,
|
||
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
||
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||
status,
|
||
scoringType,
|
||
isDraftable,
|
||
};
|
||
|
||
if (scoringPattern && typeof scoringPattern === "string") {
|
||
updateData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
|
||
}
|
||
|
||
if (totalMajors && typeof totalMajors === "string") {
|
||
const totalMajorsNum = parseInt(totalMajors, 10);
|
||
if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) {
|
||
updateData.totalMajors = totalMajorsNum;
|
||
}
|
||
}
|
||
|
||
await updateSportsSeason(params.id, updateData);
|
||
|
||
return { success: true, message: "Sports season updated successfully!" };
|
||
} catch (error) {
|
||
logger.error("Error updating sports season:", error);
|
||
return { error: "Failed to update sports season. Please try again." };
|
||
}
|
||
}
|
||
|
||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||
const navigate = useNavigate();
|
||
const navigation = useNavigation();
|
||
const isSyncingStandings =
|
||
navigation.state === "submitting" &&
|
||
(navigation.formData?.get("intent") as string) === "sync-standings";
|
||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||
|
||
return (
|
||
<div className="p-8">
|
||
<div className="max-w-2xl">
|
||
<div className="mb-6">
|
||
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Sports Season Details</CardTitle>
|
||
<CardDescription>
|
||
Update the information for this sports season
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Season Name</Label>
|
||
<Input
|
||
id="name"
|
||
name="name"
|
||
type="text"
|
||
defaultValue={sportsSeason.name}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="year">Year</Label>
|
||
<Input
|
||
id="year"
|
||
name="year"
|
||
type="number"
|
||
min="2000"
|
||
max="2100"
|
||
defaultValue={sportsSeason.year}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
||
<Input
|
||
id="startDate"
|
||
name="startDate"
|
||
type="date"
|
||
defaultValue={sportsSeason.startDate || ""}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="endDate">End Date (Optional)</Label>
|
||
<Input
|
||
id="endDate"
|
||
name="endDate"
|
||
type="date"
|
||
defaultValue={sportsSeason.endDate || ""}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="status">Status</Label>
|
||
<Select name="status" defaultValue={sportsSeason.status} required>
|
||
<SelectTrigger id="status">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||
<SelectItem value="active">Active</SelectItem>
|
||
<SelectItem value="completed">Completed</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||
<Select name="scoringType" defaultValue={sportsSeason.scoringType} required>
|
||
<SelectTrigger id="scoringType">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||
<SelectItem value="majors">Majors</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-sm text-muted-foreground">
|
||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||
<Select name="scoringPattern" value={scoringPattern} onValueChange={setScoringPattern}>
|
||
<SelectTrigger id="scoringPattern">
|
||
<SelectValue placeholder="Select scoring pattern (optional)" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="playoff_bracket">Playoff Bracket</SelectItem>
|
||
<SelectItem value="season_standings">Season Standings</SelectItem>
|
||
<SelectItem value="qualifying_points">Qualifying Points (Golf/Tennis)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-sm text-muted-foreground">
|
||
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
|
||
</p>
|
||
</div>
|
||
|
||
{scoringPattern === "qualifying_points" && (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="totalMajors">Total Majors</Label>
|
||
<Input
|
||
id="totalMajors"
|
||
name="totalMajors"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
defaultValue={sportsSeason.totalMajors || 4}
|
||
placeholder="e.g., 4 (for Golf)"
|
||
/>
|
||
<p className="text-sm text-muted-foreground">
|
||
How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams)
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="isDraftable"
|
||
name="isDraftable"
|
||
defaultChecked={sportsSeason.isDraftable}
|
||
value="true"
|
||
/>
|
||
<Label htmlFor="isDraftable">Available for drafting</Label>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
When unchecked, this season will not appear in league creation or pre-draft league settings.
|
||
</p>
|
||
</div>
|
||
|
||
{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">
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-4">
|
||
<Button type="submit" className="flex-1">
|
||
Save Changes
|
||
</Button>
|
||
<Button type="button" variant="outline" asChild>
|
||
<Link to="/admin/sports-seasons">Cancel</Link>
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Participants</CardTitle>
|
||
<CardDescription>
|
||
{participants.length} {participants.length === 1 ? "participant" : "participants"}
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/participants`)}
|
||
>
|
||
<Users className="mr-2 h-4 w-4" />
|
||
Manage Participants
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{participants.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
No participants added yet. Add teams or players to this season.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{participants.slice(0, 5).map((participant) => (
|
||
<div key={participant.id} className="text-sm">
|
||
{participant.name}
|
||
</div>
|
||
))}
|
||
{participants.length > 5 && (
|
||
<p className="text-sm text-muted-foreground">
|
||
And {participants.length - 5} more...
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Expected Values</CardTitle>
|
||
<CardDescription>
|
||
{simulatorInfo
|
||
? simulatorInfo.name
|
||
: "Manage probability distributions and projected points"}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{sportsSeason.simulationStatus === "failed" && (
|
||
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
|
||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||
Last run failed
|
||
</Badge>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Futures Odds
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Elo Ratings
|
||
</Button>
|
||
{simulatorInfo && (
|
||
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
|
||
<Button
|
||
type="submit"
|
||
size="sm"
|
||
disabled={sportsSeason.simulationStatus === "running"}
|
||
>
|
||
{sportsSeason.simulationStatus === "running" ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Running...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Zap className="mr-2 h-4 w-4" />
|
||
Run Simulation
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">
|
||
{simulatorInfo
|
||
? <>
|
||
{simulatorInfo.description}.{" "}
|
||
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
|
||
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
|
||
</>
|
||
: "Import futures odds to set probability distributions."}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Scoring Events</CardTitle>
|
||
<CardDescription>
|
||
Manage games, tournaments, and schedule entries
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/events`)}
|
||
>
|
||
<Trophy className="mr-2 h-4 w-4" />
|
||
Manage Events
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">
|
||
Create playoff brackets, major tournaments, or import a race/game schedule.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{sportsSeason.sport?.type === "team" && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Regular Season Standings</CardTitle>
|
||
<CardDescription>
|
||
Sync current W/L standings from the official API, or edit manually.
|
||
{lastStandingsSyncedAt && (
|
||
<span className="ml-1 text-muted-foreground">
|
||
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
|
||
</span>
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/regular-standings`)}
|
||
>
|
||
Edit Manually
|
||
</Button>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="sync-standings" />
|
||
<Button type="submit" size="sm" disabled={isSyncingStandings}>
|
||
{isSyncingStandings ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Syncing...
|
||
</>
|
||
) : (
|
||
<>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
Sync Standings
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
|
||
<CardContent>
|
||
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
|
||
<div className="space-y-2">
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
Synced {actionData.syncResult.synced} team{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
|
||
</div>
|
||
{actionData.syncResult.unmatched.length > 0 && (
|
||
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
|
||
<p className="font-medium mb-1">
|
||
{actionData.syncResult.unmatched.length} team{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants:
|
||
</p>
|
||
<ul className="list-disc list-inside space-y-0.5">
|
||
{actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => (
|
||
<li key={u.externalTeamId}>{u.teamName}</li>
|
||
))}
|
||
</ul>
|
||
<p className="mt-2 text-xs">
|
||
Use the "Unmatched Teams" card below to assign these to participants. Future syncs will use the saved ID.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{actionData?.syncError && (
|
||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||
{actionData.syncError}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
) : null}
|
||
</Card>
|
||
)}
|
||
|
||
{sportsSeason.sport?.type === "team" && pendingMappings.length > 0 && (
|
||
<Card className="border-amber-500/30">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||
Unmatched Teams ({pendingMappings.length})
|
||
</CardTitle>
|
||
<CardDescription>
|
||
These teams from the last sync could not be automatically matched to a participant.
|
||
Assign each one to the correct participant to resolve.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{actionData?.success && actionData.intent === "resolve-mapping" && (
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
Resolved: {actionData.resolvedTeam}
|
||
</div>
|
||
)}
|
||
{pendingMappings.map((mapping) => (
|
||
<Form key={mapping.externalTeamId} method="post" className="flex items-center gap-3">
|
||
<input type="hidden" name="intent" value="resolve-mapping" />
|
||
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
|
||
<input
|
||
type="hidden"
|
||
name="standingData"
|
||
value={JSON.stringify(mapping.standingData)}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium truncate">{mapping.teamName}</p>
|
||
<p className="text-xs text-muted-foreground">ID: {mapping.externalTeamId}</p>
|
||
</div>
|
||
<Select name="participantId" required>
|
||
<SelectTrigger className="w-56">
|
||
<SelectValue placeholder="Select participant…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{participants.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>
|
||
{p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button type="submit" size="sm">
|
||
Resolve
|
||
</Button>
|
||
</Form>
|
||
))}
|
||
</CardContent>
|
||
</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.intent === "finalize-standings" && (
|
||
<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>
|
||
<CardHeader>
|
||
<CardTitle>Fantasy Standings</CardTitle>
|
||
<CardDescription>
|
||
Force a recalculation of all fantasy standings linked to this sports season. Use this after fixing scoring bugs or data corrections.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{actionData?.success && actionData.intent === "rescore" && (
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="rescore" />
|
||
<Button type="submit" variant="outline" className="border-blue-500/50 text-blue-600 hover:bg-blue-500/10 dark:text-blue-400">
|
||
Force Re-score
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-destructive">
|
||
<CardHeader>
|
||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||
<CardDescription>
|
||
Permanently delete this sports season
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="destructive">
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
Delete Sports Season
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will permanently delete the sports season "{sportsSeason.name}" and all
|
||
associated participants and results. This action cannot be undone.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="delete" />
|
||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||
Delete
|
||
</AlertDialogAction>
|
||
</Form>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|