351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
import { Form, Link } from "react-router";
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.regular-standings";
|
|
|
|
import { logger } from "~/lib/logger";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
import { getRegularSeasonStandings, upsertManualStanding } from "~/models/regular-season-standings";
|
|
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, BarChart3 } from "lucide-react";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Regular Season Standings — ${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);
|
|
const standingsRecords = await getRegularSeasonStandings(params.id);
|
|
|
|
// Build a map of existing records keyed by participantId
|
|
const standingsMap = Object.fromEntries(
|
|
standingsRecords.map((r) => [r.participantId, r])
|
|
);
|
|
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; simulatorType: string | null } },
|
|
participants,
|
|
standingsMap,
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
|
|
// Parse updates from form fields: wins_<id>, losses_<id>, ties_<id>, otLosses_<id>, etc.
|
|
const byId = new Map<
|
|
string,
|
|
{
|
|
wins?: number;
|
|
losses?: number;
|
|
otLosses?: number | null;
|
|
ties?: number | null;
|
|
gamesPlayed?: number | null;
|
|
leagueRank?: number | null;
|
|
tablePoints?: number | null;
|
|
goalsFor?: number | null;
|
|
goalsAgainst?: number | null;
|
|
goalDifference?: number | null;
|
|
conference?: string | null;
|
|
division?: string | null;
|
|
}
|
|
>();
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
const winsMatch = key.match(/^wins_(.+)$/);
|
|
const lossesMatch = key.match(/^losses_(.+)$/);
|
|
const otMatch = key.match(/^otLosses_(.+)$/);
|
|
const tiesMatch = key.match(/^ties_(.+)$/);
|
|
const gpMatch = key.match(/^gamesPlayed_(.+)$/);
|
|
const rankMatch = key.match(/^leagueRank_(.+)$/);
|
|
const pointsMatch = key.match(/^tablePoints_(.+)$/);
|
|
const gfMatch = key.match(/^goalsFor_(.+)$/);
|
|
const gaMatch = key.match(/^goalsAgainst_(.+)$/);
|
|
const gdMatch = key.match(/^goalDifference_(.+)$/);
|
|
const confMatch = key.match(/^conference_(.+)$/);
|
|
const divMatch = key.match(/^division_(.+)$/);
|
|
|
|
if (winsMatch) {
|
|
const id = winsMatch[1];
|
|
const wins = parseInt(value as string, 10);
|
|
if (!isNaN(wins)) byId.set(id, { ...byId.get(id), wins });
|
|
} else if (lossesMatch) {
|
|
const id = lossesMatch[1];
|
|
const losses = parseInt(value as string, 10);
|
|
if (!isNaN(losses)) byId.set(id, { ...byId.get(id), losses });
|
|
} else if (otMatch) {
|
|
const id = otMatch[1];
|
|
const otLosses = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), otLosses: isNaN(otLosses as number) ? null : otLosses });
|
|
} else if (tiesMatch) {
|
|
const id = tiesMatch[1];
|
|
const ties = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), ties: isNaN(ties as number) ? null : ties });
|
|
} else if (gpMatch) {
|
|
const id = gpMatch[1];
|
|
const gamesPlayed = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), gamesPlayed: isNaN(gamesPlayed as number) ? null : gamesPlayed });
|
|
} else if (rankMatch) {
|
|
const id = rankMatch[1];
|
|
const leagueRank = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), leagueRank: isNaN(leagueRank as number) ? null : leagueRank });
|
|
} else if (pointsMatch) {
|
|
const id = pointsMatch[1];
|
|
const tablePoints = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), tablePoints: isNaN(tablePoints as number) ? null : tablePoints });
|
|
} else if (gfMatch) {
|
|
const id = gfMatch[1];
|
|
const goalsFor = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), goalsFor: isNaN(goalsFor as number) ? null : goalsFor });
|
|
} else if (gaMatch) {
|
|
const id = gaMatch[1];
|
|
const goalsAgainst = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), goalsAgainst: isNaN(goalsAgainst as number) ? null : goalsAgainst });
|
|
} else if (gdMatch) {
|
|
const id = gdMatch[1];
|
|
const goalDifference = value === "" ? null : parseInt(value as string, 10);
|
|
byId.set(id, { ...byId.get(id), goalDifference: isNaN(goalDifference as number) ? null : goalDifference });
|
|
} else if (confMatch) {
|
|
const id = confMatch[1];
|
|
byId.set(id, { ...byId.get(id), conference: (value as string).trim() || null });
|
|
} else if (divMatch) {
|
|
const id = divMatch[1];
|
|
byId.set(id, { ...byId.get(id), division: (value as string).trim() || null });
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const [participantId, data] of byId.entries()) {
|
|
if (data.wins === null && data.losses === null) continue;
|
|
const wins = data.wins ?? 0;
|
|
const losses = data.losses ?? 0;
|
|
const ties = data.ties ?? 0;
|
|
const gamesPlayed = data.gamesPlayed ?? wins + losses + ties + (data.otLosses ?? 0);
|
|
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
|
|
const goalsFor = data.goalsFor ?? null;
|
|
const goalsAgainst = data.goalsAgainst ?? null;
|
|
const goalDifference = data.goalDifference ?? (goalsFor !== null && goalsAgainst !== null ? goalsFor - goalsAgainst : null);
|
|
const tablePoints = data.tablePoints ?? wins * 3 + ties;
|
|
|
|
await upsertManualStanding(participantId, params.id, {
|
|
wins,
|
|
losses,
|
|
otLosses: data.otLosses ?? null,
|
|
ties: data.ties ?? null,
|
|
tablePoints,
|
|
goalsFor,
|
|
goalsAgainst,
|
|
goalDifference,
|
|
gamesPlayed,
|
|
leagueRank: data.leagueRank ?? null,
|
|
winPct,
|
|
conference: data.conference ?? null,
|
|
division: data.division ?? null,
|
|
});
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error("Error saving regular season standings:", error);
|
|
return { error: "Failed to save standings. Please try again." };
|
|
}
|
|
}
|
|
|
|
export default function ManageRegularStandings({ loaderData, actionData }: Route.ComponentProps) {
|
|
const { sportsSeason, participants, standingsMap } = loaderData;
|
|
const isNhl = sportsSeason.sport?.name?.toLowerCase().includes("nhl") ||
|
|
sportsSeason.sport?.name?.toLowerCase().includes("hockey");
|
|
const isEpl = sportsSeason.sport?.simulatorType === "epl_standings";
|
|
|
|
// Sort participants: those with records first (by wins desc), then unrecorded
|
|
const sorted = [...participants].toSorted((a, b) => {
|
|
const recA = standingsMap[a.id];
|
|
const recB = standingsMap[b.id];
|
|
if (recA && recB) {
|
|
if (isEpl) return (recA.leagueRank ?? 999) - (recB.leagueRank ?? 999);
|
|
return (recB.wins ?? 0) - (recA.wins ?? 0);
|
|
}
|
|
if (recA) return -1;
|
|
if (recB) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
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">Regular Season Standings</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{sportsSeason.sport.name} — {sportsSeason.name}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Manually enter current standings. These will be overwritten on the next auto-sync unless you use this page again afterward.
|
|
</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>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<BarChart3 className="h-5 w-5" />
|
|
W/L Records
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{isEpl
|
|
? "Enter Premier League table rows. Points, GP, and GD are auto-derived if left blank."
|
|
: <>Enter wins, losses{isNhl ? ", and OT losses" : ""} for each team. Conference and division are optional but improve the standings display.</>}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post">
|
|
<input type="hidden" name="_isNhl" value={isNhl ? "true" : "false"} />
|
|
|
|
<div className="space-y-1 mb-4">
|
|
{isEpl ? (
|
|
<div className="grid grid-cols-[1fr_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem] gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b">
|
|
<span>Team</span>
|
|
<span>Pos</span>
|
|
<span>GP</span>
|
|
<span>W</span>
|
|
<span>D</span>
|
|
<span>L</span>
|
|
<span>GF</span>
|
|
<span>GA</span>
|
|
<span>GD</span>
|
|
<span>Pts</span>
|
|
</div>
|
|
) : (
|
|
<div className={`grid gap-2 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}>
|
|
<span>Team</span>
|
|
<span>W</span>
|
|
<span>L</span>
|
|
{isNhl && <span>OTL</span>}
|
|
<span>Conference</span>
|
|
<span>Division</span>
|
|
</div>
|
|
)}
|
|
|
|
{sorted.map((participant) => {
|
|
const record = standingsMap[participant.id];
|
|
const isSynced = record && record.syncedAt !== null;
|
|
if (isEpl) {
|
|
return (
|
|
<div
|
|
key={participant.id}
|
|
className="grid grid-cols-[1fr_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem_3rem] gap-2 items-center px-2 py-1.5 rounded hover:bg-muted/40"
|
|
>
|
|
<span className="text-sm font-medium truncate flex items-center gap-1.5">
|
|
{participant.name}
|
|
{isSynced && (
|
|
<span className="text-xs text-muted-foreground font-normal">(synced)</span>
|
|
)}
|
|
</span>
|
|
<Input name={`leagueRank_${participant.id}`} type="number" min="1" defaultValue={record?.leagueRank ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`gamesPlayed_${participant.id}`} type="number" min="0" defaultValue={record?.gamesPlayed ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`wins_${participant.id}`} type="number" min="0" defaultValue={record?.wins ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`ties_${participant.id}`} type="number" min="0" defaultValue={record?.ties ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`losses_${participant.id}`} type="number" min="0" defaultValue={record?.losses ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`goalsFor_${participant.id}`} type="number" min="0" defaultValue={record?.goalsFor ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`goalsAgainst_${participant.id}`} type="number" min="0" defaultValue={record?.goalsAgainst ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`goalDifference_${participant.id}`} type="number" defaultValue={record?.goalDifference ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
<Input name={`tablePoints_${participant.id}`} type="number" min="0" defaultValue={record?.tablePoints ?? ""} placeholder="—" className="h-8 text-sm" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={participant.id}
|
|
className={`grid gap-2 items-center px-2 py-1.5 rounded hover:bg-muted/40 ${isNhl ? "grid-cols-[1fr_3.5rem_3.5rem_3.5rem_6rem_6rem]" : "grid-cols-[1fr_3.5rem_3.5rem_6rem_6rem]"}`}
|
|
>
|
|
<span className="text-sm font-medium truncate flex items-center gap-1.5">
|
|
{participant.name}
|
|
{isSynced && (
|
|
<span className="text-xs text-muted-foreground font-normal">(synced)</span>
|
|
)}
|
|
</span>
|
|
<Input
|
|
name={`wins_${participant.id}`}
|
|
type="number"
|
|
min="0"
|
|
defaultValue={record?.wins ?? ""}
|
|
placeholder="—"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Input
|
|
name={`losses_${participant.id}`}
|
|
type="number"
|
|
min="0"
|
|
defaultValue={record?.losses ?? ""}
|
|
placeholder="—"
|
|
className="h-8 text-sm"
|
|
/>
|
|
{isNhl && (
|
|
<Input
|
|
name={`otLosses_${participant.id}`}
|
|
type="number"
|
|
min="0"
|
|
defaultValue={record?.otLosses ?? ""}
|
|
placeholder="—"
|
|
className="h-8 text-sm"
|
|
/>
|
|
)}
|
|
<Input
|
|
name={`conference_${participant.id}`}
|
|
type="text"
|
|
defaultValue={record?.conference ?? ""}
|
|
placeholder="Eastern"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Input
|
|
name={`division_${participant.id}`}
|
|
type="text"
|
|
defaultValue={record?.division ?? ""}
|
|
placeholder="Atlantic"
|
|
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>
|
|
);
|
|
}
|