This commit is contained in:
Zeeshaun
2026-05-06 16:10:07 +00:00
parent 2ed854389c
commit 02186dcdfa
15 changed files with 557 additions and 189 deletions
+56
View File
@@ -8,6 +8,9 @@
"name": "little-league-roster-manager", "name": "little-league-roster-manager",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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", "@radix-ui/react-dialog": "^1.1.15",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -42,6 +45,59 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@emnapi/runtime": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+3
View File
@@ -9,6 +9,9 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "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", "@radix-ui/react-dialog": "^1.1.15",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "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);
}
}
+8 -8
View File
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const gameDate = searchParams.get("gameDate"); const gameDate = searchParams.get("gameDate");
const filter = gameDate ? { gameDate: new Date(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)); return NextResponse.json(serialize(lineups));
} catch (error) { } catch (error) {
return handleApiError(error); return handleApiError(error);
@@ -26,8 +26,12 @@ export async function POST(request: Request) {
try { try {
await connectToDatabase(); await connectToDatabase();
const payload = saveLineupSchema.parse(await request.json()); 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 = { const lineupPayload = {
gameDate: new Date(payload.gameDate), gameDate,
lineupNumber,
playerIds: payload.playerIds, playerIds: payload.playerIds,
startingPitcherId: payload.startingPitcherId ?? null, startingPitcherId: payload.startingPitcherId ?? null,
battingOrder: payload.battingOrder, battingOrder: payload.battingOrder,
@@ -36,11 +40,7 @@ export async function POST(request: Request) {
notes: payload.notes notes: payload.notes
}; };
const lineup = await LineupModel.findOneAndUpdate({ gameDate: lineupPayload.gameDate }, lineupPayload, { const lineup = await LineupModel.create(lineupPayload);
upsert: true,
new: true,
runValidators: true
}).lean();
await GameModel.findOneAndUpdate( await GameModel.findOneAndUpdate(
{ gameDate: lineupPayload.gameDate }, { gameDate: lineupPayload.gameDate },
@@ -52,7 +52,7 @@ export async function POST(request: Request) {
{ upsert: true, new: true } { upsert: true, new: true }
); );
return NextResponse.json(serialize(lineup), { status: 201 }); return NextResponse.json(serialize(lineup.toObject()), { status: 201 });
} catch (error) { } catch (error) {
return handleApiError(error); return handleApiError(error);
} }
+5
View File
@@ -0,0 +1,5 @@
import { PitchTrackingClient } from "@/components/PitchTrackingClient";
export default function PitchTrackingPage() {
return <PitchTrackingClient />;
}
+11 -10
View File
@@ -1,6 +1,6 @@
"use client"; "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 Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 }, { href: "/dashboard", label: "Dashboard", icon: BarChart3 },
{ href: "/roster", label: "Roster", icon: Users }, { href: "/roster", label: "Roster", icon: Users },
{ href: "/pitching", label: "Pitch Tracking", icon: TimerReset },
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList }, { href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck } { href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
]; ];
@@ -26,24 +27,24 @@ export function AppShell({
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<div className="container py-4 md:py-8"> <div className="container py-3 md:py-6">
<header className="relative overflow-hidden rounded-[2rem] border bg-primary p-6 text-primary-foreground shadow-glow md:p-10"> <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-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="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="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" /> <Trophy className="h-4 w-4" />
Little League Coach Console Little League Coach Console
</div> </div>
<h1 className="font-display text-4xl font-semibold leading-none tracking-tight md:text-6xl">{title}</h1> <h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
<p className="mt-4 max-w-2xl text-base text-white/70 md:text-lg">{subtitle}</p> <p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
</div> </div>
{actions ? <div className="relative flex items-start">{actions}</div> : null} {actions ? <div className="relative flex items-start">{actions}</div> : null}
</div> </div>
</header> </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) => { {navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const active = pathname === item.href; const active = pathname === item.href;
@@ -52,7 +53,7 @@ export function AppShell({
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( 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 active
? "bg-primary text-primary-foreground shadow-panel" ? "bg-primary text-primary-foreground shadow-panel"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@@ -65,7 +66,7 @@ export function AppShell({
})} })}
</nav> </nav>
<main className="mt-5 grid gap-5">{children}</main> <main className="mt-4 grid gap-4">{children}</main>
</div> </div>
); );
} }
+249 -70
View File
@@ -1,7 +1,27 @@
"use client"; "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 { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState"; import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard"; import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
@@ -12,7 +32,68 @@ import { Input } from "@/components/ui/input";
import { fetchJson } from "@/lib/client"; import { fetchJson } from "@/lib/client";
import { FIELD_POSITIONS } from "@/lib/constants"; import { FIELD_POSITIONS } from "@/lib/constants";
import { formatPlayerName } from "@/lib/utils"; 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() { export function LineupClient() {
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10)); const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
@@ -21,9 +102,19 @@ export function LineupClient() {
const [startingPitcherId, setStartingPitcherId] = useState<string>(""); const [startingPitcherId, setStartingPitcherId] = useState<string>("");
const [showAllPitchers, setShowAllPitchers] = useState(false); const [showAllPitchers, setShowAllPitchers] = useState(false);
const [generated, setGenerated] = useState<GeneratedLineup | null>(null); 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 [message, setMessage] = useState<string | null>(null);
const [error, setError] = 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) { async function loadPlayers(targetDate: string) {
try { try {
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`); 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(() => { useEffect(() => {
void loadPlayers(gameDate); void loadPlayers(gameDate);
void loadSavedLineups(gameDate);
}, [gameDate]); }, [gameDate]);
const selectablePitchers = useMemo( const selectablePitchers = useMemo(
@@ -60,6 +161,34 @@ export function LineupClient() {
setGenerated(next); 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() { async function generate() {
try { try {
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", { const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
@@ -81,29 +210,60 @@ export function LineupClient() {
if (!generated) return; if (!generated) return;
try { try {
await fetchJson<LineupRecord>("/api/lineups", { const saved = await fetchJson<LineupRecord>("/api/lineups", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
...generated, ...generated,
playerIds: selectedPlayerIds playerIds: selectedPlayerIds
}) })
}); });
setMessage("Lineup saved."); setMessage(`Saved as lineup #${saved.lineupNumber} for ${saved.gameDate}.`);
await loadSavedLineups(gameDate);
} catch (saveError) { } catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup."); 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 ( return (
<AppShell <AppShell
title="Generate Lineup" 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} {message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null} {error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard title="Game Setup" description="Start with the players who are available today."> <SectionCard title="Game Setup" description="Keep the setup compact for quick game-day edits.">
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label> <label>
<span className="field-label">Game Date</span> <span className="field-label">Game Date</span>
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} /> <Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
@@ -131,17 +291,17 @@ export function LineupClient() {
/> />
Show pitchers who need rest Show pitchers who need rest
</label> </label>
<div className="md:col-span-2 flex flex-wrap gap-3"> <div className="md:col-span-2 flex flex-wrap gap-2">
<Button disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}> <Button size="sm" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
Generate Suggested Lineup Generate
</Button> </Button>
<Button variant="secondary" disabled={!generated} onClick={() => void save()}> <Button size="sm" variant="secondary" disabled={!generated} onClick={() => void save()}>
Save Lineup Save Version
</Button> </Button>
</div> </div>
</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) => { {players.map((player) => {
const selected = selectedPlayerIds.includes(player._id); const selected = selectedPlayerIds.includes(player._id);
return ( return (
@@ -149,8 +309,8 @@ export function LineupClient() {
key={player._id} key={player._id}
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""} className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
> >
<CardContent className="flex items-start justify-between gap-4 pt-6"> <CardContent className="flex items-start justify-between gap-3 pt-4">
<label className="flex flex-1 cursor-pointer items-start gap-4"> <label className="flex flex-1 cursor-pointer items-start gap-3">
<input <input
type="checkbox" type="checkbox"
checked={selected} checked={selected}
@@ -164,10 +324,10 @@ export function LineupClient() {
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary" className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/> />
<span> <span>
<span className="block text-lg font-semibold"> <span className="block text-base font-semibold">
{formatPlayerName(player.firstName, player.lastName)} {formatPlayerName(player.firstName, player.lastName)}
</span> </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} #{player.jerseyNumber} {player.primaryPosition}
</span> </span>
</span> </span>
@@ -182,71 +342,55 @@ export function LineupClient() {
{generated ? ( {generated ? (
<> <>
<SectionCard title="Batting Order" description="Adjust any slot manually before saving."> <SectionCard title="Batting Order" description="Drag rows to reorder.">
<div className="divide-y divide-border"> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBattingDragEnd}>
{generated.battingOrder.map((slot, index) => ( <SortableContext items={generated.battingOrder.map((slot) => slot.playerId)} strategy={verticalListSortingStrategy}>
<div key={`${slot.playerId}-${index}`} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center"> <div className="grid gap-3">
<div> {generated.battingOrder.map((slot, index) => (
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> <SortableBattingOrderItem
Spot key={slot.playerId}
</p> slot={slot}
<p className="mt-1 text-lg font-semibold">{index + 1}</p> index={index}
</div> selectedPlayers={selectedPlayers}
<select onChange={(playerId) => {
className="field-select" const nextPlayer = selectedPlayers.find((player) => player._id === playerId);
value={slot.playerId} if (!nextPlayer || !generated) {
onChange={(event) => { return;
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value); }
if (!nextPlayer) return;
updateGenerated({ updateBattingOrder(
...generated, generated.battingOrder.map((entry, entryIndex) =>
battingOrder: generated.battingOrder.map((entry, entryIndex) => entryIndex === index
entryIndex === index ? {
? { ...entry,
...entry, playerId: nextPlayer._id,
playerId: nextPlayer._id, playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName) }
} : entry
: entry )
) );
}); }}
}} />
> ))}
{selectedPlayers.map((player) => (
<option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
))}
</select>
</div> </div>
))} </SortableContext>
</div> </DndContext>
</SectionCard> </SectionCard>
<SectionCard <SectionCard title="Defensive Alignment" description="Keep the fielding assignments editable.">
title="Defensive Alignment"
description="Assignments follow position preferences, but every field is editable."
>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{FIELD_POSITIONS.map((position) => { {FIELD_POSITIONS.map((position) => {
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position); const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
return ( return (
<div key={position} className="grid gap-4 py-4 md:grid-cols-[180px_1fr] md:items-center"> <div key={position} className="grid gap-3 py-3 md:grid-cols-[110px_1fr] md:items-center">
<div> <p className="text-sm font-semibold">{position}</p>
<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>
<select <select
className="field-select" className="field-select"
value={assignment?.playerId ?? ""} value={assignment?.playerId ?? ""}
onChange={(event) => { onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value); const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
if (!nextPlayer) return; if (!nextPlayer || !generated) return;
const nextAssignments = generated.defensiveAssignments.filter( const nextAssignments = generated.defensiveAssignments.filter((entry) => entry.position !== position);
(entry) => entry.position !== position
);
nextAssignments.push({ nextAssignments.push({
playerId: nextPlayer._id, playerId: nextPlayer._id,
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName), 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."> <SectionCard title="No Lineup Yet" description="Generate a lineup once your game-day roster is selected.">
<EmptyState <EmptyState
title="Waiting on lineup" 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>
)} )}
<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> </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>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export function PlayerForm({
submitLabel: string; submitLabel: string;
}) { }) {
return ( return (
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label> <label>
<span className="field-label">First Name</span> <span className="field-label">First Name</span>
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} /> <Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
+8 -94
View File
@@ -10,11 +10,9 @@ import { StatusBadge } from "@/components/StatusBadge";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; 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 { fetchJson } from "@/lib/client";
import { formatPlayerName } from "@/lib/utils"; import { formatPlayerName } from "@/lib/utils";
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types"; import type { PlayerInput, PlayerRecord } from "@/lib/types";
export function RosterClient() { export function RosterClient() {
const [players, setPlayers] = useState<PlayerRecord[]>([]); const [players, setPlayers] = useState<PlayerRecord[]>([]);
@@ -25,12 +23,6 @@ export function RosterClient() {
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null); const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm()); const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
const playerFormRef = useRef<HTMLDivElement | null>(null); const playerFormRef = useRef<HTMLDivElement | null>(null);
const [appearanceForm, setAppearanceForm] = useState({
playerId: "",
gameDate: new Date().toISOString().slice(0, 10),
pitchCount: 20,
notes: ""
});
const editingPlayer = useMemo( const editingPlayer = useMemo(
() => players.find((player) => player._id === editingPlayerId) ?? null, () => players.find((player) => player._id === editingPlayerId) ?? null,
@@ -43,9 +35,6 @@ export function RosterClient() {
try { try {
const response = await fetchJson<PlayerRecord[]>("/api/players"); const response = await fetchJson<PlayerRecord[]>("/api/players");
setPlayers(response); setPlayers(response);
if (response.length > 0 && !appearanceForm.playerId) {
setAppearanceForm((current) => ({ ...current, playerId: response[0]._id }));
}
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load roster."); setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
} finally { } 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 ( return (
<AppShell <AppShell
title="Roster Management" title="Roster Management"
@@ -163,68 +139,6 @@ export function RosterClient() {
</SectionCard> </SectionCard>
</div> </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 <SectionCard
title="Current Roster" title="Current Roster"
description="Tap edit for fast changes and check pitcher status at a glance." description="Tap edit for fast changes and check pitcher status at a glance."
@@ -232,7 +146,7 @@ export function RosterClient() {
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground">Loading roster...</p> <p className="text-sm text-muted-foreground">Loading roster...</p>
) : players.length > 0 ? ( ) : players.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
{players.map((player) => ( {players.map((player) => (
<Card <Card
key={player._id} 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 className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h3 className="text-xl font-semibold"> <h3 className="text-lg font-semibold md:text-xl">
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)} #{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
</h3> </h3>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
@@ -257,7 +171,7 @@ export function RosterClient() {
<StatusBadge status={player.pitcherAvailability.status} /> <StatusBadge status={player.pitcherAvailability.status} />
</div> </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> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Bats/Throws Bats/Throws
@@ -290,11 +204,11 @@ export function RosterClient() {
</div> </div>
) : null} ) : null}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => startEditingPlayer(player._id)}> <Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
{editingPlayerId === player._id ? "Editing" : "Edit"} {editingPlayerId === player._id ? "Editing" : "Edit"}
</Button> </Button>
<Button variant="destructive" onClick={() => setPlayerPendingDelete(player)}> <Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
Delete Delete
</Button> </Button>
</div> </div>
+2 -2
View File
@@ -11,11 +11,11 @@ export function SectionCard({
}) { }) {
return ( return (
<Card> <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> <CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader> </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> </Card>
); );
} }
+2 -2
View File
@@ -14,7 +14,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
} }
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { 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>) { 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>) { 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} />;
} }
+3
View File
@@ -82,7 +82,10 @@ export interface PitchingAppearanceRecord {
export interface LineupRecord extends GeneratedLineup { export interface LineupRecord extends GeneratedLineup {
_id?: string; _id?: string;
lineupNumber: number;
playerIds: string[]; playerIds: string[];
createdAt?: string;
updatedAt?: string;
} }
export interface PlayerAvailabilitySummary { export interface PlayerAvailabilitySummary {
+5 -1
View File
@@ -2,6 +2,7 @@ import { Model, Schema, Types, model, models } from "mongoose";
export interface LineupDocument { export interface LineupDocument {
gameDate: Date; gameDate: Date;
lineupNumber: number;
playerIds: Types.ObjectId[]; playerIds: Types.ObjectId[];
startingPitcherId?: Types.ObjectId | null; startingPitcherId?: Types.ObjectId | null;
battingOrder: Array<{ battingOrder: Array<{
@@ -22,7 +23,8 @@ export interface LineupDocument {
const lineupSchema = new Schema<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 }], playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null }, startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
battingOrder: [ battingOrder: [
@@ -45,4 +47,6 @@ const lineupSchema = new Schema<LineupDocument>(
{ timestamps: true } { timestamps: true }
); );
lineupSchema.index({ gameDate: 1, lineupNumber: 1 }, { unique: true });
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema); export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
File diff suppressed because one or more lines are too long