Updating
This commit is contained in:
Generated
+56
@@ -8,6 +8,9 @@
|
||||
"name": "little-league-roster-manager",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -42,6 +45,59 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const lineup = await LineupModel.findByIdAndDelete(id).lean();
|
||||
|
||||
if (!lineup) {
|
||||
return NextResponse.json({ error: "Lineup not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate");
|
||||
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
||||
const lineups = await LineupModel.find(filter).sort({ gameDate: -1 }).lean();
|
||||
const lineups = await LineupModel.find(filter).sort({ gameDate: -1, lineupNumber: -1 }).lean();
|
||||
return NextResponse.json(serialize(lineups));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
@@ -26,8 +26,12 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = saveLineupSchema.parse(await request.json());
|
||||
const gameDate = new Date(payload.gameDate);
|
||||
const latestForDate = await LineupModel.findOne({ gameDate }).sort({ lineupNumber: -1 }).select("lineupNumber").lean();
|
||||
const lineupNumber = (latestForDate?.lineupNumber ?? 0) + 1;
|
||||
const lineupPayload = {
|
||||
gameDate: new Date(payload.gameDate),
|
||||
gameDate,
|
||||
lineupNumber,
|
||||
playerIds: payload.playerIds,
|
||||
startingPitcherId: payload.startingPitcherId ?? null,
|
||||
battingOrder: payload.battingOrder,
|
||||
@@ -36,11 +40,7 @@ export async function POST(request: Request) {
|
||||
notes: payload.notes
|
||||
};
|
||||
|
||||
const lineup = await LineupModel.findOneAndUpdate({ gameDate: lineupPayload.gameDate }, lineupPayload, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
runValidators: true
|
||||
}).lean();
|
||||
const lineup = await LineupModel.create(lineupPayload);
|
||||
|
||||
await GameModel.findOneAndUpdate(
|
||||
{ gameDate: lineupPayload.gameDate },
|
||||
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(lineup), { status: 201 });
|
||||
return NextResponse.json(serialize(lineup.toObject()), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PitchTrackingClient } from "@/components/PitchTrackingClient";
|
||||
|
||||
export default function PitchTrackingPage() {
|
||||
return <PitchTrackingClient />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ClipboardList, ShieldCheck, Trophy, Users } from "lucide-react";
|
||||
import { BarChart3, ClipboardList, ShieldCheck, TimerReset, Trophy, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ href: "/roster", label: "Roster", icon: Users },
|
||||
{ href: "/pitching", label: "Pitch Tracking", icon: TimerReset },
|
||||
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
||||
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
||||
];
|
||||
@@ -26,24 +27,24 @@ export function AppShell({
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="container py-4 md:py-8">
|
||||
<header className="relative overflow-hidden rounded-[2rem] border bg-primary p-6 text-primary-foreground shadow-glow md:p-10">
|
||||
<div className="container py-3 md:py-6">
|
||||
<header className="relative overflow-hidden rounded-[1.75rem] border bg-primary p-4 text-primary-foreground shadow-glow md:p-7">
|
||||
<div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-[0.08]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-white/10 to-transparent" />
|
||||
<div className="relative flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-white/80">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/80">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Little League Coach Console
|
||||
</div>
|
||||
<h1 className="font-display text-4xl font-semibold leading-none tracking-tight md:text-6xl">{title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-base text-white/70 md:text-lg">{subtitle}</p>
|
||||
<h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="relative flex items-start">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="app-surface mt-4 flex flex-wrap gap-2 p-2">
|
||||
<nav className="app-surface mt-3 flex flex-wrap gap-1.5 p-1.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href;
|
||||
@@ -52,7 +53,7 @@ export function AppShell({
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
"inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition md:text-sm",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-panel"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
@@ -65,7 +66,7 @@ export function AppShell({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<main className="mt-5 grid gap-5">{children}</main>
|
||||
<main className="mt-4 grid gap-4">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
@@ -12,7 +32,68 @@ import { Input } from "@/components/ui/input";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||
import type { BattingSlot, GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||
|
||||
function SortableBattingOrderItem({
|
||||
slot,
|
||||
index,
|
||||
selectedPlayers,
|
||||
onChange
|
||||
}: {
|
||||
slot: BattingSlot;
|
||||
index: number;
|
||||
selectedPlayers: PlayerRecord[];
|
||||
onChange: (playerId: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: slot.playerId
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="grid gap-3 rounded-[1.5rem] border border-border bg-white/70 p-3 md:grid-cols-[auto_72px_1fr] md:items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden rounded-full p-2 text-muted-foreground hover:bg-muted md:inline-flex"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`Drag batting spot ${index + 1}`}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center justify-between md:justify-start">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground md:hidden">
|
||||
Drag
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-full p-2 text-muted-foreground hover:bg-muted md:hidden"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`Drag batting spot ${index + 1}`}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<p className="text-lg font-semibold">#{index + 1}</p>
|
||||
</div>
|
||||
<select className="field-select md:col-start-3" value={slot.playerId} onChange={(event) => onChange(event.target.value)}>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineupClient() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
@@ -21,9 +102,19 @@ export function LineupClient() {
|
||||
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
||||
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
||||
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
||||
const [savedLineups, setSavedLineups] = useState<LineupRecord[]>([]);
|
||||
const [lineupPendingDelete, setLineupPendingDelete] = useState<LineupRecord | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
|
||||
async function loadPlayers(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
||||
@@ -37,8 +128,18 @@ export function LineupClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedLineups(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<LineupRecord[]>(`/api/lineups?gameDate=${targetDate}`);
|
||||
setSavedLineups(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load saved lineups.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers(gameDate);
|
||||
void loadSavedLineups(gameDate);
|
||||
}, [gameDate]);
|
||||
|
||||
const selectablePitchers = useMemo(
|
||||
@@ -60,6 +161,34 @@ export function LineupClient() {
|
||||
setGenerated(next);
|
||||
}
|
||||
|
||||
function updateBattingOrder(order: BattingSlot[]) {
|
||||
if (!generated) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateGenerated({
|
||||
...generated,
|
||||
battingOrder: order.map((entry, index) => ({ ...entry, slot: index + 1 }))
|
||||
});
|
||||
}
|
||||
|
||||
function handleBattingDragEnd(event: DragEndEvent) {
|
||||
if (!generated || !event.over || event.active.id === event.over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overId = event.over.id;
|
||||
|
||||
const oldIndex = generated.battingOrder.findIndex((slot) => slot.playerId === event.active.id);
|
||||
const newIndex = generated.battingOrder.findIndex((slot) => slot.playerId === overId);
|
||||
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBattingOrder(arrayMove(generated.battingOrder, oldIndex, newIndex));
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
try {
|
||||
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
||||
@@ -81,29 +210,60 @@ export function LineupClient() {
|
||||
if (!generated) return;
|
||||
|
||||
try {
|
||||
await fetchJson<LineupRecord>("/api/lineups", {
|
||||
const saved = await fetchJson<LineupRecord>("/api/lineups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...generated,
|
||||
playerIds: selectedPlayerIds
|
||||
})
|
||||
});
|
||||
setMessage("Lineup saved.");
|
||||
setMessage(`Saved as lineup #${saved.lineupNumber} for ${saved.gameDate}.`);
|
||||
await loadSavedLineups(gameDate);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSavedLineup(lineup: LineupRecord) {
|
||||
if (!lineup._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/lineups/${lineup._id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
setMessage(`Deleted lineup #${lineup.lineupNumber}.`);
|
||||
setLineupPendingDelete(null);
|
||||
await loadSavedLineups(gameDate);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Generate Lineup"
|
||||
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
||||
subtitle="Build the game-day lineup, drag batting order into place, and save numbered versions."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={lineupPendingDelete !== null}
|
||||
title="Delete Saved Lineup?"
|
||||
description={
|
||||
lineupPendingDelete
|
||||
? `Delete lineup #${lineupPendingDelete.lineupNumber} for ${lineupPendingDelete.gameDate}?`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onCancel={() => setLineupPendingDelete(null)}
|
||||
onConfirm={() => (lineupPendingDelete ? void deleteSavedLineup(lineupPendingDelete) : undefined)}
|
||||
/>
|
||||
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard title="Game Setup" description="Start with the players who are available today.">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<SectionCard title="Game Setup" description="Keep the setup compact for quick game-day edits.">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className="field-label">Game Date</span>
|
||||
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||
@@ -131,17 +291,17 @@ export function LineupClient() {
|
||||
/>
|
||||
Show pitchers who need rest
|
||||
</label>
|
||||
<div className="md:col-span-2 flex flex-wrap gap-3">
|
||||
<Button disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||
Generate Suggested Lineup
|
||||
<div className="md:col-span-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||
Generate
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={!generated} onClick={() => void save()}>
|
||||
Save Lineup
|
||||
<Button size="sm" variant="secondary" disabled={!generated} onClick={() => void save()}>
|
||||
Save Version
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{players.map((player) => {
|
||||
const selected = selectedPlayerIds.includes(player._id);
|
||||
return (
|
||||
@@ -149,8 +309,8 @@ export function LineupClient() {
|
||||
key={player._id}
|
||||
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
|
||||
>
|
||||
<CardContent className="flex items-start justify-between gap-4 pt-6">
|
||||
<label className="flex flex-1 cursor-pointer items-start gap-4">
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<label className="flex flex-1 cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
@@ -164,10 +324,10 @@ export function LineupClient() {
|
||||
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-lg font-semibold">
|
||||
<span className="block text-base font-semibold">
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
#{player.jerseyNumber} {player.primaryPosition}
|
||||
</span>
|
||||
</span>
|
||||
@@ -182,25 +342,24 @@ export function LineupClient() {
|
||||
|
||||
{generated ? (
|
||||
<>
|
||||
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
||||
<div className="divide-y divide-border">
|
||||
<SectionCard title="Batting Order" description="Drag rows to reorder.">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBattingDragEnd}>
|
||||
<SortableContext items={generated.battingOrder.map((slot) => slot.playerId)} strategy={verticalListSortingStrategy}>
|
||||
<div className="grid gap-3">
|
||||
{generated.battingOrder.map((slot, index) => (
|
||||
<div key={`${slot.playerId}-${index}`} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Spot
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{index + 1}</p>
|
||||
</div>
|
||||
<select
|
||||
className="field-select"
|
||||
value={slot.playerId}
|
||||
onChange={(event) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||
if (!nextPlayer) return;
|
||||
updateGenerated({
|
||||
...generated,
|
||||
battingOrder: generated.battingOrder.map((entry, entryIndex) =>
|
||||
<SortableBattingOrderItem
|
||||
key={slot.playerId}
|
||||
slot={slot}
|
||||
index={index}
|
||||
selectedPlayers={selectedPlayers}
|
||||
onChange={(playerId) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === playerId);
|
||||
if (!nextPlayer || !generated) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateBattingOrder(
|
||||
generated.battingOrder.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? {
|
||||
...entry,
|
||||
@@ -209,44 +368,29 @@ export function LineupClient() {
|
||||
}
|
||||
: entry
|
||||
)
|
||||
});
|
||||
);
|
||||
}}
|
||||
>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Defensive Alignment"
|
||||
description="Assignments follow position preferences, but every field is editable."
|
||||
>
|
||||
<SectionCard title="Defensive Alignment" description="Keep the fielding assignments editable.">
|
||||
<div className="divide-y divide-border">
|
||||
{FIELD_POSITIONS.map((position) => {
|
||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||
return (
|
||||
<div key={position} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Position
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">{position}</p>
|
||||
</div>
|
||||
<div key={position} className="grid gap-3 py-3 md:grid-cols-[110px_1fr] md:items-center">
|
||||
<p className="text-sm font-semibold">{position}</p>
|
||||
<select
|
||||
className="field-select"
|
||||
value={assignment?.playerId ?? ""}
|
||||
onChange={(event) => {
|
||||
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
|
||||
if (!nextPlayer) return;
|
||||
const nextAssignments = generated.defensiveAssignments.filter(
|
||||
(entry) => entry.position !== position
|
||||
);
|
||||
if (!nextPlayer || !generated) return;
|
||||
const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
|
||||
nextAssignments.push({
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||
@@ -282,10 +426,45 @@ export function LineupClient() {
|
||||
<SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
|
||||
<EmptyState
|
||||
title="Waiting on lineup"
|
||||
description="Select your active players, choose a starter, and generate a suggested batting order and field setup."
|
||||
description="Select active players, choose a starter, then generate the first version."
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Saved Lineups" description="Saved versions are numbered by game date and can be deleted.">
|
||||
{savedLineups.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{savedLineups.map((lineup) => (
|
||||
<Card key={lineup._id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{lineup.battingOrder.length} hitters · {lineup.defensiveAssignments.length} field assignments
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setLineupPendingDelete(lineup)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No saved lineups"
|
||||
description="Save a generated lineup to keep numbered versions for this game date."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PitchingAppearanceRecord, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function PitchTrackingClient() {
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [appearanceForm, setAppearanceForm] = useState({
|
||||
playerId: "",
|
||||
gameDate: new Date().toISOString().slice(0, 10),
|
||||
pitchCount: 20,
|
||||
notes: ""
|
||||
});
|
||||
|
||||
async function loadPlayers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||
setPlayers(response);
|
||||
if (response.length > 0) {
|
||||
setAppearanceForm((current) => ({
|
||||
...current,
|
||||
playerId: current.playerId || response[0]._id
|
||||
}));
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers();
|
||||
}, []);
|
||||
|
||||
async function logAppearance() {
|
||||
try {
|
||||
await fetchJson<PitchingAppearanceRecord>("/api/pitching-appearances", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(appearanceForm)
|
||||
});
|
||||
setMessage("Pitching appearance recorded.");
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save pitching appearance.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Pitch Tracking"
|
||||
subtitle="Log the latest outing quickly so rest guidance stays accurate between games."
|
||||
>
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Log Appearance"
|
||||
description="Keep this lightweight for fast updates from the dugout or phone."
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading players...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className="field-label">Player</span>
|
||||
<select
|
||||
className="field-select"
|
||||
value={appearanceForm.playerId}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))
|
||||
}
|
||||
>
|
||||
{players.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Game Date</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={appearanceForm.gameDate}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className="field-label">Pitches Thrown</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={appearanceForm.pitchCount}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({
|
||||
...current,
|
||||
pitchCount: Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className="field-label">Notes</span>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={appearanceForm.notes}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Button onClick={() => void logAppearance()}>Save Pitching Appearance</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No players to track yet"
|
||||
description="Add players on the roster tab first, then return here to log outings."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Pitcher Snapshot"
|
||||
description="Quick read of each player’s latest pitching status."
|
||||
>
|
||||
{players.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{players.map((player) => (
|
||||
<Card key={player._id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
: "No pitching history"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{player.pitcherAvailability.daysSinceLastAppearance === null
|
||||
? "No history"
|
||||
: `${player.pitcherAvailability.daysSinceLastAppearance}d`}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No pitching data yet"
|
||||
description="Log the first appearance above once your roster is in place."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function PlayerForm({
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className="field-label">First Name</span>
|
||||
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||
|
||||
@@ -10,11 +10,9 @@ import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function RosterClient() {
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
@@ -25,12 +23,6 @@ export function RosterClient() {
|
||||
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
||||
const [appearanceForm, setAppearanceForm] = useState({
|
||||
playerId: "",
|
||||
gameDate: new Date().toISOString().slice(0, 10),
|
||||
pitchCount: 20,
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const editingPlayer = useMemo(
|
||||
() => players.find((player) => player._id === editingPlayerId) ?? null,
|
||||
@@ -43,9 +35,6 @@ export function RosterClient() {
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>("/api/players");
|
||||
setPlayers(response);
|
||||
if (response.length > 0 && !appearanceForm.playerId) {
|
||||
setAppearanceForm((current) => ({ ...current, playerId: response[0]._id }));
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
|
||||
} finally {
|
||||
@@ -107,19 +96,6 @@ export function RosterClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function logAppearance() {
|
||||
try {
|
||||
await fetchJson<PitchingAppearanceRecord>("/api/pitching-appearances", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(appearanceForm)
|
||||
});
|
||||
setMessage("Pitching appearance recorded.");
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save pitching appearance.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Roster Management"
|
||||
@@ -163,68 +139,6 @@ export function RosterClient() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
title="Pitch Count Log"
|
||||
description="Record the latest outing to keep rest recommendations accurate."
|
||||
>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<label>
|
||||
<span className="field-label">Player</span>
|
||||
<select
|
||||
className="field-select"
|
||||
value={appearanceForm.playerId}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, playerId: event.target.value }))
|
||||
}
|
||||
>
|
||||
{players.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Game Date</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={appearanceForm.gameDate}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Pitches Thrown</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={appearanceForm.pitchCount}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({
|
||||
...current,
|
||||
pitchCount: Number(event.target.value)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="md:col-span-3">
|
||||
<span className="field-label">Notes</span>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={appearanceForm.notes}
|
||||
onChange={(event) =>
|
||||
setAppearanceForm((current) => ({ ...current, notes: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="md:col-span-3">
|
||||
<Button onClick={() => void logAppearance()} disabled={players.length === 0}>
|
||||
Save Pitching Appearance
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Current Roster"
|
||||
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||
@@ -232,7 +146,7 @@ export function RosterClient() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{players.map((player) => (
|
||||
<Card
|
||||
key={player._id}
|
||||
@@ -242,10 +156,10 @@ export function RosterClient() {
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<CardContent className="grid gap-5 pt-6">
|
||||
<CardContent className="grid gap-4 pt-4 md:pt-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
<h3 className="text-lg font-semibold md:text-xl">
|
||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -257,7 +171,7 @@ export function RosterClient() {
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-[1.5rem] bg-muted/60 p-4 sm:grid-cols-3">
|
||||
<div className="grid gap-3 rounded-[1.5rem] bg-muted/60 p-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Bats/Throws
|
||||
@@ -290,11 +204,11 @@ export function RosterClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,11 @@ export function SectionCard({
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="px-4 pb-3 pt-4 md:px-6 md:pb-4 md:pt-6">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">{children}</CardContent>
|
||||
<CardContent className="space-y-4 px-4 pb-4 md:space-y-5 md:px-6 md:pb-6">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
||||
return <div className={cn("flex flex-col gap-1.5 p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
@@ -26,5 +26,5 @@ export function CardDescription({ className, ...props }: React.HTMLAttributes<HT
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-6 pb-6", className)} {...props} />;
|
||||
return <div className={cn("px-4 pb-4 md:px-6 md:pb-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ export interface PitchingAppearanceRecord {
|
||||
|
||||
export interface LineupRecord extends GeneratedLineup {
|
||||
_id?: string;
|
||||
lineupNumber: number;
|
||||
playerIds: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PlayerAvailabilitySummary {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface LineupDocument {
|
||||
gameDate: Date;
|
||||
lineupNumber: number;
|
||||
playerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
battingOrder: Array<{
|
||||
@@ -22,7 +23,8 @@ export interface LineupDocument {
|
||||
|
||||
const lineupSchema = new Schema<LineupDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, unique: true, index: true },
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
lineupNumber: { type: Number, required: true, min: 1 },
|
||||
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
battingOrder: [
|
||||
@@ -45,4 +47,6 @@ const lineupSchema = new Schema<LineupDocument>(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
lineupSchema.index({ gameDate: 1, lineupNumber: 1 }, { unique: true });
|
||||
|
||||
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user