brackt/app/routes/admin.sports-seasons.$id.standings.tsx
Chris Parsons e2b178221a
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

187 lines
6.8 KiB
TypeScript

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 function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `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 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].toSorted((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) => {
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>
);
}