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",
"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",
+3
View File
@@ -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);
}
}
+8 -8
View File
@@ -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);
}
+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";
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>
);
}
+238 -59
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 })} />
+8 -94
View File
@@ -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>
+2 -2
View File
@@ -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>
);
}
+2 -2
View File
@@ -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} />;
}
+3
View File
@@ -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 {
+5 -1
View File
@@ -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