* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
import { Link } from "react-router";
|
|
import { useState } from "react";
|
|
import type { Route } from "./+types/admin._index";
|
|
|
|
import { findAllSports } from "~/models/sport";
|
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
|
import { getEventsForDates } from "~/models/scoring-event";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Trophy, Calendar, FolderKanban, ArrowRight, CheckCircle2, Clock, ExternalLink } from "lucide-react";
|
|
|
|
export function meta(): Route.MetaDescriptors {
|
|
return [{ title: "Admin - Brackt" }];
|
|
}
|
|
|
|
function toDateStr(d: Date) {
|
|
return d.toISOString().split("T")[0];
|
|
}
|
|
|
|
function getTodayAndTomorrowDates() {
|
|
const today = new Date();
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(today.getDate() + 1);
|
|
return { today: toDateStr(today), tomorrow: toDateStr(tomorrow) };
|
|
}
|
|
|
|
export async function loader() {
|
|
const { today, tomorrow } = getTodayAndTomorrowDates();
|
|
|
|
const [sports, sportsSeasons, templates, upcomingEvents] = await Promise.all([
|
|
findAllSports(),
|
|
findAllSportsSeasons(),
|
|
findAllSeasonTemplates(),
|
|
getEventsForDates([today, tomorrow]),
|
|
]);
|
|
|
|
return {
|
|
stats: {
|
|
sportsCount: sports.length,
|
|
sportsSeasonsCount: sportsSeasons.length,
|
|
templatesCount: templates.length,
|
|
},
|
|
upcomingEvents,
|
|
today,
|
|
tomorrow,
|
|
};
|
|
}
|
|
|
|
function formatEventTime(eventStartsAt: string | null): string | null {
|
|
if (!eventStartsAt) return null;
|
|
const d = new Date(eventStartsAt);
|
|
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
}
|
|
|
|
function getEventTypeLabel(eventType: string): string {
|
|
switch (eventType) {
|
|
case "playoff_game": return "Bracket";
|
|
case "major_tournament": return "Tournament";
|
|
case "final_standings": return "Final Standings";
|
|
default: return eventType;
|
|
}
|
|
}
|
|
|
|
type DashboardEvent = {
|
|
id: string;
|
|
name: string;
|
|
eventDate: string | null;
|
|
eventStartsAt: string | null;
|
|
eventType: string;
|
|
isComplete: boolean;
|
|
completedAt: string | null;
|
|
sportsSeasonId: string;
|
|
sportsSeasonName: string;
|
|
sportName: string;
|
|
sportSlug: string | null;
|
|
};
|
|
|
|
function GamesToScore({
|
|
events,
|
|
today,
|
|
tomorrow,
|
|
}: {
|
|
events: DashboardEvent[];
|
|
today: string;
|
|
tomorrow: string;
|
|
}) {
|
|
const [activeTab, setActiveTab] = useState<"today" | "tomorrow">("today");
|
|
|
|
const todayEvents = events.filter((e) => e.eventDate === today);
|
|
const tomorrowEvents = events.filter((e) => e.eventDate === tomorrow);
|
|
const displayEvents = activeTab === "today" ? todayEvents : tomorrowEvents;
|
|
|
|
const scored = displayEvents.filter((e) => e.isComplete).length;
|
|
const total = displayEvents.length;
|
|
const pending = total - scored;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle>Games to Score</CardTitle>
|
|
<CardDescription>Scoring events that need your attention</CardDescription>
|
|
</div>
|
|
{total > 0 && (
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{scored}/{total}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">scored</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tab selector */}
|
|
<div className="flex gap-2 mt-3">
|
|
<button
|
|
onClick={() => setActiveTab("today")}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === "today"
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Today
|
|
{todayEvents.length > 0 && (
|
|
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
|
|
activeTab === "today"
|
|
? "bg-primary-foreground/20 text-primary-foreground"
|
|
: "bg-muted text-muted-foreground"
|
|
}`}>
|
|
{todayEvents.filter((e) => !e.isComplete).length || todayEvents.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("tomorrow")}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === "tomorrow"
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Tomorrow
|
|
{tomorrowEvents.length > 0 && (
|
|
<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs ${
|
|
activeTab === "tomorrow"
|
|
? "bg-primary-foreground/20 text-primary-foreground"
|
|
: "bg-muted text-muted-foreground"
|
|
}`}>
|
|
{tomorrowEvents.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
{displayEvents.length === 0 ? (
|
|
<div className="py-6 text-center text-muted-foreground text-sm">
|
|
No scoring events scheduled for {activeTab === "today" ? "today" : "tomorrow"}.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{pending > 0 && (
|
|
<div className="mb-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
|
|
<Clock className="h-4 w-4 shrink-0" />
|
|
<span>{pending} event{pending !== 1 ? "s" : ""} still need{pending === 1 ? "s" : ""} scoring</span>
|
|
</div>
|
|
)}
|
|
{pending === 0 && total > 0 && (
|
|
<div className="mb-3 flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-3 py-2 text-sm text-green-800 dark:text-green-300">
|
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
|
<span>All events scored for {activeTab === "today" ? "today" : "tomorrow"}!</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{displayEvents.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
className={`flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors ${
|
|
event.isComplete
|
|
? "border-border/50 bg-muted/30 opacity-70"
|
|
: "border-border bg-card hover:bg-muted/50"
|
|
}`}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-medium truncate">{event.name}</span>
|
|
<Badge variant="outline" className="text-xs shrink-0">
|
|
{getEventTypeLabel(event.eventType)}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
|
<span className="font-medium text-foreground/70">{event.sportName}</span>
|
|
<span>·</span>
|
|
<span>{event.sportsSeasonName}</span>
|
|
{event.eventStartsAt && (
|
|
<>
|
|
<span>·</span>
|
|
<span>{formatEventTime(event.eventStartsAt)}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 ml-3 shrink-0">
|
|
{event.isComplete ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs border-green-300 text-green-700 dark:border-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/30"
|
|
>
|
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
Scored
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30"
|
|
>
|
|
Pending
|
|
</Badge>
|
|
)}
|
|
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
|
<Link
|
|
to={`/admin/sports-seasons/${event.sportsSeasonId}/events/${event.id}`}
|
|
>
|
|
{event.isComplete ? "View" : "Score"}
|
|
<ExternalLink className="h-3 w-3 ml-1" />
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
|
const { stats, upcomingEvents, today, tomorrow } = loaderData;
|
|
|
|
// Serialize dates to strings for the client component
|
|
const serializedEvents: DashboardEvent[] = upcomingEvents.map((e) => ({
|
|
...e,
|
|
eventStartsAt: e.eventStartsAt ? new Date(e.eventStartsAt).toISOString() : null,
|
|
completedAt: e.completedAt ? new Date(e.completedAt).toISOString() : null,
|
|
}));
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
|
<p className="text-muted-foreground mt-2">
|
|
Manage sports, seasons, participants, and templates
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-3 mb-8">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Sports</CardTitle>
|
|
<Trophy className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.sportsCount}</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Active sports in the system
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Sports Seasons
|
|
</CardTitle>
|
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Across all sports
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Templates</CardTitle>
|
|
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.templatesCount}</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Season templates available
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Games to Score widget — full width */}
|
|
<div className="mb-6">
|
|
<GamesToScore events={serializedEvents} today={today} tomorrow={tomorrow} />
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Quick Actions</CardTitle>
|
|
<CardDescription>Common administrative tasks</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Button variant="outline" className="w-full justify-between" asChild>
|
|
<Link to="/admin/sports/new">
|
|
Create New Sport
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" className="w-full justify-between" asChild>
|
|
<Link to="/admin/sports-seasons/new">
|
|
Create Sports Season
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" className="w-full justify-between" asChild>
|
|
<Link to="/admin/templates/new">
|
|
Create Season Template
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" className="w-full justify-between" asChild>
|
|
<Link to="/admin/standings-snapshots">
|
|
Manage Standings Snapshots
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Getting Started</CardTitle>
|
|
<CardDescription>Set up your sports system</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-start space-x-3">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
|
1
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">Create Sports</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Add sports like NFL, NBA, Golf, etc.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start space-x-3">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
|
2
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">Create Sports Seasons</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Add specific seasons for each sport
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start space-x-3">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
|
3
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">Add Participants</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Add teams or players to each sports season
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start space-x-3">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
|
4
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">Create Templates</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Bundle sports seasons into templates for leagues
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|