brackt/app/routes/admin.sports-seasons.$id.regular-standings.tsx

253 lines
9.8 KiB
TypeScript
Raw Normal View History

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/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 } },
participants,
standingsMap,
};
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
// Parse updates from form fields: wins_<id>, losses_<id>, otLosses_<id>, conference_<id>, division_<id>
const byId = new Map<
string,
{
wins?: number;
losses?: number;
otLosses?: 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 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 (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()) {
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
if (data.wins === null && data.losses === null) continue;
const wins = data.wins ?? 0;
const losses = data.losses ?? 0;
const gamesPlayed = wins + losses + (data.otLosses ?? 0);
const winPct = gamesPlayed > 0 ? wins / gamesPlayed : 0;
await upsertManualStanding(participantId, params.id, {
wins,
losses,
otLosses: data.otLosses ?? null,
gamesPlayed,
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");
// Sort participants: those with records first (by wins desc), then unrecorded
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
const sorted = [...participants].toSorted((a, b) => {
const recA = standingsMap[a.id];
const recB = standingsMap[b.id];
if (recA && recB) 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 W/L records. 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>
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">
<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;
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>
);
}