diff --git a/baseball/package-lock.json b/baseball/package-lock.json
index 59156a3..3fa1e43 100644
--- a/baseball/package-lock.json
+++ b/baseball/package-lock.json
@@ -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",
diff --git a/baseball/package.json b/baseball/package.json
index cf226d9..c35d3c6 100644
--- a/baseball/package.json
+++ b/baseball/package.json
@@ -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",
diff --git a/baseball/src/app/api/lineups/[id]/route.ts b/baseball/src/app/api/lineups/[id]/route.ts
new file mode 100644
index 0000000..b5f8672
--- /dev/null
+++ b/baseball/src/app/api/lineups/[id]/route.ts
@@ -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);
+ }
+}
diff --git a/baseball/src/app/api/lineups/route.ts b/baseball/src/app/api/lineups/route.ts
index cead9cf..0ef5c62 100644
--- a/baseball/src/app/api/lineups/route.ts
+++ b/baseball/src/app/api/lineups/route.ts
@@ -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);
}
diff --git a/baseball/src/app/pitching/page.tsx b/baseball/src/app/pitching/page.tsx
new file mode 100644
index 0000000..dd680fc
--- /dev/null
+++ b/baseball/src/app/pitching/page.tsx
@@ -0,0 +1,5 @@
+import { PitchTrackingClient } from "@/components/PitchTrackingClient";
+
+export default function PitchTrackingPage() {
+ return ;
+}
diff --git a/baseball/src/components/AppShell.tsx b/baseball/src/components/AppShell.tsx
index 74c2797..fe92970 100644
--- a/baseball/src/components/AppShell.tsx
+++ b/baseball/src/components/AppShell.tsx
@@ -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 (
-
-
+
+
-
+
-
+
Little League Coach Console
-
{title}
-
{subtitle}
+
{title}
+
{subtitle}
{actions ?
{actions}
: null}
-
);
}
diff --git a/baseball/src/components/LineupClient.tsx b/baseball/src/components/LineupClient.tsx
index fb6f034..8d294b0 100644
--- a/baseball/src/components/LineupClient.tsx
+++ b/baseball/src/components/LineupClient.tsx
@@ -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 (
+
+
+
+
+ Drag
+
+
+
#{index + 1}
+
+
+
+ );
+}
export function LineupClient() {
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
@@ -21,9 +102,19 @@ export function LineupClient() {
const [startingPitcherId, setStartingPitcherId] = useState
("");
const [showAllPitchers, setShowAllPitchers] = useState(false);
const [generated, setGenerated] = useState(null);
+ const [savedLineups, setSavedLineups] = useState([]);
+ const [lineupPendingDelete, setLineupPendingDelete] = useState(null);
const [message, setMessage] = useState(null);
const [error, setError] = useState(null);
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(TouchSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates
+ })
+ );
+
async function loadPlayers(targetDate: string) {
try {
const response = await fetchJson(`/api/players?gameDate=${targetDate}`);
@@ -37,8 +128,18 @@ export function LineupClient() {
}
}
+ async function loadSavedLineups(targetDate: string) {
+ try {
+ const response = await fetchJson(`/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("/api/lineups/generate", {
@@ -81,29 +210,60 @@ export function LineupClient() {
if (!generated) return;
try {
- await fetchJson("/api/lineups", {
+ const saved = await fetchJson("/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 (
+ setLineupPendingDelete(null)}
+ onConfirm={() => (lineupPendingDelete ? void deleteSavedLineup(lineupPendingDelete) : undefined)}
+ />
+
{message ? {message} : null}
{error ? {error} : null}
-
-
+
+
-
-
-
+
{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" : ""}
>
-
-