Updating
This commit is contained in:
@@ -374,6 +374,29 @@ describe('CreditApplicationService', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should clear stale co-applicant spouse state when switching back to a single applicant', () => {
|
||||
const marriedPrimaryApplication: any = JSON.parse(JSON.stringify(primaryAndCoApplicationAddressSameApplication));
|
||||
spyOn(creditApplicationService, 'getFullCreditApplication').and.returnValue(marriedPrimaryApplication);
|
||||
spyOn(creditApplicationService, 'saveCreditApplication').and.returnValue(of(true));
|
||||
|
||||
const memberInfo: MembershipInformation = {
|
||||
limitRequested: marriedPrimaryApplication.limitRequested,
|
||||
applicantType: CreditApplicationValues.CHOICE_NO,
|
||||
livesInMaritalPropertyState: CreditApplicationValues.CHOICE_YES,
|
||||
confirmJointCredit: null,
|
||||
coApplicantLivesInMaritalPropertyState: null,
|
||||
purposeOfLoanSignature: null,
|
||||
agreementRequirement: undefined
|
||||
};
|
||||
|
||||
creditApplicationService.saveMemberInformation(memberInfo);
|
||||
|
||||
expect(creditApplicationService.getFullCreditApplication().fullInfoBorrowers.length).toBe(1);
|
||||
expect(creditApplicationService.getFullCreditApplication().hasCoApplicantSpouse).toBeNull();
|
||||
expect(creditApplicationService.getFullCreditApplication().coApplicantLivesInMaritalPropertyState).toBeNull();
|
||||
expect(creditApplicationService.getFullCreditApplication().confirmJointCredit).toBeNull();
|
||||
});
|
||||
|
||||
it('should set Estimated Value & Property Type defaults on HELOC Rate Mod loan type', () => {
|
||||
const loanConfigurationService = TestBed.get(ConfigurationService);
|
||||
loanConfigurationService.saveConfiguration(DefaultSettings as UiSettings);
|
||||
|
||||
@@ -368,6 +368,9 @@ export class CreditApplicationService {
|
||||
if (creditApplication.hasCoApplicant === CreditApplicationValues.CHOICE_NO &&
|
||||
creditApplication.fullInfoBorrowers.length > 1) {
|
||||
creditApplication.fullInfoBorrowers.pop();
|
||||
creditApplication.hasCoApplicantSpouse = null;
|
||||
creditApplication.coApplicantLivesInMaritalPropertyState = null;
|
||||
creditApplication.confirmJointCredit = null;
|
||||
}
|
||||
if (memberInfo.applicantType === CreditApplicationValues.CHOICE_YES &&
|
||||
memberInfo.coApplicantLivesInMaritalPropertyState === CreditApplicationValues.CHOICE_YES) {
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ RUN dotnet publish -c Release -o /app/publish
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
|
||||
RUN apk add --no-cache icu-libs && apk add --no-cache tzdata;
|
||||
ENV TZ=America/Chicago
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ENV ASPNETCORE_URLS=http://+:80
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/little-league-roster
|
||||
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,102 @@
|
||||
# Little League Roster Manager
|
||||
|
||||
Mobile-friendly roster and lineup planning app for Little League baseball. It includes roster management, pitcher rest tracking, configurable rest rules, and a game-day lineup generator.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js App Router with React and TypeScript
|
||||
- MongoDB with Mongoose
|
||||
- Node.js API routes
|
||||
|
||||
## Features
|
||||
|
||||
- Manage players with positions, handedness, notes, and active status
|
||||
- Log pitching appearances and pitch counts by game date
|
||||
- Configure pitch-count-based rest rules
|
||||
- See pitcher availability as `Available`, `Needs Rest`, or `Unknown`
|
||||
- Generate suggested batting orders and defensive assignments
|
||||
- Manually adjust generated lineups before saving
|
||||
- Seed sample data for a quick demo
|
||||
- Placeholder auth utility for future expansion
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
cd baseball
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create environment variables:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Update `MONGODB_URI` in `.env` if needed.
|
||||
|
||||
Example:
|
||||
|
||||
```env
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/little-league-roster
|
||||
```
|
||||
|
||||
4. Start the app:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Open `http://localhost:3000`.
|
||||
|
||||
## Sample Data
|
||||
|
||||
You can load sample data in two ways:
|
||||
|
||||
- Click `Load Sample Team` on the dashboard
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
The seed script adds:
|
||||
|
||||
- Sample players
|
||||
- Default pitching rest rules
|
||||
- Sample pitching appearances
|
||||
- One sample saved game/lineup
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` starts the development server
|
||||
- `npm run build` builds the app
|
||||
- `npm run start` runs the production build
|
||||
- `npm run seed` seeds the database if it is empty
|
||||
|
||||
## Data Models
|
||||
|
||||
- `Player`
|
||||
- `PitchingAppearance`
|
||||
- `PitchingRule`
|
||||
- `Game`
|
||||
- `Lineup`
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```text
|
||||
baseball/
|
||||
src/
|
||||
app/ Next.js pages and API routes
|
||||
components/ Reusable UI and screen components
|
||||
lib/ DB, validation, client helpers, lineup logic
|
||||
models/ Mongoose schemas
|
||||
scripts/ Seed script
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- No authentication is enforced yet. `src/lib/auth.ts` provides a placeholder for future auth integration.
|
||||
- API routes return basic validation and server errors in a simple JSON format.
|
||||
- Pitcher eligibility is based on the most recent pitching appearance and the configured rule table.
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "little-league-roster-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"seed": "node --import tsx src/scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"mongoose": "^8.14.3",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function handleApiError(error: unknown) {
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json({ error: error.issues[0]?.message ?? "Validation failed." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unexpected server error." }, { status: 500 });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import type { DashboardResponse } from "@/lib/types";
|
||||
import { formatPlayerName, toDateOnly } from "@/lib/utils";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate") ?? toDateOnly(new Date());
|
||||
|
||||
const [players, appearances, rules] = await Promise.all([
|
||||
PlayerModel.find().sort({ jerseyNumber: 1 }).lean(),
|
||||
PitchingAppearanceModel.find().lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean()
|
||||
]);
|
||||
|
||||
const availabilityMap = buildPitcherAvailabilityMap(
|
||||
players.map((player) => ({ ...player, _id: String(player._id) })),
|
||||
appearances.map((appearance) => ({ ...appearance, playerId: String(appearance.playerId) })),
|
||||
rules,
|
||||
gameDate
|
||||
);
|
||||
|
||||
const response: DashboardResponse = {
|
||||
rosterCount: players.length,
|
||||
activePlayers: players.filter((player) => player.isActive).length,
|
||||
availablePitchers: 0,
|
||||
needsRest: 0,
|
||||
unknownPitchers: 0,
|
||||
recentPitching: players.map((player) => {
|
||||
const summary = availabilityMap[String(player._id)];
|
||||
if (summary.status === "available") response.availablePitchers += 1;
|
||||
if (summary.status === "needs-rest") response.needsRest += 1;
|
||||
if (summary.status === "unknown") response.unknownPitchers += 1;
|
||||
|
||||
return {
|
||||
playerId: String(player._id),
|
||||
playerName: formatPlayerName(player.firstName, player.lastName),
|
||||
status: summary.status,
|
||||
daysSinceLastAppearance: summary.daysSinceLastAppearance,
|
||||
requiredRestDays: summary.requiredRestDays,
|
||||
lastPitchedDate: summary.lastPitchedDate,
|
||||
pitchCount: summary.lastPitchCount
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
return NextResponse.json(serialize(response));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { generateLineup } from "@/lib/lineup";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import type { Position } from "@/lib/types";
|
||||
import { generateLineupSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = generateLineupSchema.parse(await request.json());
|
||||
|
||||
const [players, rules, appearances] = await Promise.all([
|
||||
PlayerModel.find({ _id: { $in: payload.playerIds } }).lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean(),
|
||||
PitchingAppearanceModel.find({ playerId: { $in: payload.playerIds } }).lean()
|
||||
]);
|
||||
|
||||
const lineup = generateLineup({
|
||||
players: players.map((player) => ({
|
||||
...player,
|
||||
_id: String(player._id),
|
||||
primaryPosition: player.primaryPosition as Position,
|
||||
secondaryPosition: (player.secondaryPosition ?? null) as Position | null,
|
||||
tertiaryPosition: (player.tertiaryPosition ?? null) as Position | null
|
||||
})),
|
||||
rules,
|
||||
appearances: appearances.map((appearance) => ({
|
||||
...appearance,
|
||||
playerId: String(appearance.playerId)
|
||||
})),
|
||||
gameDate: payload.gameDate,
|
||||
startingPitcherId: payload.startingPitcherId ?? null
|
||||
});
|
||||
|
||||
return NextResponse.json(serialize(lineup));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { saveLineupSchema } from "@/lib/validators";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
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();
|
||||
return NextResponse.json(serialize(lineups));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = saveLineupSchema.parse(await request.json());
|
||||
const lineupPayload = {
|
||||
gameDate: new Date(payload.gameDate),
|
||||
playerIds: payload.playerIds,
|
||||
startingPitcherId: payload.startingPitcherId ?? null,
|
||||
battingOrder: payload.battingOrder,
|
||||
defensiveAssignments: payload.defensiveAssignments,
|
||||
bench: payload.bench,
|
||||
notes: payload.notes
|
||||
};
|
||||
|
||||
const lineup = await LineupModel.findOneAndUpdate({ gameDate: lineupPayload.gameDate }, lineupPayload, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
runValidators: true
|
||||
}).lean();
|
||||
|
||||
await GameModel.findOneAndUpdate(
|
||||
{ gameDate: lineupPayload.gameDate },
|
||||
{
|
||||
gameDate: lineupPayload.gameDate,
|
||||
activePlayerIds: payload.playerIds,
|
||||
startingPitcherId: payload.startingPitcherId ?? null
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(lineup), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { pitchingAppearanceSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const playerId = searchParams.get("playerId");
|
||||
const filter = playerId ? { playerId } : {};
|
||||
const appearances = await PitchingAppearanceModel.find(filter).sort({ gameDate: -1 }).lean();
|
||||
return NextResponse.json(serialize(appearances));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = pitchingAppearanceSchema.parse(await request.json());
|
||||
const appearance = await PitchingAppearanceModel.create({
|
||||
...payload,
|
||||
gameDate: new Date(payload.gameDate)
|
||||
});
|
||||
|
||||
return NextResponse.json(serialize(appearance), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { pitchingRulesSchema } from "@/lib/validators";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const rules = await PitchingRuleModel.find().sort({ sortOrder: 1 }).lean();
|
||||
return NextResponse.json(serialize(rules));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = pitchingRulesSchema.parse(await request.json());
|
||||
|
||||
await PitchingRuleModel.deleteMany({});
|
||||
const rules = await PitchingRuleModel.insertMany(
|
||||
payload.map((rule, index) => ({
|
||||
...rule,
|
||||
sortOrder: index
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(rules));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { playerSchema } from "@/lib/validators";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const payload = playerSchema.parse(await request.json());
|
||||
const player = await PlayerModel.findByIdAndUpdate(id, payload, { new: true, runValidators: true }).lean();
|
||||
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: "Player not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(serialize(player));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
|
||||
await Promise.all([
|
||||
PitchingAppearanceModel.deleteMany({ playerId: id }),
|
||||
LineupModel.updateMany(
|
||||
{},
|
||||
{
|
||||
$pull: {
|
||||
playerIds: id,
|
||||
bench: id,
|
||||
battingOrder: { playerId: id },
|
||||
defensiveAssignments: { playerId: id }
|
||||
}
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
const player = await PlayerModel.findByIdAndDelete(id).lean();
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: "Player not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { toDateOnly } from "@/lib/utils";
|
||||
import { playerSchema } from "@/lib/validators";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate") ?? toDateOnly(new Date());
|
||||
|
||||
const [players, rules, appearances] = await Promise.all([
|
||||
PlayerModel.find().sort({ jerseyNumber: 1, lastName: 1 }).lean(),
|
||||
PitchingRuleModel.find().sort({ sortOrder: 1 }).lean(),
|
||||
PitchingAppearanceModel.find().lean()
|
||||
]);
|
||||
|
||||
const availabilityMap = buildPitcherAvailabilityMap(
|
||||
players.map((player) => ({ ...player, _id: String(player._id) })),
|
||||
appearances.map((appearance) => ({ ...appearance, playerId: String(appearance.playerId) })),
|
||||
rules,
|
||||
gameDate
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
serialize(
|
||||
players.map((player) => ({
|
||||
...player,
|
||||
_id: String(player._id),
|
||||
pitcherAvailability: availabilityMap[String(player._id)]
|
||||
}))
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = playerSchema.parse(await request.json());
|
||||
const player = await PlayerModel.create(payload);
|
||||
return NextResponse.json(serialize(player), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { seedDatabase } from "@/lib/seed";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await seedDatabase();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "@/components/DashboardClient";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
:root {
|
||||
--bg: #f4efe3;
|
||||
--surface: rgba(255, 252, 246, 0.94);
|
||||
--surface-strong: #fffdf8;
|
||||
--ink: #152033;
|
||||
--muted: #5e687c;
|
||||
--line: rgba(21, 32, 51, 0.1);
|
||||
--accent: #0d6a5b;
|
||||
--accent-strong: #094f44;
|
||||
--warning: #b87510;
|
||||
--danger: #b53a2c;
|
||||
--success: #177245;
|
||||
--shadow: 0 20px 45px rgba(21, 32, 51, 0.12);
|
||||
--radius-lg: 24px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(13, 106, 91, 0.2), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(184, 117, 16, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #f8f2e7 0%, var(--bg) 48%, #efe7d5 100%);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1120px, calc(100% - 24px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 64px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, rgba(14, 65, 92, 0.96), rgba(13, 106, 91, 0.92));
|
||||
color: white;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
margin-top: 12px;
|
||||
max-width: 50rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 18px 0 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background: var(--surface-strong);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.list-row h3,
|
||||
.empty-state h3,
|
||||
.player-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-heading p,
|
||||
.empty-state p,
|
||||
.list-row p,
|
||||
.muted-copy {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stat-card-success strong {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-card-warning strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list-row,
|
||||
.picker-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 108px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-available {
|
||||
background: rgba(23, 114, 69, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-needs-rest {
|
||||
background: rgba(181, 58, 44, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-unknown {
|
||||
background: rgba(94, 104, 124, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-success {
|
||||
background: rgba(23, 114, 69, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
background: rgba(181, 58, 44, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-grid label,
|
||||
.rule-table th,
|
||||
.rule-table td {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-grid label span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-grid input,
|
||||
.form-grid select,
|
||||
.form-grid textarea,
|
||||
.rule-table input,
|
||||
.list-row select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
padding: 12px 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox-row span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: white;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.player-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.player-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-card-head p,
|
||||
.player-notes,
|
||||
.player-meta dt {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.player-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.player-meta dd {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rule-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rule-table th,
|
||||
.rule-table td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.picker-card input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.note-block {
|
||||
margin-top: 16px;
|
||||
border-left: 4px solid var(--warning);
|
||||
padding-left: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(100% - 16px, 1120px);
|
||||
}
|
||||
|
||||
.hero,
|
||||
.tabs,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.list-row,
|
||||
.picker-card,
|
||||
.player-card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Little League Roster Manager",
|
||||
description: "Roster, pitching availability, and lineup planning for game day."
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LineupClient } from "@/components/LineupClient";
|
||||
|
||||
export default function LineupPage() {
|
||||
return <LineupClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RosterClient } from "@/components/RosterClient";
|
||||
|
||||
export default function RosterPage() {
|
||||
return <RosterClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SettingsClient } from "@/components/SettingsClient";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return <SettingsClient />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/roster", label: "Roster" },
|
||||
{ href: "/lineup", label: "Generate Lineup" },
|
||||
{ href: "/settings", label: "Pitching Rules" }
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
actions
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Little League Coach Console</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="hero-copy">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="hero-actions">{actions}</div> : null}
|
||||
</header>
|
||||
|
||||
<nav className="tabs" aria-label="Primary">
|
||||
{navItems.map((item) => (
|
||||
<Link key={item.href} href={item.href as Route} className={cn("tab", pathname === item.href && "tab-active")}>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="page-grid">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import type { DashboardResponse } from "@/lib/types";
|
||||
|
||||
export function DashboardClient() {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function loadDashboard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchJson<DashboardResponse>("/api/dashboard");
|
||||
setData(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load dashboard.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedSampleData() {
|
||||
try {
|
||||
const response = await fetchJson<{ seeded: boolean; reason?: string }>("/api/seed", { method: "POST" });
|
||||
setMessage(response.seeded ? "Sample team loaded." : response.reason ?? "Seed skipped.");
|
||||
await loadDashboard();
|
||||
} catch (seedError) {
|
||||
setError(seedError instanceof Error ? seedError.message : "Failed to seed data.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Game Day Snapshot"
|
||||
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
|
||||
actions={
|
||||
<button className="button button-secondary" onClick={() => void seedSampleData()}>
|
||||
Load Sample Team
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
|
||||
) : data && data.rosterCount > 0 ? (
|
||||
<>
|
||||
<section className="stats-grid">
|
||||
<article className="stat-card">
|
||||
<span>Rostered Players</span>
|
||||
<strong>{data.rosterCount}</strong>
|
||||
</article>
|
||||
<article className="stat-card">
|
||||
<span>Active Today</span>
|
||||
<strong>{data.activePlayers}</strong>
|
||||
</article>
|
||||
<article className="stat-card stat-card-success">
|
||||
<span>Pitchers Available</span>
|
||||
<strong>{data.availablePitchers}</strong>
|
||||
</article>
|
||||
<article className="stat-card stat-card-warning">
|
||||
<span>Need Rest</span>
|
||||
<strong>{data.needsRest}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<SectionCard
|
||||
title="Pitcher Availability"
|
||||
description="Availability is based on the latest appearance and your configured rest rules."
|
||||
>
|
||||
<div className="list-stack">
|
||||
{data.recentPitching.map((entry) => (
|
||||
<article key={entry.playerId} className="list-row">
|
||||
<div>
|
||||
<h3>{entry.playerName}</h3>
|
||||
<p>
|
||||
{entry.lastPitchedDate
|
||||
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
|
||||
: "No pitching history recorded yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="list-meta">
|
||||
<StatusBadge status={entry.status} />
|
||||
<small>
|
||||
{entry.daysSinceLastAppearance === null
|
||||
? "No history"
|
||||
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<SectionCard title="No Team Yet" description="Start with sample data or add players on the roster screen.">
|
||||
<EmptyState
|
||||
title="Roster is empty"
|
||||
description="Load the sample team to explore the app, then adjust players and pitching records as needed."
|
||||
action={
|
||||
<button className="button" onClick={() => void seedSampleData()}>
|
||||
Load Sample Team
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function LineupClient() {
|
||||
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [players, setPlayers] = useState<PlayerRecord[]>([]);
|
||||
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>([]);
|
||||
const [startingPitcherId, setStartingPitcherId] = useState<string>("");
|
||||
const [showAllPitchers, setShowAllPitchers] = useState(false);
|
||||
const [generated, setGenerated] = useState<GeneratedLineup | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadPlayers(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
|
||||
setPlayers(response);
|
||||
const activeIds = response.filter((player) => player.isActive).map((player) => player._id);
|
||||
setSelectedPlayerIds(activeIds);
|
||||
const firstEligible = response.find((player) => player.pitcherAvailability.status === "available");
|
||||
setStartingPitcherId(firstEligible?._id ?? response[0]?._id ?? "");
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load players.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers(gameDate);
|
||||
}, [gameDate]);
|
||||
|
||||
const selectablePitchers = useMemo(
|
||||
() =>
|
||||
players.filter((player) =>
|
||||
selectedPlayerIds.includes(player._id) &&
|
||||
(showAllPitchers || player.pitcherAvailability.status !== "needs-rest")
|
||||
),
|
||||
[players, selectedPlayerIds, showAllPitchers]
|
||||
);
|
||||
|
||||
const selectedPlayers = useMemo(
|
||||
() => players.filter((player) => selectedPlayerIds.includes(player._id)),
|
||||
[players, selectedPlayerIds]
|
||||
);
|
||||
|
||||
function updateGenerated(next: GeneratedLineup | null) {
|
||||
setGenerated(next);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
try {
|
||||
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
gameDate,
|
||||
playerIds: selectedPlayerIds,
|
||||
startingPitcherId: startingPitcherId || null
|
||||
})
|
||||
});
|
||||
setGenerated(response);
|
||||
setMessage("Suggested lineup generated.");
|
||||
} catch (generateError) {
|
||||
setError(generateError instanceof Error ? generateError.message : "Unable to generate lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!generated) return;
|
||||
|
||||
try {
|
||||
await fetchJson<LineupRecord>("/api/lineups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...generated,
|
||||
playerIds: selectedPlayerIds
|
||||
})
|
||||
});
|
||||
setMessage("Lineup saved.");
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Generate Lineup"
|
||||
subtitle="Choose the active group, pick an eligible starter, then adjust the suggested lineup as needed."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard title="Game Setup" description="Start with the players who are available today.">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Game Date</span>
|
||||
<input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Starting Pitcher</span>
|
||||
<select value={startingPitcherId} onChange={(event) => setStartingPitcherId(event.target.value)}>
|
||||
{selectablePitchers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={showAllPitchers} onChange={(event) => setShowAllPitchers(event.target.checked)} />
|
||||
<span>Show pitchers who need rest</span>
|
||||
</label>
|
||||
<div className="full-width action-row">
|
||||
<button className="button" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
|
||||
Generate Suggested Lineup
|
||||
</button>
|
||||
<button className="button button-secondary" disabled={!generated} onClick={() => void save()}>
|
||||
Save Lineup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picker-grid">
|
||||
{players.map((player) => (
|
||||
<label key={player._id} className="picker-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlayerIds.includes(player._id)}
|
||||
onChange={(event) =>
|
||||
setSelectedPlayerIds((current) =>
|
||||
event.target.checked ? [...current, player._id] : current.filter((id) => id !== player._id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<strong>{formatPlayerName(player.firstName, player.lastName)}</strong>
|
||||
<p>
|
||||
#{player.jerseyNumber} {player.primaryPosition}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{generated ? (
|
||||
<>
|
||||
<SectionCard title="Batting Order" description="Adjust any slot manually before saving.">
|
||||
<div className="list-stack">
|
||||
{generated.battingOrder.map((slot, index) => (
|
||||
<div key={`${slot.playerId}-${index}`} className="list-row">
|
||||
<div>
|
||||
<h3>Spot {index + 1}</h3>
|
||||
</div>
|
||||
<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) =>
|
||||
entryIndex === index
|
||||
? {
|
||||
...entry,
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
|
||||
}
|
||||
: entry
|
||||
)
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Defensive Alignment" description="Assignments follow position preferences, but every field is editable.">
|
||||
<div className="list-stack">
|
||||
{FIELD_POSITIONS.map((position) => {
|
||||
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
|
||||
return (
|
||||
<div key={position} className="list-row">
|
||||
<div>
|
||||
<h3>{position}</h3>
|
||||
</div>
|
||||
<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);
|
||||
nextAssignments.push({
|
||||
playerId: nextPlayer._id,
|
||||
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName),
|
||||
position
|
||||
});
|
||||
updateGenerated({
|
||||
...generated,
|
||||
defensiveAssignments: nextAssignments
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{selectedPlayers.map((player) => (
|
||||
<option key={player._id} value={player._id}>
|
||||
{formatPlayerName(player.firstName, player.lastName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{generated.notes.length > 0 ? (
|
||||
<div className="note-block">
|
||||
{generated.notes.map((note) => (
|
||||
<p key={note}>{note}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
<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."
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
import type { PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
const defaultForm: PlayerInput = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
jerseyNumber: 0,
|
||||
batsThrows: "R/R",
|
||||
primaryPosition: "P",
|
||||
secondaryPosition: "C",
|
||||
tertiaryPosition: "1B",
|
||||
isActive: true,
|
||||
notes: ""
|
||||
};
|
||||
|
||||
export function emptyPlayerForm() {
|
||||
return { ...defaultForm };
|
||||
}
|
||||
|
||||
export function toPlayerForm(player: PlayerRecord): PlayerInput {
|
||||
return {
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
jerseyNumber: player.jerseyNumber,
|
||||
batsThrows: player.batsThrows,
|
||||
primaryPosition: player.primaryPosition,
|
||||
secondaryPosition: player.secondaryPosition ?? null,
|
||||
tertiaryPosition: player.tertiaryPosition ?? null,
|
||||
isActive: player.isActive,
|
||||
notes: player.notes ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export function PlayerForm({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitLabel
|
||||
}: {
|
||||
value: PlayerInput;
|
||||
onChange: (next: PlayerInput) => void;
|
||||
onSubmit: () => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>First Name</span>
|
||||
<input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Last Name</span>
|
||||
<input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Jersey #</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value.jerseyNumber}
|
||||
onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Bats / Throws</span>
|
||||
<select value={value.batsThrows} onChange={(event) => onChange({ ...value, batsThrows: event.target.value as PlayerInput["batsThrows"] })}>
|
||||
{HANDEDNESS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary Position</span>
|
||||
<select value={value.primaryPosition} onChange={(event) => onChange({ ...value, primaryPosition: event.target.value as PlayerInput["primaryPosition"] })}>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Secondary Position</span>
|
||||
<select
|
||||
value={value.secondaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, secondaryPosition: event.target.value ? (event.target.value as PlayerInput["secondaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Tertiary Position</span>
|
||||
<select
|
||||
value={value.tertiaryPosition ?? ""}
|
||||
onChange={(event) => onChange({ ...value, tertiaryPosition: event.target.value ? (event.target.value as PlayerInput["tertiaryPosition"]) : null })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{POSITIONS.map((position) => (
|
||||
<option key={position} value={position}>
|
||||
{position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} />
|
||||
<span>Active for selection</span>
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
||||
</label>
|
||||
<div className="full-width">
|
||||
<button className="button" onClick={onSubmit}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
import type { PitchingAppearanceRecord, PlayerInput, PlayerRecord } from "@/lib/types";
|
||||
|
||||
export function RosterClient() {
|
||||
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 [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||
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,
|
||||
[editingPlayerId, players]
|
||||
);
|
||||
|
||||
async function loadPlayers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPlayer) {
|
||||
setPlayerForm(toPlayerForm(editingPlayer));
|
||||
} else {
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
}
|
||||
}, [editingPlayer]);
|
||||
|
||||
async function submitPlayer() {
|
||||
try {
|
||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||
const method = editingPlayerId ? "PATCH" : "POST";
|
||||
await fetchJson<PlayerRecord>(url, { method, body: JSON.stringify(playerForm) });
|
||||
setMessage(editingPlayerId ? "Player updated." : "Player added.");
|
||||
setEditingPlayerId(null);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
await loadPlayers();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "Unable to save player.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlayer(player: PlayerRecord) {
|
||||
if (!window.confirm(`Remove ${formatPlayerName(player.firstName, player.lastName)} from the roster?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
|
||||
setMessage("Player removed.");
|
||||
await loadPlayers();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
subtitle="Add players, update availability, and keep pitching history current from one screen."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard
|
||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||
description="Keep player details simple so a coach can update them quickly before game time."
|
||||
>
|
||||
<PlayerForm value={playerForm} onChange={setPlayerForm} onSubmit={() => void submitPlayer()} submitLabel={editingPlayerId ? "Save Changes" : "Add Player"} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Pitch Count Log" description="Record the latest outing to keep rest recommendations accurate.">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span>Player</span>
|
||||
<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>Game Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={appearanceForm.gameDate}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, gameDate: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Pitches Thrown</span>
|
||||
<input
|
||||
type="number"
|
||||
value={appearanceForm.pitchCount}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, pitchCount: Number(event.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="full-width">
|
||||
<span>Notes</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={appearanceForm.notes}
|
||||
onChange={(event) => setAppearanceForm((current) => ({ ...current, notes: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="full-width">
|
||||
<button className="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.">
|
||||
{loading ? (
|
||||
<p className="muted-copy">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="card-grid">
|
||||
{players.map((player) => (
|
||||
<article key={player._id} className="player-card">
|
||||
<div className="player-card-head">
|
||||
<div>
|
||||
<h3>
|
||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||
</h3>
|
||||
<p>
|
||||
{player.primaryPosition}
|
||||
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
||||
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</div>
|
||||
<dl className="player-meta">
|
||||
<div>
|
||||
<dt>Bats/Throws</dt>
|
||||
<dd>{player.batsThrows}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{player.isActive ? "Active" : "Inactive"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pitching</dt>
|
||||
<dd>
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
: "No history"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{player.notes ? <p className="player-notes">{player.notes}</p> : null}
|
||||
<div className="action-row">
|
||||
<button className="button button-secondary" onClick={() => setEditingPlayerId(player._id)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="button button-danger" onClick={() => void deletePlayer(player)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="No players yet" description="Add your first player above or load sample data from the dashboard." />
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="section-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SectionCard } from "@/components/SectionCard";
|
||||
import { fetchJson } from "@/lib/client";
|
||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
||||
import type { PitchingRuleRecord } from "@/lib/types";
|
||||
|
||||
export function SettingsClient() {
|
||||
const [rules, setRules] = useState<PitchingRuleRecord[]>(DEFAULT_PITCHING_RULES);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function loadRules() {
|
||||
try {
|
||||
const response = await fetchJson<PitchingRuleRecord[]>("/api/pitching-rules");
|
||||
if (response.length > 0) {
|
||||
setRules(
|
||||
response.map((rule) => ({
|
||||
minPitches: rule.minPitches,
|
||||
maxPitches: rule.maxPitches,
|
||||
requiredRestDays: rule.requiredRestDays
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load rules.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, []);
|
||||
|
||||
async function saveRules() {
|
||||
try {
|
||||
await fetchJson("/api/pitching-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(rules)
|
||||
});
|
||||
setMessage("Pitching rules updated.");
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Failed to save rules.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Pitching Rest Rules"
|
||||
subtitle="Tune the rest windows your league uses so eligibility stays accurate and easy to trust."
|
||||
>
|
||||
{message ? <div className="banner banner-success">{message}</div> : null}
|
||||
{error ? <div className="banner banner-error">{error}</div> : null}
|
||||
|
||||
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
|
||||
<div className="table-wrap">
|
||||
<table className="rule-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Min Pitches</th>
|
||||
<th>Max Pitches</th>
|
||||
<th>Rest Days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, index) => (
|
||||
<tr key={`${rule.minPitches}-${index}`}>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.minPitches}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.maxPitches ?? ""}
|
||||
placeholder="No max"
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index
|
||||
? { ...entry, maxPitches: event.target.value ? Number(event.target.value) : null }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.requiredRestDays}
|
||||
onChange={(event) =>
|
||||
setRules((current) =>
|
||||
current.map((entry, entryIndex) =>
|
||||
entryIndex === index ? { ...entry, requiredRestDays: Number(event.target.value) } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<button className="button button-secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
||||
Reset to Example Rules
|
||||
</button>
|
||||
<button className="button" onClick={() => void saveRules()}>
|
||||
Save Rules
|
||||
</button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PitcherAvailability } from "@/lib/types";
|
||||
import { cn, statusLabel } from "@/lib/utils";
|
||||
|
||||
export function StatusBadge({ status }: { status: PitcherAvailability }) {
|
||||
return <span className={cn("badge", `badge-${status}`)}>{statusLabel(status)}</span>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface UserContext {
|
||||
userId: string | null;
|
||||
role: "coach" | "admin" | "viewer";
|
||||
}
|
||||
|
||||
export async function getCurrentUserContext(): Promise<UserContext> {
|
||||
// Placeholder for future auth integration.
|
||||
return {
|
||||
userId: null,
|
||||
role: "admin"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
export async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error ?? "Request failed.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Handedness, PitchingRuleInput, Position } from "@/lib/types";
|
||||
|
||||
export const POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "UTIL"] as const satisfies readonly Position[];
|
||||
|
||||
export const FIELD_POSITIONS = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"] as const satisfies readonly Position[];
|
||||
|
||||
export const DEFAULT_PITCHING_RULES: PitchingRuleInput[] = [
|
||||
{ minPitches: 1, maxPitches: 20, requiredRestDays: 0 },
|
||||
{ minPitches: 21, maxPitches: 35, requiredRestDays: 1 },
|
||||
{ minPitches: 36, maxPitches: 50, requiredRestDays: 2 },
|
||||
{ minPitches: 51, maxPitches: 65, requiredRestDays: 3 },
|
||||
{ minPitches: 66, maxPitches: null, requiredRestDays: 4 }
|
||||
];
|
||||
|
||||
export const HANDEDNESS_OPTIONS = ["R/R", "R/L", "L/L", "L/R", "Switch"] as const satisfies readonly Handedness[];
|
||||
@@ -0,0 +1,35 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var mongooseCache:
|
||||
| {
|
||||
conn: typeof mongoose | null;
|
||||
promise: Promise<typeof mongoose> | null;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const cache = global.mongooseCache ?? { conn: null, promise: null };
|
||||
global.mongooseCache = cache;
|
||||
|
||||
export async function connectToDatabase() {
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error("Missing MONGODB_URI environment variable.");
|
||||
}
|
||||
|
||||
if (cache.conn) {
|
||||
return cache.conn;
|
||||
}
|
||||
|
||||
if (!cache.promise) {
|
||||
cache.promise = mongoose.connect(MONGODB_URI, {
|
||||
bufferCommands: false
|
||||
});
|
||||
}
|
||||
|
||||
cache.conn = await cache.promise;
|
||||
return cache.conn;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FIELD_POSITIONS } from "@/lib/constants";
|
||||
import { buildPitcherAvailabilityMap } from "@/lib/pitching";
|
||||
import type { GeneratedLineup, Position } from "@/lib/types";
|
||||
import { formatPlayerName } from "@/lib/utils";
|
||||
|
||||
interface PlayerLike {
|
||||
_id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jerseyNumber: number;
|
||||
isActive: boolean;
|
||||
primaryPosition: Position;
|
||||
secondaryPosition?: Position | null;
|
||||
tertiaryPosition?: Position | null;
|
||||
}
|
||||
|
||||
interface AppearanceLike {
|
||||
playerId: string;
|
||||
gameDate: Date | string;
|
||||
pitchCount: number;
|
||||
}
|
||||
|
||||
interface RuleLike {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
function positionScore(player: PlayerLike, position: Position) {
|
||||
if (player.primaryPosition === position) return 100;
|
||||
if (player.secondaryPosition === position) return 75;
|
||||
if (player.tertiaryPosition === position) return 50;
|
||||
if (player.primaryPosition === "UTIL") return 25;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function generateLineup({
|
||||
players,
|
||||
appearances,
|
||||
rules,
|
||||
gameDate,
|
||||
startingPitcherId
|
||||
}: {
|
||||
players: PlayerLike[];
|
||||
appearances: AppearanceLike[];
|
||||
rules: RuleLike[];
|
||||
gameDate: string;
|
||||
startingPitcherId?: string | null;
|
||||
}): GeneratedLineup {
|
||||
const notes: string[] = [];
|
||||
const availabilityMap = buildPitcherAvailabilityMap(players, appearances, rules, gameDate);
|
||||
const availablePlayers = players.filter((player) => player.isActive);
|
||||
const lineupPool = availablePlayers.length > 0 ? availablePlayers : players;
|
||||
const selectedPitcher =
|
||||
startingPitcherId && lineupPool.find((player) => player._id === startingPitcherId)
|
||||
? lineupPool.find((player) => player._id === startingPitcherId) ?? null
|
||||
: null;
|
||||
|
||||
if (selectedPitcher && availabilityMap[selectedPitcher._id]?.status === "needs-rest") {
|
||||
notes.push(`${formatPlayerName(selectedPitcher.firstName, selectedPitcher.lastName)} is currently marked as needing rest.`);
|
||||
}
|
||||
|
||||
const remainingPlayers = lineupPool.filter((player) => player._id !== selectedPitcher?._id);
|
||||
const defensiveAssignments: GeneratedLineup["defensiveAssignments"] = [];
|
||||
const assigned = new Set<string>();
|
||||
|
||||
if (selectedPitcher) {
|
||||
defensiveAssignments.push({
|
||||
playerId: selectedPitcher._id,
|
||||
playerName: formatPlayerName(selectedPitcher.firstName, selectedPitcher.lastName),
|
||||
position: "P"
|
||||
});
|
||||
assigned.add(selectedPitcher._id);
|
||||
}
|
||||
|
||||
for (const position of FIELD_POSITIONS.filter((entry) => entry !== "P")) {
|
||||
const candidate = remainingPlayers
|
||||
.filter((player) => !assigned.has(player._id))
|
||||
.sort((a, b) => positionScore(b, position) - positionScore(a, position))[0];
|
||||
|
||||
if (!candidate) {
|
||||
break;
|
||||
}
|
||||
|
||||
defensiveAssignments.push({
|
||||
playerId: candidate._id,
|
||||
playerName: formatPlayerName(candidate.firstName, candidate.lastName),
|
||||
position
|
||||
});
|
||||
assigned.add(candidate._id);
|
||||
}
|
||||
|
||||
const battingPool = [...lineupPool].sort((a, b) => {
|
||||
const aFlex = [a.primaryPosition, a.secondaryPosition, a.tertiaryPosition].filter(Boolean).length;
|
||||
const bFlex = [b.primaryPosition, b.secondaryPosition, b.tertiaryPosition].filter(Boolean).length;
|
||||
if (bFlex !== aFlex) return bFlex - aFlex;
|
||||
return a.jerseyNumber - b.jerseyNumber;
|
||||
});
|
||||
|
||||
const battingOrder = battingPool.map((player, index) => ({
|
||||
playerId: player._id,
|
||||
playerName: formatPlayerName(player.firstName, player.lastName),
|
||||
slot: index + 1
|
||||
}));
|
||||
|
||||
const bench = lineupPool.filter((player) => !assigned.has(player._id)).map((player) => player._id);
|
||||
|
||||
if (!selectedPitcher) {
|
||||
notes.push("No starting pitcher was selected. Choose one before game time.");
|
||||
}
|
||||
|
||||
if (lineupPool.length < FIELD_POSITIONS.length) {
|
||||
notes.push("Fewer than nine players are available. Some positions will need to be covered creatively.");
|
||||
}
|
||||
|
||||
return {
|
||||
gameDate,
|
||||
startingPitcherId: selectedPitcher?._id ?? null,
|
||||
battingOrder,
|
||||
defensiveAssignments,
|
||||
bench,
|
||||
notes
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PlayerAvailabilitySummary } from "@/lib/types";
|
||||
import { daysBetween, toDateOnly } from "@/lib/utils";
|
||||
|
||||
interface PlayerLike {
|
||||
_id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
primaryPosition: string;
|
||||
secondaryPosition?: string | null;
|
||||
tertiaryPosition?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AppearanceLike {
|
||||
playerId: string;
|
||||
gameDate: Date | string;
|
||||
pitchCount: number;
|
||||
}
|
||||
|
||||
interface RuleLike {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
export function resolveRestDays(pitchCount: number, rules: RuleLike[]) {
|
||||
const match = rules.find(
|
||||
(rule) => pitchCount >= rule.minPitches && (rule.maxPitches === null || pitchCount <= rule.maxPitches)
|
||||
);
|
||||
|
||||
return match?.requiredRestDays ?? 0;
|
||||
}
|
||||
|
||||
export function summarizePitcherAvailability(
|
||||
playerId: string,
|
||||
appearances: AppearanceLike[],
|
||||
rules: RuleLike[],
|
||||
gameDate: string
|
||||
): PlayerAvailabilitySummary {
|
||||
const latest = appearances
|
||||
.filter((appearance) => appearance.playerId === playerId)
|
||||
.sort((a, b) => new Date(b.gameDate).getTime() - new Date(a.gameDate).getTime())[0];
|
||||
|
||||
if (!latest) {
|
||||
return {
|
||||
status: "unknown",
|
||||
daysSinceLastAppearance: null,
|
||||
requiredRestDays: null,
|
||||
lastPitchedDate: null,
|
||||
lastPitchCount: null
|
||||
};
|
||||
}
|
||||
|
||||
const daysSinceLastAppearance = daysBetween(latest.gameDate, gameDate);
|
||||
const requiredRestDays = resolveRestDays(latest.pitchCount, rules);
|
||||
const status = daysSinceLastAppearance >= requiredRestDays ? "available" : "needs-rest";
|
||||
|
||||
return {
|
||||
status,
|
||||
daysSinceLastAppearance,
|
||||
requiredRestDays,
|
||||
lastPitchedDate: toDateOnly(latest.gameDate),
|
||||
lastPitchCount: latest.pitchCount
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPitcherAvailabilityMap(players: PlayerLike[], appearances: AppearanceLike[], rules: RuleLike[], gameDate: string) {
|
||||
return Object.fromEntries(
|
||||
players.map((player) => [player._id, summarizePitcherAvailability(player._id, appearances, rules, gameDate)])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DEFAULT_PITCHING_RULES } from "@/lib/constants";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
|
||||
import { PitchingRuleModel } from "@/models/PitchingRule";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
const samplePlayers = [
|
||||
["Mason", "Reed", 2, "R/R", "SS", "P", "2B", true, "Steady glove and strong arm."],
|
||||
["Eli", "Watson", 4, "R/R", "P", "1B", "CF", true, "Reliable control pitcher."],
|
||||
["Noah", "Foster", 7, "L/L", "CF", "LF", "P", true, "Tracks fly balls well."],
|
||||
["Liam", "Parker", 9, "R/R", "C", "3B", "1B", true, "Best communicator on defense."],
|
||||
["Owen", "Hayes", 11, "R/L", "2B", "SS", "CF", true, "Quick hands on double plays."],
|
||||
["Lucas", "Brooks", 14, "L/R", "1B", "P", "RF", true, "Lefty target at first base."],
|
||||
["Jack", "Morris", 18, "R/R", "3B", "C", "2B", true, "Aggressive base runner."],
|
||||
["Henry", "Cole", 21, "Switch", "LF", "RF", "CF", true, "Good contact hitter."],
|
||||
["Wyatt", "Price", 24, "R/R", "RF", "LF", "1B", true, "Can fill corner spots."],
|
||||
["Caleb", "Ross", 27, "R/R", "P", "UTIL", "1B", false, "Recovering from a sore shoulder."]
|
||||
] as const;
|
||||
|
||||
export async function seedDatabase() {
|
||||
await connectToDatabase();
|
||||
|
||||
const playerCount = await PlayerModel.countDocuments();
|
||||
if (playerCount > 0) {
|
||||
return { seeded: false, reason: "Players already exist." };
|
||||
}
|
||||
|
||||
const createdPlayers = await PlayerModel.insertMany(
|
||||
samplePlayers.map(([firstName, lastName, jerseyNumber, batsThrows, primaryPosition, secondaryPosition, tertiaryPosition, isActive, notes]) => ({
|
||||
firstName,
|
||||
lastName,
|
||||
jerseyNumber,
|
||||
batsThrows,
|
||||
primaryPosition,
|
||||
secondaryPosition,
|
||||
tertiaryPosition,
|
||||
isActive,
|
||||
notes
|
||||
}))
|
||||
);
|
||||
|
||||
await PitchingRuleModel.insertMany(
|
||||
DEFAULT_PITCHING_RULES.map((rule, index) => ({
|
||||
...rule,
|
||||
sortOrder: index
|
||||
}))
|
||||
);
|
||||
|
||||
const lookup = Object.fromEntries(createdPlayers.map((player) => [`${player.firstName} ${player.lastName}`, player._id]));
|
||||
|
||||
await PitchingAppearanceModel.insertMany([
|
||||
{ playerId: lookup["Eli Watson"], gameDate: new Date("2026-05-03"), pitchCount: 42, notes: "Saturday league game." },
|
||||
{ playerId: lookup["Mason Reed"], gameDate: new Date("2026-05-05"), pitchCount: 18, notes: "Short relief outing." },
|
||||
{ playerId: lookup["Noah Foster"], gameDate: new Date("2026-04-28"), pitchCount: 55, notes: "Closed a tough game." },
|
||||
{ playerId: lookup["Lucas Brooks"], gameDate: new Date("2026-05-01"), pitchCount: 24, notes: "Spot start." }
|
||||
]);
|
||||
|
||||
await GameModel.create({
|
||||
gameDate: new Date("2026-05-07"),
|
||||
activePlayerIds: createdPlayers.filter((player) => player.isActive).map((player) => player._id),
|
||||
startingPitcherId: lookup["Eli Watson"],
|
||||
notes: "Sample upcoming game."
|
||||
});
|
||||
|
||||
await LineupModel.create({
|
||||
gameDate: new Date("2026-05-07"),
|
||||
playerIds: createdPlayers.filter((player) => player.isActive).map((player) => player._id),
|
||||
startingPitcherId: lookup["Eli Watson"],
|
||||
battingOrder: createdPlayers
|
||||
.filter((player) => player.isActive)
|
||||
.slice(0, 9)
|
||||
.map((player, index) => ({
|
||||
playerId: player._id,
|
||||
playerName: `${player.firstName} ${player.lastName}`,
|
||||
slot: index + 1
|
||||
})),
|
||||
defensiveAssignments: [
|
||||
["Eli Watson", "P"],
|
||||
["Liam Parker", "C"],
|
||||
["Lucas Brooks", "1B"],
|
||||
["Owen Hayes", "2B"],
|
||||
["Jack Morris", "3B"],
|
||||
["Mason Reed", "SS"],
|
||||
["Henry Cole", "LF"],
|
||||
["Noah Foster", "CF"],
|
||||
["Wyatt Price", "RF"]
|
||||
].map(([name, position]) => ({
|
||||
playerId: lookup[name],
|
||||
playerName: name,
|
||||
position
|
||||
})),
|
||||
bench: [],
|
||||
notes: ["Sample lineup generated for reference."]
|
||||
});
|
||||
|
||||
return { seeded: true };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function serialize<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type Handedness = "R/R" | "R/L" | "L/L" | "L/R" | "Switch";
|
||||
|
||||
export type Position =
|
||||
| "P"
|
||||
| "C"
|
||||
| "1B"
|
||||
| "2B"
|
||||
| "3B"
|
||||
| "SS"
|
||||
| "LF"
|
||||
| "CF"
|
||||
| "RF"
|
||||
| "UTIL";
|
||||
|
||||
export type PitcherAvailability = "available" | "needs-rest" | "unknown";
|
||||
|
||||
export interface PitchingRuleInput {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
}
|
||||
|
||||
export interface PlayerInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jerseyNumber: number;
|
||||
batsThrows: Handedness;
|
||||
primaryPosition: Position;
|
||||
secondaryPosition?: Position | null;
|
||||
tertiaryPosition?: Position | null;
|
||||
isActive: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface PlayerRecord extends PlayerInput {
|
||||
_id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
pitcherAvailability: PlayerAvailabilitySummary;
|
||||
}
|
||||
|
||||
export interface PitchingAppearanceInput {
|
||||
playerId: string;
|
||||
gameDate: string;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DefensiveAssignment {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
position: Position;
|
||||
}
|
||||
|
||||
export interface BattingSlot {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
export interface GeneratedLineup {
|
||||
gameDate: string;
|
||||
startingPitcherId?: string | null;
|
||||
battingOrder: BattingSlot[];
|
||||
defensiveAssignments: DefensiveAssignment[];
|
||||
bench: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PitchingRuleRecord extends PitchingRuleInput {
|
||||
_id?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface PitchingAppearanceRecord {
|
||||
_id: string;
|
||||
playerId: string;
|
||||
gameDate: string;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LineupRecord extends GeneratedLineup {
|
||||
_id?: string;
|
||||
playerIds: string[];
|
||||
}
|
||||
|
||||
export interface PlayerAvailabilitySummary {
|
||||
status: PitcherAvailability;
|
||||
daysSinceLastAppearance: number | null;
|
||||
requiredRestDays: number | null;
|
||||
lastPitchedDate: string | null;
|
||||
lastPitchCount: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
rosterCount: number;
|
||||
activePlayers: number;
|
||||
availablePitchers: number;
|
||||
needsRest: number;
|
||||
unknownPitchers: number;
|
||||
recentPitching: Array<{
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
status: PitcherAvailability;
|
||||
daysSinceLastAppearance: number | null;
|
||||
requiredRestDays: number | null;
|
||||
lastPitchedDate: string | null;
|
||||
pitchCount: number | null;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PitcherAvailability } from "@/lib/types";
|
||||
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function formatPlayerName(firstName: string, lastName: string) {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
|
||||
export function toDateOnly(date: Date | string) {
|
||||
const target = typeof date === "string" ? new Date(date) : date;
|
||||
return target.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function startOfDay(date: Date | string) {
|
||||
const target = typeof date === "string" ? new Date(date) : new Date(date);
|
||||
target.setUTCHours(0, 0, 0, 0);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function daysBetween(from: Date | string, to: Date | string) {
|
||||
const left = startOfDay(from).getTime();
|
||||
const right = startOfDay(to).getTime();
|
||||
return Math.floor((right - left) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function statusLabel(status: PitcherAvailability) {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return "Available";
|
||||
case "needs-rest":
|
||||
return "Needs Rest";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function sortBy<T>(items: T[], getter: (item: T) => string | number) {
|
||||
return [...items].sort((a, b) => {
|
||||
const left = getter(a);
|
||||
const right = getter(b);
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
|
||||
export const playerSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required."),
|
||||
lastName: z.string().trim().min(1, "Last name is required."),
|
||||
jerseyNumber: z.coerce.number().int().min(0).max(999),
|
||||
batsThrows: z.enum(HANDEDNESS_OPTIONS),
|
||||
primaryPosition: z.enum(POSITIONS),
|
||||
secondaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
tertiaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
isActive: z.boolean(),
|
||||
notes: z.string().max(500).optional().default("")
|
||||
});
|
||||
|
||||
export const pitchingAppearanceSchema = z.object({
|
||||
playerId: z.string().min(1),
|
||||
gameDate: z.string().min(1),
|
||||
pitchCount: z.coerce.number().int().min(1).max(200),
|
||||
notes: z.string().max(300).optional().default("")
|
||||
});
|
||||
|
||||
export const pitchingRulesSchema = z.array(
|
||||
z.object({
|
||||
minPitches: z.coerce.number().int().min(0),
|
||||
maxPitches: z.coerce.number().int().positive().nullable(),
|
||||
requiredRestDays: z.coerce.number().int().min(0).max(10)
|
||||
})
|
||||
).min(1);
|
||||
|
||||
export const generateLineupSchema = z.object({
|
||||
gameDate: z.string().min(1),
|
||||
playerIds: z.array(z.string()).min(1, "Select at least one player."),
|
||||
startingPitcherId: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const saveLineupSchema = generateLineupSchema.extend({
|
||||
battingOrder: z.array(
|
||||
z.object({
|
||||
playerId: z.string(),
|
||||
playerName: z.string(),
|
||||
slot: z.coerce.number().int().positive()
|
||||
})
|
||||
).min(1),
|
||||
defensiveAssignments: z.array(
|
||||
z.object({
|
||||
playerId: z.string(),
|
||||
playerName: z.string(),
|
||||
position: z.enum(POSITIONS)
|
||||
})
|
||||
),
|
||||
bench: z.array(z.string()),
|
||||
notes: z.array(z.string())
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface GameDocument {
|
||||
gameDate: Date;
|
||||
activePlayerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
notes?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const gameSchema = new Schema<GameDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, unique: true, index: true },
|
||||
activePlayerIds: [{ type: Schema.Types.ObjectId, ref: "Player" }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
notes: { type: String, default: "" }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const GameModel = (models.Game as Model<GameDocument>) || model<GameDocument>("Game", gameSchema);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface LineupDocument {
|
||||
gameDate: Date;
|
||||
playerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
battingOrder: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
slot: number;
|
||||
}>;
|
||||
defensiveAssignments: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
position: string;
|
||||
}>;
|
||||
bench: Types.ObjectId[];
|
||||
notes: string[];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const lineupSchema = new Schema<LineupDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, unique: true, index: true },
|
||||
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
battingOrder: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
slot: { type: Number, required: true }
|
||||
}
|
||||
],
|
||||
defensiveAssignments: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
position: { type: String, required: true }
|
||||
}
|
||||
],
|
||||
bench: [{ type: Schema.Types.ObjectId, ref: "Player" }],
|
||||
notes: [{ type: String }]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface PitchingAppearanceDocument {
|
||||
playerId: Types.ObjectId;
|
||||
gameDate: Date;
|
||||
pitchCount: number;
|
||||
notes?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const pitchingAppearanceSchema = new Schema<PitchingAppearanceDocument>(
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true, index: true },
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
pitchCount: { type: Number, required: true },
|
||||
notes: { type: String, default: "" }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const PitchingAppearanceModel =
|
||||
(models.PitchingAppearance as Model<PitchingAppearanceDocument>) ||
|
||||
model<PitchingAppearanceDocument>("PitchingAppearance", pitchingAppearanceSchema);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Model, Schema, model, models } from "mongoose";
|
||||
|
||||
export interface PitchingRuleDocument {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
requiredRestDays: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const pitchingRuleSchema = new Schema<PitchingRuleDocument>({
|
||||
minPitches: { type: Number, required: true },
|
||||
maxPitches: { type: Number, default: null },
|
||||
requiredRestDays: { type: Number, required: true },
|
||||
sortOrder: { type: Number, required: true }
|
||||
});
|
||||
|
||||
export const PitchingRuleModel =
|
||||
(models.PitchingRule as Model<PitchingRuleDocument>) || model<PitchingRuleDocument>("PitchingRule", pitchingRuleSchema);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Model, Schema, model, models } from "mongoose";
|
||||
|
||||
export interface PlayerDocument {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jerseyNumber: number;
|
||||
batsThrows: string;
|
||||
primaryPosition: string;
|
||||
secondaryPosition?: string | null;
|
||||
tertiaryPosition?: string | null;
|
||||
isActive: boolean;
|
||||
notes?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const playerSchema = new Schema<PlayerDocument>(
|
||||
{
|
||||
firstName: { type: String, required: true, trim: true },
|
||||
lastName: { type: String, required: true, trim: true },
|
||||
jerseyNumber: { type: Number, required: true },
|
||||
batsThrows: { type: String, required: true },
|
||||
primaryPosition: { type: String, required: true },
|
||||
secondaryPosition: { type: String, default: null },
|
||||
tertiaryPosition: { type: String, default: null },
|
||||
isActive: { type: Boolean, default: true },
|
||||
notes: { type: String, default: "" }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export const PlayerModel = (models.Player as Model<PlayerDocument>) || model<PlayerDocument>("Player", playerSchema);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { seedDatabase } from "@/lib/seed";
|
||||
|
||||
async function run() {
|
||||
const result = await seedDatabase();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(result);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user