Compare commits

..
11 Commits
Author SHA1 Message Date
Zeeshaun c48023ace4 Updating 2026-05-06 18:31:27 +00:00
Zeeshaun 2263dc3aa5 Updating 2026-05-06 16:37:58 +00:00
Zeeshaun 1d587599fc Updating 2026-05-06 16:27:48 +00:00
Zeeshaun 02186dcdfa Updating 2026-05-06 16:10:07 +00:00
Zeeshaun 2ed854389c Updating 2026-05-06 16:00:13 +00:00
Zeeshaun 4d098a9ec9 Updating 2026-05-06 15:51:10 +00:00
Zeeshaun 9e56dae161 Updating 2026-05-06 15:33:36 +00:00
Zeeshaun 0de593c7d3 Updating 2026-05-06 15:08:32 +00:00
Zeeshaun b05a1a954b updating 2026-05-04 15:38:57 +00:00
Zeeshaun ba3728ec34 wq
Merge branch 'main' of https://git.ranaze.com/admin/monorepo
2026-05-04 15:29:31 +00:00
Zeeshaun 7b25f249e3 Updates 2026-05-04 10:09:55 -05:00
106 changed files with 7795 additions and 283 deletions
@@ -0,0 +1,79 @@
using LCU.Api.Internal.FileOperations.Validations;
using LCU.Library.Internal.FileOperationsModels.Models;
using LCU.Library.Internal.LCUServiceFactory.Configurations;
using Newtonsoft.Json;
using System;
using System.IO;
namespace LCU.Api.Internal.FileOperations.Services.FileActionServices
{
public class WriteBinaryFileService : FileActionService, IFileActionService, IActionService<WriteFileRequest>
{
public WriteBinaryFileService(ServicesFactoryConfiguration servicesFactoryConfiguration)
: base(servicesFactoryConfiguration)
{
}
public RequestResponse ExecuteAction(QueueRequest queueRequest)
{
var requestResponse = new RequestResponse();
try
{
var fileRequest = JsonConvert.DeserializeObject<WriteFileRequest>(queueRequest.Request);
requestResponse = PerformRequest(fileRequest);
requestResponse.ReturnResponse = fileRequest;
return requestResponse;
}
catch (Exception exception)
{
return RequestResponseHelper.ExceptionUpdateRequestResponse(requestResponse,
queueRequest.FileOperationType,
exception.Message);
}
}
public RequestResponse PerformRequest(WriteFileRequest request)
{
return OperationService.PerformOperation(request, IsRequestValid, WriteBinaryFile);
}
private void WriteBinaryFile(WriteFileRequest request)
{
try
{
var fileBytes = Convert.FromBase64String(request.FileData);
File.WriteAllBytes(request.FullFilePath, fileBytes);
}
catch (Exception exception)
{
throw new Exception($"Exception thrown while trying to ExecuteRequestAction. " +
$"Request: {JsonConvert.SerializeObject(request)}" +
$"ExceptionMessage: {exception.Message}");
}
}
public RequestResponse PreQueueValidation(WriteFileRequest originalRequest)
{
var requestResponse = new RequestResponse();
requestResponse = IsRequestValid(originalRequest, requestResponse);
return requestResponse;
}
public RequestResponse IsRequestValid(WriteFileRequest originalRequest, RequestResponse requestResponse)
{
requestResponse = FileValidation.ValidateDirectoryExists(originalRequest.DestinationPath, requestResponse);
if (!originalRequest.Overwrite)
{
requestResponse = FileValidation.ValidateFileNotExists(originalRequest.FullFilePath, requestResponse);
}
return requestResponse;
}
}
}
@@ -0,0 +1,49 @@
using LCU.Api.Internal.FileOperations.Services.FileActionServices;
using LCU.Library.Internal.FileOperationsModels.Models;
using LCU.Library.Internal.LCUServiceFactory.Configurations;
using System;
using System.IO;
using Xunit;
namespace LCU.Api.Internal.FileOperationsTests.Services
{
public class WriteBinaryFileServiceTest : IDisposable
{
private readonly string _tempDirectory;
private readonly WriteBinaryFileService _writeBinaryFileService;
public WriteBinaryFileServiceTest()
{
_tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(_tempDirectory);
_writeBinaryFileService = new WriteBinaryFileService(new ServicesFactoryConfiguration());
}
[Fact]
public void PerformRequest_WritesBinaryBytes()
{
var expectedBytes = new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00 };
var request = new WriteFileRequest
{
DestinationPath = _tempDirectory,
FileName = "sample",
Extension = ".wav",
FileData = Convert.ToBase64String(expectedBytes),
Overwrite = true
};
var result = _writeBinaryFileService.PerformRequest(request);
Assert.True(result.IsSuccessful);
Assert.Equal(expectedBytes, File.ReadAllBytes(request.FullFilePath));
}
public void Dispose()
{
if (Directory.Exists(_tempDirectory))
{
Directory.Delete(_tempDirectory, true);
}
}
}
}
@@ -5,7 +5,6 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS aspnetcore-build
COPY LCU.API.Internal.MemberPreferencesApi/LCU.API.Internal.MemberPreferencesApi.csproj ./source/LCU.API.Internal.MemberPreferencesApi/
COPY LCU.API.Internal.MemberPreferencesApiLib/LCU.API.Internal.MemberPreferencesApiLib.csproj ./source/LCU.API.Internal.MemberPreferencesApiLib/
COPY LCU.Library.Internal.MemberPreferencesApiModels/LCU.Library.Internal.MemberPreferencesApiModels.csproj ./source/LCU.Library.Internal.MemberPreferencesApiModels/
COPY LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/
# Install Credential Provider and set env variables to enable Nuget restore with auth
ARG PAT=bh2lq3jibktw67b2wdbqyfln7cd3je6lk52kijntpj43v6kimpgq
@@ -20,7 +19,10 @@ RUN dotnet restore -s "https://landmarkcu.pkgs.visualstudio.com/_packaging/LCU-I
COPY . ./source/
LABEL test=true
COPY LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/
RUN dotnet restore -s "https://landmarkcu.pkgs.visualstudio.com/_packaging/LCU-Internal/nuget/v3/index.json" -s "https://api.nuget.org/v3/index.json" ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj
RUN dotnet test -c Release --results-directory /testresults --logger "trx;LogFileName=test_results.trx" ./source/LCU.API.Internal.MemberPreferencesApi.UnitTests/LCU.API.Internal.MemberPreferencesApi.UnitTests.csproj
# .NET Core Publish
@@ -18,8 +18,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
private Mock<IUnifiedOptInRepository> _mockUnifiedOptinRepository;
private Mock<IMemberPreferenceRepository> _mockMemberPreferenceRepository;
private Mock<IFileOperationsApiService> _mockFileOperationsApiService;
private IMock<IXdiBatchProcessInfoRepository> _mockXdiBatchProcessInfoRepository;
[SetUp]
public void Setup()
@@ -27,7 +25,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
_mockFileOperationsApiService = new Mock<IFileOperationsApiService>();
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
_mockBatchItemsRepository = new Mock<IXdiBatchItemsRepository>();
_mockXdiBatchProcessInfoRepository = new Mock<IXdiBatchProcessInfoRepository>();
_mockUnitOfWork = new Mock<IUnitOfWork>();
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
_mockMemberPreferenceRepository = new Mock<IMemberPreferenceRepository>();
@@ -35,7 +32,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.IntegrationTests
_xdiBatchItemService = new XdiBatchService(_mockBatchItemsRepository.Object, _mockUnitOfWork.Object,
_mockUnifiedOptinRepository.Object, _mockMemberPreferenceRepository.Object,
_mockFileOperationsApiService.Object, _mockXdiBatchProcessInfoRepository.Object);
_mockFileOperationsApiService.Object);
}
[Test]
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="LCU.Library.Internal.CoreApiWrapperModel" Version="2.27.14" />
<PackageReference Include="LCU.Library.Internal.LcuClassPatterns.CrudTests" Version="1.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.18.4" />
@@ -3,8 +3,8 @@ using LCU.API.Internal.MemberPreferencesApi.Models;
using LCU.API.Internal.MemberPreferencesApi.Models.Requests;
using LCU.API.Internal.MemberPreferencesApi.Services.XdiServices;
using LCU.API.Internal.MemberPreferencesApiLib.Repositories;
using LCU.Library.Internal.LcuClassPatterns.CrudTests.Tests;
using LCU.Library.Internal.LcuClassPatterns.CrudTests.Utilities;
using LCU.Library.Internal.ApiResponse.Models;
using LCU.Library.Internal.FileOperationsModels.Models;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
using Moq;
@@ -17,11 +17,10 @@ using System.Threading.Tasks;
namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
{
public class XdiBatchItemServiceTest : CrudServiceTest<XdiBatchProcessInfo>
public class XdiBatchItemServiceTest
{
private IXdiBatchService _xdiBatchItemService;
private Mock<IXdiBatchItemsRepository> _mockBatchItemsRepository;
private Mock<IXdiBatchProcessInfoRepository> _mockXdiBatchProcessInfoRepository;
private Mock<IUnitOfWork> _mockUnitOfWork;
private Mock<IUnifiedOptInRepository> _mockUnifiedOptinRepository;
private Mock<IMemberPreferenceRepository> _mockMemberPreferenceRepository;
@@ -31,7 +30,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
public void Setup()
{
_mockBatchItemsRepository = new Mock<IXdiBatchItemsRepository>();
_mockXdiBatchProcessInfoRepository = new Mock<IXdiBatchProcessInfoRepository>();
_mockUnitOfWork = new Mock<IUnitOfWork>();
_mockUnifiedOptinRepository = new Mock<IUnifiedOptInRepository>();
_mockMemberPreferenceRepository = new Mock<IMemberPreferenceRepository>();
@@ -39,7 +37,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
_xdiBatchItemService = new XdiBatchService(_mockBatchItemsRepository.Object, _mockUnitOfWork.Object,
_mockUnifiedOptinRepository.Object, _mockMemberPreferenceRepository.Object,
_mockFileOperationsApiService.Object, _mockXdiBatchProcessInfoRepository.Object);
_mockFileOperationsApiService.Object);
}
[Test]
@@ -305,14 +303,17 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
UniversalOptIn = DeliveryPreference.Hold
});
var xdiBatchProcessInfo = new List<XdiBatchProcessInfo>
var appendFileResponse = new RequestResponse
{
new()
IsSuccessful = true,
ReturnResponse = new ReturnResponse
{
XdiBatchProcessInfoId = (int)typeof(int).GetRandomValue(),
StartTimeStamp = DateTime.Now,
XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress,
ApiResponse = "Started"
ResponseItems = new List<ResponseItem>() {
new()
{
ResponsePayload = "File Created"
}
}
}
};
@@ -321,10 +322,9 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
XdiAppendFileRootBasePath = "test/test"
};
_mockXdiBatchProcessInfoRepository.Setup(x => x.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress)).ReturnsAsync(xdiBatchProcessInfo)
.Verifiable();
_mockFileOperationsApiService.Setup(x => x.AppendFile(It.IsAny<AppendFileRequest>())).ReturnsAsync(appendFileResponse).Verifiable();
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo.FirstOrDefault());
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath);
Assert.AreEqual(4, results.Count);
Assert.True(results.Exists(x => x.UserId == "100" && x.Value == "[email protected]"));
@@ -347,17 +347,6 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
}
};
var xdiBatchProcessInfo = new List<XdiBatchProcessInfo>
{
new()
{
XdiBatchProcessInfoId = (int)typeof(int).GetRandomValue(),
StartTimeStamp = DateTime.Now,
XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress,
ApiResponse = "Started"
}
};
_mockUnifiedOptinRepository
.Setup(x => x.GetMemberOptInSettingsNoTracking("100")).ReturnsAsync(new MemberOptInSettings
{
@@ -369,15 +358,28 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests.Services
.Setup(x => x.GetMemberOptInSettingsNoTracking(It.Is<string>(userId => int.Parse(userId) > 100)))
.Returns(Task.FromResult<MemberOptInSettings>(null));
_mockXdiBatchProcessInfoRepository.Setup(x => x.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress)).ReturnsAsync(xdiBatchProcessInfo)
.Verifiable();
var request = new XdiBatchRequest()
{
XdiAppendFileRootBasePath = "test/test"
};
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo.FirstOrDefault());
var appendFileResponse = new RequestResponse
{
IsSuccessful = true,
ReturnResponse = new ReturnResponse
{
ResponseItems = new List<ResponseItem>() {
new()
{
ResponsePayload = "File Created"
}
}
}
};
_mockFileOperationsApiService.Setup(x => x.AppendFile(It.IsAny<AppendFileRequest>())).ReturnsAsync(appendFileResponse).Verifiable();
var results = await _xdiBatchItemService.BuildXdiFiles(testFile, request.XdiAppendFileRootBasePath);
Assert.AreEqual(1, results.Count);
Assert.True(results.Exists(x => x.UserId == "100" && x.Value == DeliveryPreference.Paper));
@@ -2,7 +2,6 @@ using System;
using System.Linq;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
using LCU.API.Internal.MemberPreferencesApi.Models;
using NUnit.Framework;
namespace LCU.API.Internal.MemberPreferencesApi.UnitTests
@@ -162,57 +161,5 @@ namespace LCU.API.Internal.MemberPreferencesApi.UnitTests
Assert.AreEqual(1, files.Count);
Assert.AreEqual(XdiFileType.UpdateMemberEmail, files.First().Type);
}
[Test]
public void Test_Enrollment_Microfiche()
{
var batch = new Models.XdiBatch();
var settings = new MemberOptInSettings()
{
Id = 1000,
UniversalOptIn = DeliveryPreference.Microfiche,
ENotifications = DeliveryPreference.Electronic,
EStatements = DeliveryPreference.Electronic,
UnifiedOptInEmail = "[email protected]",
ENotificationsEmail = "[email protected]",
EStatementsEmail = "[email protected]",
UserId = "99"
};
var result = batch.ProcessItem(_item, settings);
Assert.True(result);
var files = batch.GetNonEmptyFiles();
Assert.NotNull(files);
Assert.AreEqual(1, files.Count);
Assert.AreEqual(XdiFileType.UpdateMemberToPrint, files.First().Type);
Assert.AreEqual("[email protected]", files.First().Data.First().Email);
}
[Test]
public void Test_Enrollment_Hold()
{
var batch = new Models.XdiBatch();
var settings = new MemberOptInSettings()
{
Id = 1000,
UniversalOptIn = DeliveryPreference.Hold,
ENotifications = DeliveryPreference.Electronic,
EStatements = DeliveryPreference.Electronic,
UnifiedOptInEmail = "[email protected]",
ENotificationsEmail = "[email protected]",
EStatementsEmail = "[email protected]",
UserId = "99"
};
var result = batch.ProcessItem(_item, settings);
Assert.True(result);
var files = batch.GetNonEmptyFiles();
Assert.NotNull(files);
Assert.AreEqual(1, files.Count);
Assert.AreEqual(XdiFileType.UpdateMemberToPrint, files.First().Type);
Assert.AreEqual("[email protected]", files.First().Data.First().Email);
}
}
}
@@ -21,14 +21,6 @@ public class DnaAuditProcessConsumer : IConsumer<DnaAuditProcessRequest>
await scope.ServiceProvider.GetRequiredService<IDNADataAuditProcessService>().HandleDnaAuditProcessRequest(context.Message);
Log.Debug("Ending DnaAuditProcessConsumer.");
}
catch (ServiceException serviceException)
{
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
Enum.GetName(typeof(GroupEnum), GroupEnum.ITDevelopment))
.Error(serviceException, $"DnaAuditProcess Failure. " +
$"The request is placed on DnaAuditProcess_error.");
throw;
}
catch (Exception exception)
{
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
@@ -17,15 +17,6 @@ public class XdiBatchProcessConsumer : IConsumer<XdiBatchProcessRequest>
using var scope = _scopeFactory.CreateScope();
await Task.Run(async () => await scope.ServiceProvider.GetRequiredService<IXdiBatchProcessService>().HandleXdiBatchProcess(context.Message));
}
catch (ServiceException ex)
{
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
Enum.GetName(typeof(GroupEnum), GroupEnum.ITDevelopment))
.Error($"XdiBatchProcessConsumer Failure. " +
$"The response is placed on LCU.MemberPreferencesApi.XdiBatchProcess_error. " +
$"Error Message: {ex.Message}");
throw;
}
catch (Exception exception)
{
Log.ForContext(Enum.GetName(typeof(PropertyEnum), PropertyEnum.Group),
@@ -1,8 +1,10 @@
using LCU.API.Internal.MemberPreferencesApi.Services.QueueServices;
using Microsoft.AspNetCore.Mvc;
using Microsoft.FeatureManagement.Mvc;
namespace LCU.API.Internal.MemberPreferencesApi.Controllers;
[FeatureGate(FeatureGates.EnableControllers)]
[ApiController]
[Route("api/[controller]")]
public class QueueXdiBatchProcessController : ControllerBase
@@ -13,7 +13,6 @@
<PackageReference Include="LCU.Api.Internal.MaidBotLib" Version="1.0.2" />
<PackageReference Include="LCU.Library.Internal.Exceptions" Version="2.0.0" />
<PackageReference Include="LCU.Library.Internal.FileOperationsModels" Version="4.1.5" />
<PackageReference Include="LCU.Library.Internal.LcuClassPatterns.Crud" Version="1.0.2" />
<PackageReference Include="LCU.Library.Internal.LcuHttpClient" Version="1.21.0" />
<PackageReference Include="LCU.Library.Internal.LcuMassTransit" Version="1.0.0" />
<PackageReference Include="LCU.Library.Internal.LcuMassTransitModels" Version="1.1.0" />
@@ -68,7 +68,7 @@ public class XdiBatch
}
if (currentSettings.UniversalOptIn == DeliveryPreference.Microfiche &&
currentSettings.ENotifications == DeliveryPreference.Electronic)
currentSettings.ENotifications == DeliveryPreference.Electronic)
{
_updateMemberToPrint.AddRow(item.UserId, currentSettings.UnifiedOptInEmail);
return true;
@@ -245,7 +245,6 @@ void AddMemberPreferencesLib(ref IServiceCollection services)
services.AddScoped<IMemberPreferenceRepository, MemberPreferenceRepository>();
services.AddScoped<IMemberPreferences, MemberPreferenceRepository>();
services.AddScoped<IXdiBatchItemsRepository, XdiBatchItemsRepository>();
services.AddScoped<IXdiBatchProcessInfoRepository, XdiBatchProcessInfoRepository>();
services.AddScoped<IDnaAuditProcessRepository, DnaAuditProcessRepository>();
}
@@ -50,7 +50,6 @@ public class DNADataAuditProcessService : IDNADataAuditProcessService
Log.Error(ex.Message);
// Return successful as True so Dispatcher job never stops running.
await QueueReturnResponse.QueueResponse(request, true, new List<string>() { ex.Message });
throw new ServiceException($"Failed to handled Consumed DnaAuditProcessRequest. ExceptionMessage: {ex.Message}");
}
}
@@ -18,7 +18,7 @@ namespace LCU.API.Internal.MemberPreferencesApi.MicroApiServices
{
var result = await _fileOperationsApiDao.AppendFileAsync(request);
Log.Information($"XDI File Sent, Response: {result.ReturnResponse}");
Log.Information($"XDI File Saved. Response: {result.ReturnResponse}");
return result;
}
catch (Exception ex)
@@ -1,5 +1,6 @@
using LCU.API.Internal.MemberPreferencesApi.Models;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Responses;
namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices;
@@ -7,9 +8,9 @@ public interface IXdiBatchService
{
Task AddXdiBatchItem(XdiBatchItem newItem);
Task SaveRangeAsync(List<XdiBatchItem> xdiBatchItemList);
Task CreateXdiBatchFiles(XdiBatchRequest request);
Task<RequestResponse> CreateXdiBatchFiles(XdiBatchRequest request);
Task<XdiBatchRequest> BuildXdiBatchRequest(XdiBatchRequest request);
Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath, XdiBatchProcessInfo xdiBatchProcessInfo);
Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath);
Task<bool> MarkPendingSuccessful(List<XdiBatchFileExport> batchFileExportList);
Task<bool> DoesMemberHavePendingXdiChange(string memberId);
}
@@ -36,8 +36,8 @@ public class XdiBatchProcessService : IXdiBatchProcessService
XdiAppendFileRootBasePath = xdiBatchProcessRequest.XdiAppendFileRootBasePath
};
await _xdiBatchService.CreateXdiBatchFiles(request);
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, true, new List<string> { $"Requested {xdiBatchItems.Count()} Xdi Batch Items to Process."});
var result = await _xdiBatchService.CreateXdiBatchFiles(request);
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, result.IsSuccessful, result.MessageStack);
}
}
catch (Exception ex)
@@ -45,7 +45,6 @@ public class XdiBatchProcessService : IXdiBatchProcessService
var errorMessage = $"Failed to HandleXdiBatchProcess. Exception Message: {ex.Message}";
Log.Error(errorMessage);
await QueueReturnResponse.QueueResponse(xdiBatchProcessRequest, false, new List<string>() {errorMessage});
throw new ServiceException(errorMessage);
}
}
}
@@ -1,17 +1,15 @@
using LCU.API.Internal.MemberPreferencesApi.MicroApiServices;
using LCU.API.Internal.MemberPreferencesApi.Models;
using LCU.API.Internal.MemberPreferencesApiLib.Repositories;
using LCU.Library.Internal.FileOperationsModels.Models;
using LCU.Library.Internal.LcuClassPatterns.Crud.Services;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
using System.Data.SqlClient;
using LCU.Library.Internal.MemberPreferencesApiModels.Responses;
using FileOperationsModels = LCU.Library.Internal.FileOperationsModels.Models;
namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
{
public class XdiBatchService : CrudService<XdiBatchProcessInfo>, IXdiBatchService
public class XdiBatchService : IXdiBatchService
{
private readonly IXdiBatchProcessInfoRepository _xdiBatchProcessInfoRepository;
private readonly IXdiBatchItemsRepository _xdiBatchItemsRepository;
private readonly IUnifiedOptInRepository _unifiedOptInRepository;
private readonly IMemberPreferenceRepository _memberPreferenceRepository;
@@ -21,44 +19,37 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
public XdiBatchService(IXdiBatchItemsRepository xdiBatchItemsRepository,
IUnitOfWork unitOfWork, IUnifiedOptInRepository unifiedOptInRepository,
IMemberPreferenceRepository memberPreferencesRepository, IFileOperationsApiService fileOperationsApiService,
IXdiBatchProcessInfoRepository xdiBatchProcessInfoRepository) : base(xdiBatchProcessInfoRepository)
IMemberPreferenceRepository memberPreferencesRepository, IFileOperationsApiService fileOperationsApiService)
{
_xdiBatchItemsRepository = xdiBatchItemsRepository;
_unitOfWork = unitOfWork;
_memberPreferenceRepository = memberPreferencesRepository;
_unifiedOptInRepository = unifiedOptInRepository;
_fileOperationsApiService = fileOperationsApiService;
_xdiBatchProcessInfoRepository = xdiBatchProcessInfoRepository;
}
public async Task CreateXdiBatchFiles(XdiBatchRequest request)
public async Task<RequestResponse> CreateXdiBatchFiles(XdiBatchRequest request)
{
var batchProcessInfo = new XdiBatchProcessInfo();
var batchUserIds = string.Join(",", request.XdiBatchItems.Select(c => c.UserId).ToList());
var requestResponse = new RequestResponse();
try
{
var batchProcessInProgress = _xdiBatchProcessInfoRepository.FindBatchProcessInfoByStatus(XdiBatchProcessStatus.InProgress).Result;
if (batchProcessInProgress.Count() == 0)
{
request = await BuildXdiBatchRequest(request);
batchProcessInfo = await CreateNewBatchProcessInfo(request.XdiBatchItems.Count());
await CreateXdiExportFileRequest(request, batchProcessInfo);
await UpdateBatchAsync(batchProcessInfo, XdiBatchProcessStatus.Completed, $"Completed XDI Batch Process for UserIds: {batchUserIds}");
}
request = await BuildXdiBatchRequest(request);
requestResponse = await CreateXdiExportFileRequest(request);
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to Create XDI Batch Files.");
await UpdateBatchAsync(batchProcessInfo, XdiBatchProcessStatus.Failed, $"Failed XDI Batch Process for UserIds: {batchUserIds}");
throw new ServiceException($"Failed to Create XDI Batch Files. Exception Message: {ex.Message}");
var batchUserIds = string.Join(",", request.XdiBatchItems.Select(c => c.UserId).ToList());
var errorMessage = $"Failed to Create XDI Batch Files for UserId(s): {batchUserIds} Error: { ex.Message}";
Log.Error(errorMessage);
requestResponse.MessageStack.Add(errorMessage);
requestResponse.IsSuccessful = false;
}
return requestResponse;
}
public async Task<XdiBatchRequest> BuildXdiBatchRequest(XdiBatchRequest request)
{
foreach (var item in request.XdiBatchItems)
foreach (var item in request.XdiBatchItems)
{
var currentSettings = await _unifiedOptInRepository.GetMemberOptInSettings(item.UserId);
if (currentSettings != null)
@@ -81,45 +72,62 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
Log.Error($"No XdiBatchRow or XDI File was processed based on current member UserId: {item.UserId} Opt In Settings, marking complete.");
_xdiBatchItemsRepository.Delete(item);
}
};
};
}
;
}
;
await _unitOfWork.CompleteAsync(XdiBatchProcessOperatorName, DateTime.UtcNow, UserType.System);
return request;
}
public async Task CreateXdiExportFileRequest(XdiBatchRequest request, XdiBatchProcessInfo xdiBatchProcessInfo)
public async Task<RequestResponse> CreateXdiExportFileRequest(XdiBatchRequest request)
{
var requestResponse = new RequestResponse();
try
{
IReadOnlyCollection<XdiBatchFile> batchFiles = request.XdiBatch.GetNonEmptyFiles();
Log.Information($"XdiBatchService produced {batchFiles.Count} files");
var batchFilesMessage = ($"XdiBatchService produced {batchFiles.Count} file(s)");
Log.Information(batchFilesMessage);
foreach (var xdiBatchFile in batchFiles)
{
var xdiBatchFileExport = await BuildXdiFiles(xdiBatchFile, request.XdiAppendFileRootBasePath, xdiBatchProcessInfo);
await MarkPendingSuccessful(xdiBatchFileExport);
var xdiBatchFileExport = await BuildXdiFiles(xdiBatchFile, request.XdiAppendFileRootBasePath);
if (xdiBatchFileExport != null)
{
await MarkPendingSuccessful(xdiBatchFileExport);
}
}
}
requestResponse.IsSuccessful = true;
requestResponse.MessageStack.Add(batchFilesMessage);
}
catch (Exception ex)
{
Log.Error(ex, $"Failed to Create the XDI File to export.");
var errorMessage = $"Failed to Create the XDI File to export. {ex.Message}";
Log.Error(errorMessage);
requestResponse.MessageStack.Add(errorMessage);
requestResponse.IsSuccessful = false;
}
return requestResponse;
}
public async Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath, XdiBatchProcessInfo xdiBatchProcessInfo)
public async Task<List<XdiBatchFileExport>> BuildXdiFiles(XdiBatchFile batchFile, string filePath)
{
AppendFileRequest request = BuildFileOperationsRequest(batchFile, filePath);
FileOperationsModels.AppendFileRequest request = BuildFileOperationsRequest(batchFile, filePath);
var xdiBatchFileExport = await CreateDirectoryXdiFiles(batchFile);
var result = await _fileOperationsApiService.AppendFile(request);
if (!result.IsSuccessful)
{
throw new Exception($"Failed to Append File: {batchFile.GetFileName}");
}
return xdiBatchFileExport;
}
private AppendFileRequest BuildFileOperationsRequest(XdiBatchFile xdiBatchFile, string filePath)
private FileOperationsModels.AppendFileRequest BuildFileOperationsRequest(XdiBatchFile xdiBatchFile, string filePath)
{
var request = new AppendFileRequest()
var request = new FileOperationsModels.AppendFileRequest()
{
DestinationPath = filePath,
Extension = ".txt",
@@ -256,45 +264,5 @@ namespace LCU.API.Internal.MemberPreferencesApi.Services.XdiServices
}
}
}
public async Task<XdiBatchProcessInfo> CreateNewBatchProcessInfo(int batchTotal)
{
var xdiBatchProcessInfo = new XdiBatchProcessInfo();
xdiBatchProcessInfo.StartTimeStamp = DateTime.Now;
xdiBatchProcessInfo.XdiBatchProcessStatus = XdiBatchProcessStatus.InProgress;
xdiBatchProcessInfo.BatchTotal = batchTotal;
return await _xdiBatchProcessInfoRepository.AddAsync(xdiBatchProcessInfo);
}
public async Task<XdiBatchProcessInfo> UpdateBatchAsync(XdiBatchProcessInfo xdiBatchProcessInfo, XdiBatchProcessStatus xdiBatchStatus, string apiResponse)
{
try
{
var existing = await _xdiBatchProcessInfoRepository.FindByIdAsync(xdiBatchProcessInfo.XdiBatchProcessInfoId);
if (existing == null)
{
throw new ServiceException($"XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId} not found");
}
existing.XdiBatchProcessStatus = xdiBatchStatus;
existing.ApiResponse = apiResponse;
existing.EndTimeStamp = DateTime.Now;
await _xdiBatchProcessInfoRepository.UpdateAsync(existing);
await _unitOfWork.CompleteAsync();
return existing;
}
catch (SqlException sqlEx)
{
var message = $"Failure updating XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId}";
Log.Error(sqlEx, message);
throw new ServiceException(message);
}
catch (Exception ex)
{
var message = $"Failure updating XdiBatchProcessInfo with XdiBatchProcessInfoId {xdiBatchProcessInfo.XdiBatchProcessInfoId}";
Log.Error(ex, message);
throw new ServiceException(message);
}
}
}
}
@@ -42,7 +42,7 @@
"AppendFile": "DirectFileOperations/AppendFile"
},
"CoreApiWrapperConfiguration": {
"CoreApiBaseUrl": "https://qa-core-api-wrapper-api.apps.azure.lcu-internal.com/api/core/",
"CoreApiBaseUrl": "https://localhost:44355/api/core/",
"CoreApiGetAgreementByUserId": "Agree/AgreementByUserId/{userId}",
"CoreApiGetAgreementByAgreementId": "Agree/AgreementByAgreementId/{agreementId}",
"CoreApiGetAgreementsByAccountId": "Agree/AgreementsByAccountId/{accountId}",
@@ -18,7 +18,6 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<MemberOptInSettings> MemberOptInSettings { get; set; }
public DbSet<DnaAuditProcess> DnaAuditProcesses { get; set; }
public DbSet<XdiBatchItem> XdiBatchItems { get; set; }
public DbSet<XdiBatchProcessInfo> XdiBatchProcessInfos { get; set; }
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
{
@@ -104,8 +103,5 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
builder.Entity<DnaAuditProcess>().ToTable("DnaAuditProcess");
builder.Entity<DnaAuditProcess>().HasKey(p => p.ProcessId);
builder.Entity<XdiBatchProcessInfo>().ToTable("XdiBatchProcessInfo");
builder.Entity<XdiBatchProcessInfo>().HasKey(p => p.XdiBatchProcessInfoId);
}
}
@@ -11,7 +11,6 @@ public interface IApplicationDbContext : IAuditableChanges
DbSet<MemberOptInSettings> MemberOptInSettings { get; set; }
public DbSet<XdiBatchItem> XdiBatchItems { get; set; }
public DbSet<DnaAuditProcess> DnaAuditProcesses { get; set; }
public DbSet<XdiBatchProcessInfo> XdiBatchProcessInfos { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken());
Task<int> SaveChangesWithOperatorAsync(string operatorId, DateTime dateAdded, UserType userType, CancellationToken cancellationToken);
}
@@ -1,10 +0,0 @@
using LCU.Library.Internal.LcuClassPatterns.Crud.Repositories;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
namespace LCU.API.Internal.MemberPreferencesApiLib.Repositories;
public interface IXdiBatchProcessInfoRepository: ICrudRepository<XdiBatchProcessInfo>
{
Task<IEnumerable<XdiBatchProcessInfo>> FindBatchProcessInfoByStatus(XdiBatchProcessStatus xdiProcessStatus);
}
@@ -1,21 +0,0 @@
using LCU.API.Internal.MemberPreferencesApiLib.Persistence;
using LCU.Library.Internal.LcuClassPatterns.Crud.Repositories;
using LCU.Library.Internal.MemberPreferencesApiModels.Entities;
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
using Microsoft.EntityFrameworkCore;
namespace LCU.API.Internal.MemberPreferencesApiLib.Repositories;
public class XdiBatchProcessInfoRepository : CrudRepository<XdiBatchProcessInfo>, IXdiBatchProcessInfoRepository
{
private readonly IApplicationDbContext _context;
public XdiBatchProcessInfoRepository(ApplicationDbContext context) : base(context)
{
_context = context;
}
public async Task<IEnumerable<XdiBatchProcessInfo>> FindBatchProcessInfoByStatus(XdiBatchProcessStatus xdiProcessStatus)
{
return await _context.XdiBatchProcessInfos.Where(p => p.XdiBatchProcessStatus == xdiProcessStatus).ToListAsync();
}
}
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Operations Version="1.0" xmlns="http://schemas.microsoft.com/sqlserver/dac/Serialization/2012/02">
<Operation Name="Rename Refactor" Key="5c3556a6-05fd-442f-a0af-06c6e69d6a9d" ChangeDateTime="03/24/2026 00:12:28">
<Property Name="ElementName" Value="[dbo].[XdiBatchProcessInfo].[Id]" />
<Property Name="ElementType" Value="SqlSimpleColumn" />
<Property Name="ParentElementName" Value="[dbo].[XdiBatchProcessInfo]" />
<Property Name="ParentElementType" Value="SqlTable" />
<Property Name="NewName" Value="XdiBatchProcessInfoId" />
</Operation>
</Operations>
@@ -63,9 +63,5 @@
<Build Include="MemberOptInSettings.sql" />
<Build Include="MemberPreference.sql" />
<Build Include="XdiBatchItems.sql" />
<Build Include="XdiBatchProcessInfo.sql" />
</ItemGroup>
<ItemGroup>
<RefactorLog Include="LCU.API.Internal.MemberPreferencesApiSql.refactorlog" />
</ItemGroup>
</Project>
@@ -1,9 +0,0 @@
CREATE TABLE [dbo].[XdiBatchProcessInfo]
(
[XdiBatchProcessInfoId] [int] NOT NULL PRIMARY KEY IDENTITY,
[StartTimeStamp] DATETIME2 NOT NULL,
[EndTimeStamp] DATETIME2 NULL,
[BatchTotal] INT NOT NULL DEFAULT 0,
[ApiResponse] NVARCHAR(MAX) NULL,
[XdiBatchProcessStatus] INT NOT NULL
)
@@ -1,17 +0,0 @@
using LCU.Library.Internal.MemberPreferencesApiModels.Enums;
using System;
using System.ComponentModel.DataAnnotations;
namespace LCU.Library.Internal.MemberPreferencesApiModels.Entities
{
public class XdiBatchProcessInfo
{
[Key]
public int XdiBatchProcessInfoId { get; set; }
public DateTime StartTimeStamp { get; set; }
public DateTime EndTimeStamp { get; set; }
public XdiBatchProcessStatus XdiBatchProcessStatus { get; set; }
public int BatchTotal { get; set; }
public string ApiResponse { get; set; }
}
}
@@ -1,9 +0,0 @@
namespace LCU.Library.Internal.MemberPreferencesApiModels.Enums
{
public enum XdiBatchProcessStatus
{
InProgress,
Failed,
Completed
}
}
@@ -6,7 +6,7 @@
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: member-preferences-api-csi-key-vault-secrets-provider
secretProviderClass: normal-member-preferences-api-csi-key-vault-secrets-provider
- op: add
path: /spec/template/spec/containers/0/volumeMounts/-
@@ -1,9 +1,7 @@
{
"Primary": {
"FeatureFlags": {
"RunHostedServices": true,
"RunXdiBatchProcessConsumer": true,
"RunXdiSshResponseConsumer": true,
"RunXdiBatchProcessConsumer": false,
"RunDnaAuditProcessConsumer": false
}
}
@@ -0,0 +1,3 @@
- op: replace
path: /spec/replicas
value: 3
@@ -0,0 +1,3 @@
- op: replace
path: /spec/replicas
value: 3
@@ -0,0 +1,3 @@
- op: replace
path: /spec/replicas
value: 3
+4
View File
@@ -1,6 +1,10 @@
# This import was successfully run on 4/17/25
# Commenting it out so that the scheduled pipelines can run
import {
to = azuredevops_git_repository.repositories["LCU.WebApps.Collections.RepoClient"]
id = "2aeafa30-011a-4751-8b26-ebf29243ae67/LCU.WebApps.Collections.RepoClient"
}
#import {
# to = azuredevops_git_repository.repositories["LCU.API.Internal.MaidBotApi"]
# id = "2aeafa30-011a-4751-8b26-ebf29243ae67/LCU.API.Internal.MaidBotApi"
@@ -128,6 +128,8 @@ aks_workloads:
- path: \AKS\maidbot-api
repo: LCU.API.Internal.MaidBotApi
environment: DB
- path: \AKS\repo-manager
repo: LCU.WebApps.Collections.RepoClient
environments:
- DB
@@ -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) {
@@ -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
View File
+1
View File
@@ -0,0 +1 @@
MONGODB_URI=mongodb://127.0.0.1:27017/little-league-roster
+5
View File
@@ -0,0 +1,5 @@
.next
node_modules
.env
dist
coverage
+82
View File
@@ -0,0 +1,82 @@
# Coach Connor's Lineup Lab
Mobile-friendly baseball coaching app for roster management, scorekeeping, pitcher rest tracking, configurable rest rules, and game-day lineup planning.
## 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
- 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`.
## Scripts
- `npm run dev` starts the development server
- `npm run build` builds the app
- `npm run start` runs the production build
## 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.
+6
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+3363
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "coach-connors-lineup-lab",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"mongoose": "^8.14.3",
"next": "^15.3.2",
"postcss": "^8.5.14",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.17",
"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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+14
View File
@@ -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 });
}
+68
View File
@@ -0,0 +1,68 @@
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
);
let availablePitchers = 0;
let needsRest = 0;
let unknownPitchers = 0;
const recentPitching = players.map((player) => {
const summary = availabilityMap[String(player._id)];
if (summary.status === "available") availablePitchers += 1;
if (summary.status === "needs-rest") needsRest += 1;
if (summary.status === "unknown") 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
};
});
const response: DashboardResponse = {
rosterCount: players.length,
activePlayers: players.filter((player) => player.isActive).length,
availablePitchers,
needsRest,
unknownPitchers,
recentPitching
};
return NextResponse.json(serialize(response));
} catch (error) {
return handleApiError(error);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { handleApiError } from "@/app/api/_utils";
import { connectToDatabase } from "@/lib/db";
import { GameModel } from "@/models/Game";
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
import { PlayerModel } from "@/models/Player";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const statFields = [
"atBats",
"runs",
"hits",
"doubles",
"triples",
"homeRuns",
"runsBattedIn",
"walks",
"strikeouts",
"stolenBases",
"inningsPitchedOuts",
"earnedRunsAllowed",
"hitsAllowed",
"walksAllowed",
"strikeoutsPitched"
] as const;
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await connectToDatabase();
const { id } = await params;
const game = await GameModel.findByIdAndDelete(id).lean();
if (!game) {
return NextResponse.json({ error: "Game not found." }, { status: 404 });
}
await PlayerModel.bulkWrite(
game.playerStats.map((playerStat) => {
const decrement: Record<string, number> = { "stats.gamesPlayed": -1 };
for (const field of statFields) {
decrement[`stats.${field}`] = -(playerStat[field] ?? 0);
}
return {
updateOne: {
filter: { _id: playerStat.playerId },
update: { $inc: decrement }
}
};
})
);
await PitchingAppearanceModel.deleteMany({ gameId: game._id });
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from "next/server";
import { handleApiError } from "@/app/api/_utils";
import { connectToDatabase } from "@/lib/db";
import { serialize } from "@/lib/serialize";
import { saveGameSchema } from "@/lib/validators";
import { GameModel } from "@/models/Game";
import { LineupModel } from "@/models/Lineup";
import { PitchingAppearanceModel } from "@/models/PitchingAppearance";
import { PlayerModel } from "@/models/Player";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const statFields = [
"atBats",
"runs",
"hits",
"doubles",
"triples",
"homeRuns",
"runsBattedIn",
"walks",
"strikeouts",
"stolenBases",
"inningsPitchedOuts",
"earnedRunsAllowed",
"hitsAllowed",
"walksAllowed",
"strikeoutsPitched"
] as const;
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 games = await GameModel.find(filter).sort({ gameDate: -1, gameNumber: -1 }).lean();
return NextResponse.json(serialize(games));
} catch (error) {
return handleApiError(error);
}
}
export async function POST(request: Request) {
try {
await connectToDatabase();
const payload = saveGameSchema.parse(await request.json());
const lineup = await LineupModel.findById(payload.lineupId).lean();
if (!lineup) {
return NextResponse.json({ error: "Selected lineup not found." }, { status: 404 });
}
const gameDate = new Date(payload.gameDate);
const latestForDate = await GameModel.findOne({ gameDate }).sort({ gameNumber: -1 }).select("gameNumber").lean();
const gameNumber = (latestForDate?.gameNumber ?? 0) + 1;
const game = await GameModel.create({
gameDate,
gameNumber,
activePlayerIds: lineup.playerIds,
startingPitcherId: lineup.startingPitcherId ?? null,
lineupId: lineup._id,
lineupNumber: lineup.lineupNumber ?? null,
opponent: payload.opponent ?? "",
teamScore: payload.teamScore ?? null,
opponentScore: payload.opponentScore ?? null,
notes: payload.notes ?? "",
playerStats: payload.playerStats
});
await PlayerModel.bulkWrite(
payload.playerStats.map((playerStat) => {
const increment: Record<string, number> = { "stats.gamesPlayed": 1 };
for (const field of statFields) {
increment[`stats.${field}`] = playerStat[field] ?? 0;
}
return {
updateOne: {
filter: { _id: playerStat.playerId },
update: { $inc: increment }
}
};
})
);
const pitchingEntries = payload.playerStats.filter((playerStat) => (playerStat.pitchCount ?? 0) > 0);
if (pitchingEntries.length > 0) {
await PitchingAppearanceModel.insertMany(
pitchingEntries.map((playerStat) => ({
playerId: playerStat.playerId,
gameId: game._id,
gameDate,
pitchCount: playerStat.pitchCount,
notes: payload.opponent ? `Scored game vs ${payload.opponent}` : "Scored game"
}))
);
}
return NextResponse.json(serialize(game.toObject()), { status: 201 });
} catch (error) {
return handleApiError(error);
}
}
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { handleApiError } from "@/app/api/_utils";
import { connectToDatabase } from "@/lib/db";
import { LineupModel } from "@/models/Lineup";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await connectToDatabase();
const { id } = await params;
const lineup = await LineupModel.findByIdAndDelete(id).lean();
if (!lineup) {
return NextResponse.json({ error: "Lineup not found." }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error);
}
}
@@ -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);
}
}
+59
View File
@@ -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, lineupNumber: -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 gameDate = new Date(payload.gameDate);
const latestForDate = await LineupModel.findOne({ gameDate }).sort({ lineupNumber: -1 }).select("lineupNumber").lean();
const lineupNumber = (latestForDate?.lineupNumber ?? 0) + 1;
const lineupPayload = {
gameDate,
lineupNumber,
playerIds: payload.playerIds,
startingPitcherId: payload.startingPitcherId ?? null,
battingOrder: payload.battingOrder,
defensiveAssignments: payload.defensiveAssignments,
bench: payload.bench,
notes: payload.notes
};
const lineup = await LineupModel.create(lineupPayload);
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.toObject()), { status: 201 });
} catch (error) {
return handleApiError(error);
}
}
@@ -0,0 +1,46 @@
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 PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await connectToDatabase();
const { id } = await params;
const payload = pitchingAppearanceSchema.parse(await request.json());
const appearance = await PitchingAppearanceModel.findByIdAndUpdate(
id,
{ ...payload, gameDate: new Date(payload.gameDate) },
{ new: true, runValidators: true }
).lean();
if (!appearance) {
return NextResponse.json({ error: "Pitching appearance not found." }, { status: 404 });
}
return NextResponse.json(serialize(appearance));
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await connectToDatabase();
const { id } = await params;
const appearance = await PitchingAppearanceModel.findByIdAndDelete(id).lean();
if (!appearance) {
return NextResponse.json({ error: "Pitching appearance not found." }, { status: 404 });
}
return NextResponse.json({ success: true });
} 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 { 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,
gameId: null,
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);
}
}
+78
View File
@@ -0,0 +1,78 @@
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
);
const latestAppearanceMap = Object.fromEntries(
players.map((player) => {
const latestAppearance = appearances
.filter((appearance) => String(appearance.playerId) === String(player._id))
.sort((a, b) => new Date(b.gameDate).getTime() - new Date(a.gameDate).getTime())[0];
return [
String(player._id),
latestAppearance
? {
appearanceId: String(latestAppearance._id),
gameDate: toDateOnly(latestAppearance.gameDate),
pitchCount: latestAppearance.pitchCount,
notes: latestAppearance.notes ?? ""
}
: null
];
})
);
return NextResponse.json(
serialize(
players.map((player) => ({
...player,
_id: String(player._id),
pitcherAvailability: availabilityMap[String(player._id)],
latestPitching: latestAppearanceMap[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);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { DashboardClient } from "@/components/DashboardClient";
export default function DashboardPage() {
return <DashboardClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import { GameHistoryClient } from "@/components/GameHistoryClient";
export default function GameHistoryPage() {
return <GameHistoryClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import { GameScoringClient } from "@/components/GameScoringClient";
export default function GamesPage() {
return <GameScoringClient />;
}
+62
View File
@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 1 0 0;
--foreground: 0.145 0 0;
--card: 1 0 0;
--card-foreground: 0.145 0 0;
--popover: 1 0 0;
--popover-foreground: 0.145 0 0;
--primary: 0.205 0 0;
--primary-foreground: 0.985 0 0;
--secondary: 0.97 0 0;
--secondary-foreground: 0.205 0 0;
--muted: 0.97 0 0;
--muted-foreground: 0.556 0 0;
--accent: 0.97 0 0;
--accent-foreground: 0.205 0 0;
--destructive: 0.577 0.245 27.325;
--destructive-foreground: 0.985 0 0;
--border: 0.922 0 0;
--input: 0.922 0 0;
--ring: 0.708 0 0;
}
:root {
color-scheme: light;
}
* {
@apply border-border;
}
html {
font-size: 16px;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(circle at top, rgba(0, 0, 0, 0.035), transparent 32%);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.app-surface {
@apply rounded-3xl border bg-card shadow-panel;
}
.field-label {
@apply mb-2 block text-sm font-medium text-muted-foreground;
}
.field-select {
@apply flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Coach Connor's Lineup Lab",
description: "Roster, scorekeeping, 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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { LineupClient } from "@/components/LineupClient";
export default function LineupPage() {
return <LineupClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/dashboard");
}
+5
View File
@@ -0,0 +1,5 @@
import { RosterClient } from "@/components/RosterClient";
export default function RosterPage() {
return <RosterClient />;
}
+5
View File
@@ -0,0 +1,5 @@
import { SettingsClient } from "@/components/SettingsClient";
export default function SettingsPage() {
return <SettingsClient />;
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { BarChart3, ClipboardList, FileClock, FileText, ShieldCheck, Trophy, Users } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
{ href: "/roster", label: "Roster", icon: Users },
{ href: "/games", label: "Score Game", icon: FileText },
{ href: "/games/history", label: "Past Games", icon: FileClock },
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
];
export function AppShell({
title,
subtitle,
children,
actions
}: {
title: string;
subtitle: string;
children: React.ReactNode;
actions?: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div className="container py-3 md:py-6">
<header className="relative overflow-hidden rounded-[1.75rem] border bg-primary p-4 text-primary-foreground shadow-glow md:p-7">
<div className="pointer-events-none absolute inset-0 bg-hero-grid bg-[size:38px_38px] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-white/10 to-transparent" />
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/80">
<Trophy className="h-4 w-4" />
Coach Connor's Lineup Lab
</div>
<h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
<p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
</div>
{actions ? <div className="relative flex items-start">{actions}</div> : null}
</div>
</header>
<nav className="app-surface mt-3 flex flex-wrap gap-1.5 p-1.5">
{navItems.map((item) => {
const Icon = item.icon;
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium transition md:text-sm",
active
? "bg-primary text-primary-foreground shadow-panel"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
<main className="mt-4 grid gap-4">{children}</main>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
type ConfirmDialogProps = {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
};
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
onConfirm,
onCancel
}: ConfirmDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={(nextOpen) => (!nextOpen ? onCancel() : undefined)}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-sm" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-[1.75rem] border border-white/60 bg-card p-6 shadow-glow focus:outline-none">
<div className="flex items-start justify-between gap-4">
<div>
<Dialog.Title className="font-display text-2xl font-semibold">{title}</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">{description}</Dialog.Description>
</div>
<Dialog.Close asChild>
<button className="rounded-full p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close">
<X className="h-4 w-4" />
</button>
</Dialog.Close>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="secondary" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+115
View File
@@ -0,0 +1,115 @@
"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 { Alert } from "@/components/ui/alert";
import { Card, CardContent } from "@/components/ui/card";
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 visiblePitching = data?.recentPitching.filter((entry) => entry.status !== "unknown") ?? [];
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);
}
}
useEffect(() => {
void loadDashboard();
}, []);
return (
<AppShell
title="Game Day Snapshot"
subtitle="Check roster health, pitcher rest, and lineup readiness before first pitch."
>
{error ? <Alert tone="error">{error}</Alert> : null}
{loading ? (
<SectionCard title="Loading" description="Pulling current roster and pitching data." />
) : data && data.rosterCount > 0 ? (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[
{ label: "Rostered Players", value: data.rosterCount },
{ label: "Active Today", value: data.activePlayers },
{ label: "Pitchers Available", value: data.availablePitchers, color: "text-emerald-700" },
{ label: "Need Rest", value: data.needsRest, color: "text-amber-700" }
].map((item) => (
<Card key={item.label} className="overflow-hidden">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{item.label}</p>
<p className={`mt-2 font-display text-5xl leading-none ${item.color ?? "text-foreground"}`}>
{item.value}
</p>
</CardContent>
</Card>
))}
</div>
<SectionCard
title="Pitcher Availability"
description="Availability is based on the latest appearance and your configured rest rules."
>
{visiblePitching.length > 0 ? (
<div className="divide-y divide-border">
{visiblePitching.map((entry) => (
<div
key={entry.playerId}
className="flex flex-col gap-3 py-4 md:flex-row md:items-start md:justify-between"
>
<div>
<h3 className="text-lg font-semibold">{entry.playerName}</h3>
<p className="text-sm text-muted-foreground">
{entry.lastPitchedDate
? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}`
: "No pitching history recorded yet."}
</p>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
<StatusBadge status={entry.status} />
<p className="text-sm text-muted-foreground">
{entry.daysSinceLastAppearance === null
? "No history"
: `${entry.daysSinceLastAppearance} day(s) since last outing`}
</p>
</div>
</div>
))}
</div>
) : (
<EmptyState
title="No eligible pitchers to show"
description="Pitchers with unknown availability are hidden from this dashboard view."
/>
)}
</SectionCard>
</>
) : (
<SectionCard title="No Team Yet" description="Add players on the roster screen to start tracking availability.">
<EmptyState
title="Roster is empty"
description="Add your team on the roster screen, then return here to review availability and game-day readiness."
/>
</SectionCard>
)}
</AppShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
export function EmptyState({
title,
description,
action
}: {
title: string;
description: string;
action?: React.ReactNode;
}) {
return (
<div className="rounded-[1.75rem] border border-dashed border-border bg-muted/50 px-6 py-10 text-center">
<h3 className="font-display text-2xl font-semibold">{title}</h3>
<p className="mx-auto mt-2 max-w-xl text-sm text-muted-foreground">{description}</p>
{action}
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import { ChevronDown, FileText, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { getBattingSummary, getPitchingSummary, hasBattingStats, hasPitchingStats } from "@/lib/baseball-stats";
import { fetchJson } from "@/lib/client";
import type { GameRecord } from "@/lib/types";
const battingFields = [
["atBats", "AB"],
["runs", "R"],
["hits", "H"],
["doubles", "2B"],
["triples", "3B"],
["homeRuns", "HR"],
["runsBattedIn", "RBI"],
["walks", "BB"],
["strikeouts", "SO"],
["stolenBases", "SB"]
] as const;
const pitchingFields = [
["pitchCount", "Pitches"],
["inningsPitchedOuts", "IP Outs"],
["earnedRunsAllowed", "ER"],
["hitsAllowed", "H Allowed"],
["walksAllowed", "BB Allowed"],
["strikeoutsPitched", "Pitching K"]
] as const;
export function GameHistoryClient() {
const [gameDate, setGameDate] = useState("");
const [games, setGames] = useState<GameRecord[]>([]);
const [gamePendingDelete, setGamePendingDelete] = useState<GameRecord | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadGames(targetDate?: string) {
try {
const suffix = targetDate ? `?gameDate=${targetDate}` : "";
const response = await fetchJson<GameRecord[]>(`/api/games${suffix}`);
setGames(response);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load past games.");
}
}
useEffect(() => {
void loadGames();
}, []);
async function deleteGame(game: GameRecord) {
if (!game._id) return;
try {
await fetchJson(`/api/games/${game._id}`, { method: "DELETE" });
setMessage(`Deleted game #${game.gameNumber}.`);
setGamePendingDelete(null);
await loadGames(gameDate || undefined);
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete game.");
}
}
return (
<AppShell
title="Game History"
subtitle="Review full past game details, including lineup used, score, notes, and every player stat line."
>
<ConfirmDialog
open={gamePendingDelete !== null}
title="Delete Saved Game?"
description={
gamePendingDelete
? `Delete game #${gamePendingDelete.gameNumber} for ${gamePendingDelete.gameDate}? Player stats from that game will be reversed.`
: ""
}
confirmLabel="Delete Game"
onCancel={() => setGamePendingDelete(null)}
onConfirm={() => (gamePendingDelete ? void deleteGame(gamePendingDelete) : undefined)}
/>
{message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard
title="Browse Games"
description="Filter by date or leave it blank to browse every saved game."
>
<div className="flex flex-wrap gap-2">
<div className="min-w-[180px]">
<span className="field-label">Game Date</span>
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
</div>
<div className="flex items-end gap-2">
<Button onClick={() => void loadGames(gameDate || undefined)}>Apply Filter</Button>
{gameDate ? (
<Button
variant="secondary"
onClick={() => {
setGameDate("");
void loadGames();
}}
>
Clear
</Button>
) : null}
</div>
</div>
</SectionCard>
<SectionCard
title="Saved Games"
description="Open any game card to inspect the full saved details."
>
{games.length > 0 ? (
<div className="grid gap-3">
{games.map((game) => (
<Card key={game._id}>
<CardContent className="pt-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
<FileText className="h-4 w-4" />
</div>
<div>
<h3 className="text-sm font-semibold">
{game.gameDate} · Game #{game.gameNumber}
</h3>
<p className="mt-1 text-xs text-muted-foreground">
Lineup #{game.lineupNumber ?? "?"}
{game.opponent ? ` · vs ${game.opponent}` : ""}
{game.teamScore !== null &&
game.teamScore !== undefined &&
game.opponentScore !== null &&
game.opponentScore !== undefined
? ` · ${game.teamScore}-${game.opponentScore}`
: ""}
</p>
</div>
</div>
<Button
size="sm"
variant="destructive"
className="gap-1"
onClick={() => setGamePendingDelete(game)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
<details className="mt-4 rounded-[1.25rem] border border-border bg-muted/30 px-4 py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm font-medium">
View Full Game Data
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary>
<div className="mt-4 space-y-4">
{game.notes ? (
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Notes
</p>
<p className="mt-1 text-sm">{game.notes}</p>
</div>
) : null}
<div className="grid gap-3">
{game.playerStats.map((playerStat) => (
<div key={playerStat.playerId} className="rounded-[1rem] border border-border bg-white/70 p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold">{playerStat.playerName}</p>
<p className="text-xs text-muted-foreground">Saved stat line</p>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Hitting Summary
</p>
{hasBattingStats(playerStat) ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
{getBattingSummary(playerStat).map(([label, value]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{value ?? "-"}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No batting stats recorded.</p>
)}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
{battingFields.map(([field, label]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{playerStat[field] ?? "-"}</span>
</div>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Pitching Summary
</p>
{hasPitchingStats(playerStat) ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
{getPitchingSummary(playerStat).map(([label, value]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{value ?? "-"}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No pitching stats recorded.</p>
)}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs">
{pitchingFields.map(([field, label]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{playerStat[field] ?? "-"}</span>
</div>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</details>
</CardContent>
</Card>
))}
</div>
) : (
<EmptyState
title="No saved games"
description="Score and save a game first, then its full details will appear here."
/>
)}
</SectionCard>
</AppShell>
);
}
@@ -0,0 +1,326 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { fetchJson } from "@/lib/client";
import type { GamePlayerStatsInput, GameRecord, LineupRecord } from "@/lib/types";
const battingFields = [
["atBats", "AB"],
["runs", "R"],
["hits", "H"],
["doubles", "2B"],
["triples", "3B"],
["homeRuns", "HR"],
["runsBattedIn", "RBI"],
["walks", "BB"],
["strikeouts", "SO"],
["stolenBases", "SB"]
] as const;
const pitchingFields = [
["pitchCount", "Pitches"],
["inningsPitchedOuts", "IP Outs"],
["earnedRunsAllowed", "ER"],
["hitsAllowed", "H Allowed"],
["walksAllowed", "BB Allowed"],
["strikeoutsPitched", "Pitching K"]
] as const;
function pickDefaultLineupId(gameDate: string, lineups: LineupRecord[]) {
const exactDate = lineups.find((lineup) => lineup.gameDate === gameDate);
return exactDate?._id ?? lineups[0]?._id ?? "";
}
export function GameScoringClient() {
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
const [lineups, setLineups] = useState<LineupRecord[]>([]);
const [selectedLineupId, setSelectedLineupId] = useState("");
const [opponent, setOpponent] = useState("");
const [teamScore, setTeamScore] = useState<string>("");
const [opponentScore, setOpponentScore] = useState<string>("");
const [notes, setNotes] = useState("");
const [playerStats, setPlayerStats] = useState<Record<string, GamePlayerStatsInput>>({});
const [isScoring, setIsScoring] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadLineups() {
try {
const response = await fetchJson<LineupRecord[]>("/api/lineups");
setLineups(response);
setSelectedLineupId((current) =>
current && response.some((lineup) => lineup._id === current)
? current
: pickDefaultLineupId(gameDate, response)
);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load lineups.");
}
}
useEffect(() => {
void loadLineups();
}, []);
useEffect(() => {
setSelectedLineupId((current) => {
if (current && lineups.some((lineup) => lineup._id === current)) {
return current;
}
return pickDefaultLineupId(gameDate, lineups);
});
}, [gameDate, lineups]);
const selectedLineup = useMemo(
() => lineups.find((lineup) => lineup._id === selectedLineupId) ?? null,
[lineups, selectedLineupId]
);
useEffect(() => {
if (!selectedLineup) {
setPlayerStats({});
setIsScoring(false);
return;
}
setPlayerStats((current) =>
Object.fromEntries(
selectedLineup.battingOrder.map((slot) => [
slot.playerId,
current[slot.playerId] ?? {
playerId: slot.playerId,
playerName: slot.playerName
}
])
)
);
}, [selectedLineup]);
function updatePlayerStat(
playerId: string,
playerName: string,
field: keyof Omit<GamePlayerStatsInput, "playerId" | "playerName">,
value: string
) {
setPlayerStats((current) => ({
...current,
[playerId]: {
...(current[playerId] ?? { playerId, playerName }),
playerId,
playerName,
[field]: value === "" ? null : Number(value)
}
}));
}
async function saveGame() {
if (!selectedLineup || !selectedLineup._id) {
setError("Select a saved lineup before scoring the game.");
return;
}
try {
const payload = {
gameDate,
lineupId: selectedLineup._id,
opponent,
teamScore: teamScore === "" ? null : Number(teamScore),
opponentScore: opponentScore === "" ? null : Number(opponentScore),
notes,
playerStats: selectedLineup.battingOrder.map(
(slot) => playerStats[slot.playerId] ?? { playerId: slot.playerId, playerName: slot.playerName }
)
};
const saved = await fetchJson<GameRecord>("/api/games", {
method: "POST",
body: JSON.stringify(payload)
});
setMessage(`Saved game #${saved.gameNumber} for ${saved.gameDate}.`);
setOpponent("");
setTeamScore("");
setOpponentScore("");
setNotes("");
setPlayerStats({});
setIsScoring(false);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to save game.");
}
}
return (
<AppShell
title="Score Game"
subtitle="Choose the lineup used, then open the scorecard only when you're ready to enter the game results."
>
{message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard
title="Game Setup"
description="The most recent saved lineup is selected by default."
>
{lineups.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className="field-label">Game Date</span>
<Input
type="date"
value={gameDate}
onChange={(event) => {
setGameDate(event.target.value);
setIsScoring(false);
}}
/>
</label>
<label>
<span className="field-label">Lineup Used</span>
<select
className="field-select"
value={selectedLineupId}
onChange={(event) => {
setSelectedLineupId(event.target.value);
setIsScoring(false);
}}
>
{lineups.map((lineup) => (
<option key={lineup._id} value={lineup._id}>
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
</option>
))}
</select>
</label>
<label>
<span className="field-label">Opponent</span>
<Input value={opponent} onChange={(event) => setOpponent(event.target.value)} placeholder="Optional" />
</label>
<label className="md:col-span-2">
<span className="field-label">Game Notes</span>
<Textarea rows={3} value={notes} onChange={(event) => setNotes(event.target.value)} />
</label>
<div className="md:col-span-2 flex flex-wrap gap-2">
<Button onClick={() => setIsScoring(true)}>Start Scorecard</Button>
{isScoring ? (
<Button variant="secondary" onClick={() => setIsScoring(false)}>
Hide Scorecard
</Button>
) : null}
</div>
</div>
) : (
<EmptyState
title="No lineup available"
description="Save a lineup on the lineup tab first, then return here to score the game."
/>
)}
</SectionCard>
{selectedLineup && isScoring ? (
<SectionCard
title="Player Results"
description="Enter player results as the game unfolds. Final score lives here because you know it once the game is actually being scored."
>
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-3 rounded-[1.5rem] border border-border bg-muted/30 p-4 md:max-w-sm">
<label>
<span className="field-label">Our Score</span>
<Input
type="number"
min={0}
value={teamScore}
onChange={(event) => setTeamScore(event.target.value)}
/>
</label>
<label>
<span className="field-label">Their Score</span>
<Input
type="number"
min={0}
value={opponentScore}
onChange={(event) => setOpponentScore(event.target.value)}
/>
</label>
</div>
{selectedLineup.battingOrder.map((slot, index) => {
const statLine = playerStats[slot.playerId] ?? {
playerId: slot.playerId,
playerName: slot.playerName
};
return (
<Card key={slot.playerId}>
<CardContent className="grid gap-3 pt-4">
<div className="flex items-center gap-3">
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{index + 1}
</div>
<div>
<h3 className="text-sm font-semibold">{slot.playerName}</h3>
<p className="text-xs text-muted-foreground">Batting order slot {index + 1}</p>
</div>
</div>
<div className="grid gap-3">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Batting Line
</p>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-6">
{battingFields.map(([field, label]) => (
<label key={field}>
<span className="field-label">{label}</span>
<Input
type="number"
min={0}
value={statLine[field] ?? ""}
onChange={(event) =>
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
}
/>
</label>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Pitching Line
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{pitchingFields.map(([field, label]) => (
<label key={field}>
<span className="field-label">{label}</span>
<Input
type="number"
min={0}
value={statLine[field] ?? ""}
onChange={(event) =>
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
}
/>
</label>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
<div>
<Button onClick={() => void saveGame()}>Save Game</Button>
</div>
</div>
</SectionCard>
) : null}
</AppShell>
);
}
+470
View File
@@ -0,0 +1,470 @@
"use client";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState";
import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { fetchJson } from "@/lib/client";
import { FIELD_POSITIONS } from "@/lib/constants";
import { formatPlayerName } from "@/lib/utils";
import type { BattingSlot, GeneratedLineup, LineupRecord, PlayerRecord } from "@/lib/types";
function SortableBattingOrderItem({
slot,
index,
selectedPlayers,
onChange
}: {
slot: BattingSlot;
index: number;
selectedPlayers: PlayerRecord[];
onChange: (playerId: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: slot.playerId
});
const style = {
transform: CSS.Transform.toString(transform),
transition
};
return (
<div
ref={setNodeRef}
style={style}
className="grid gap-3 rounded-[1.5rem] border border-border bg-white/70 p-3 md:grid-cols-[auto_72px_1fr] md:items-center"
>
<button
type="button"
className="hidden rounded-full p-2 text-muted-foreground hover:bg-muted md:inline-flex"
{...attributes}
{...listeners}
aria-label={`Drag batting spot ${index + 1}`}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center justify-between md:justify-start">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground md:hidden">
Drag
</p>
<button
type="button"
className="inline-flex rounded-full p-2 text-muted-foreground hover:bg-muted md:hidden"
{...attributes}
{...listeners}
aria-label={`Drag batting spot ${index + 1}`}
>
<GripVertical className="h-4 w-4" />
</button>
<p className="text-lg font-semibold">#{index + 1}</p>
</div>
<select className="field-select md:col-start-3" value={slot.playerId} onChange={(event) => onChange(event.target.value)}>
{selectedPlayers.map((player) => (
<option key={player._id} value={player._id}>
{formatPlayerName(player.firstName, player.lastName)}
</option>
))}
</select>
</div>
);
}
export function LineupClient() {
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
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 [savedLineups, setSavedLineups] = useState<LineupRecord[]>([]);
const [lineupPendingDelete, setLineupPendingDelete] = useState<LineupRecord | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
);
async function loadPlayers(targetDate: string) {
try {
const response = await fetchJson<PlayerRecord[]>(`/api/players?gameDate=${targetDate}`);
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.");
}
}
async function loadSavedLineups(targetDate: string) {
try {
const response = await fetchJson<LineupRecord[]>(`/api/lineups?gameDate=${targetDate}`);
setSavedLineups(response);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load saved lineups.");
}
}
useEffect(() => {
void loadPlayers(gameDate);
void loadSavedLineups(gameDate);
}, [gameDate]);
const selectablePitchers = useMemo(
() =>
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);
}
function updateBattingOrder(order: BattingSlot[]) {
if (!generated) {
return;
}
updateGenerated({
...generated,
battingOrder: order.map((entry, index) => ({ ...entry, slot: index + 1 }))
});
}
function handleBattingDragEnd(event: DragEndEvent) {
if (!generated || !event.over || event.active.id === event.over.id) {
return;
}
const overId = event.over.id;
const oldIndex = generated.battingOrder.findIndex((slot) => slot.playerId === event.active.id);
const newIndex = generated.battingOrder.findIndex((slot) => slot.playerId === overId);
if (oldIndex < 0 || newIndex < 0) {
return;
}
updateBattingOrder(arrayMove(generated.battingOrder, oldIndex, newIndex));
}
async function generate() {
try {
const response = await fetchJson<GeneratedLineup>("/api/lineups/generate", {
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 {
const saved = await fetchJson<LineupRecord>("/api/lineups", {
method: "POST",
body: JSON.stringify({
...generated,
playerIds: selectedPlayerIds
})
});
setMessage(`Saved as lineup #${saved.lineupNumber} for ${saved.gameDate}.`);
await loadSavedLineups(gameDate);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to save lineup.");
}
}
async function deleteSavedLineup(lineup: LineupRecord) {
if (!lineup._id) {
return;
}
try {
await fetchJson(`/api/lineups/${lineup._id}`, {
method: "DELETE"
});
setMessage(`Deleted lineup #${lineup.lineupNumber}.`);
setLineupPendingDelete(null);
await loadSavedLineups(gameDate);
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete lineup.");
}
}
return (
<AppShell
title="Generate Lineup"
subtitle="Build the game-day lineup, drag batting order into place, and save numbered versions."
>
<ConfirmDialog
open={lineupPendingDelete !== null}
title="Delete Saved Lineup?"
description={
lineupPendingDelete
? `Delete lineup #${lineupPendingDelete.lineupNumber} for ${lineupPendingDelete.gameDate}?`
: ""
}
confirmLabel="Delete"
onCancel={() => setLineupPendingDelete(null)}
onConfirm={() => (lineupPendingDelete ? void deleteSavedLineup(lineupPendingDelete) : undefined)}
/>
{message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard title="Game Setup" description="Keep the setup compact for quick game-day edits.">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className="field-label">Game Date</span>
<Input type="date" value={gameDate} onChange={(event) => setGameDate(event.target.value)} />
</label>
<label>
<span className="field-label">Starting Pitcher</span>
<select
className="field-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="md:col-span-2 flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
<input
type="checkbox"
checked={showAllPitchers}
onChange={(event) => setShowAllPitchers(event.target.checked)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
Show pitchers who need rest
</label>
<div className="md:col-span-2 flex flex-wrap gap-2">
<Button size="sm" disabled={selectedPlayerIds.length === 0} onClick={() => void generate()}>
Generate
</Button>
<Button size="sm" variant="secondary" disabled={!generated} onClick={() => void save()}>
Save Version
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{players.map((player) => {
const selected = selectedPlayerIds.includes(player._id);
return (
<Card
key={player._id}
className={selected ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20" : ""}
>
<CardContent className="flex items-start justify-between gap-3 pt-4">
<label className="flex flex-1 cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={selected}
onChange={(event) =>
setSelectedPlayerIds((current) =>
event.target.checked
? [...current, player._id]
: current.filter((id) => id !== player._id)
)
}
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
/>
<span>
<span className="block text-base font-semibold">
{formatPlayerName(player.firstName, player.lastName)}
</span>
<span className="mt-1 block text-xs text-muted-foreground">
#{player.jerseyNumber} {player.primaryPosition}
</span>
</span>
</label>
<StatusBadge status={player.pitcherAvailability.status} />
</CardContent>
</Card>
);
})}
</div>
</SectionCard>
{generated ? (
<>
<SectionCard title="Batting Order" description="Drag rows to reorder.">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleBattingDragEnd}>
<SortableContext items={generated.battingOrder.map((slot) => slot.playerId)} strategy={verticalListSortingStrategy}>
<div className="grid gap-3">
{generated.battingOrder.map((slot, index) => (
<SortableBattingOrderItem
key={slot.playerId}
slot={slot}
index={index}
selectedPlayers={selectedPlayers}
onChange={(playerId) => {
const nextPlayer = selectedPlayers.find((player) => player._id === playerId);
if (!nextPlayer || !generated) {
return;
}
updateBattingOrder(
generated.battingOrder.map((entry, entryIndex) =>
entryIndex === index
? {
...entry,
playerId: nextPlayer._id,
playerName: formatPlayerName(nextPlayer.firstName, nextPlayer.lastName)
}
: entry
)
);
}}
/>
))}
</div>
</SortableContext>
</DndContext>
</SectionCard>
<SectionCard title="Defensive Alignment" description="Keep the fielding assignments editable.">
<div className="divide-y divide-border">
{FIELD_POSITIONS.map((position) => {
const assignment = generated.defensiveAssignments.find((entry) => entry.position === position);
return (
<div key={position} className="grid gap-3 py-3 md:grid-cols-[110px_1fr] md:items-center">
<p className="text-sm font-semibold">{position}</p>
<select
className="field-select"
value={assignment?.playerId ?? ""}
onChange={(event) => {
const nextPlayer = selectedPlayers.find((player) => player._id === event.target.value);
if (!nextPlayer || !generated) 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="rounded-[1.5rem] border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{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 active players, choose a starter, then generate the first version."
/>
</SectionCard>
)}
<SectionCard title="Saved Lineups" description="Saved versions are numbered by game date and can be deleted.">
{savedLineups.length > 0 ? (
<div className="grid gap-3">
{savedLineups.map((lineup) => (
<Card key={lineup._id}>
<CardContent className="flex items-start justify-between gap-3 pt-4">
<div>
<h3 className="text-base font-semibold">
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{lineup.battingOrder.length} hitters · {lineup.defensiveAssignments.length} field assignments
</p>
</div>
<Button
size="sm"
variant="destructive"
onClick={() => setLineupPendingDelete(lineup)}
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<EmptyState
title="No saved lineups"
description="Save a generated lineup to keep numbered versions for this game date."
/>
)}
</SectionCard>
</AppShell>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
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: "",
stats: {}
};
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 ?? "",
stats: player.stats ?? {}
};
}
const statFields = [
["gamesPlayed", "Games"],
["atBats", "AB"],
["runs", "Runs"],
["hits", "Hits"],
["doubles", "2B"],
["triples", "3B"],
["homeRuns", "HR"],
["runsBattedIn", "RBI"],
["walks", "BB"],
["strikeouts", "SO"],
["stolenBases", "SB"]
] as const;
const pitchingStatFields = [
["inningsPitchedOuts", "IP Outs"],
["earnedRunsAllowed", "ER"],
["hitsAllowed", "H Allowed"],
["walksAllowed", "BB Allowed"],
["strikeoutsPitched", "Pitching K"]
] as const;
export function PlayerForm({
value,
onChange,
onSubmit,
submitLabel
}: {
value: PlayerInput;
onChange: (next: PlayerInput) => void;
onSubmit: () => void;
submitLabel: string;
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className="field-label">First Name</span>
<Input value={value.firstName} onChange={(event) => onChange({ ...value, firstName: event.target.value })} />
</label>
<label>
<span className="field-label">Last Name</span>
<Input value={value.lastName} onChange={(event) => onChange({ ...value, lastName: event.target.value })} />
</label>
<label>
<span className="field-label">Jersey #</span>
<Input type="number" value={value.jerseyNumber} onChange={(event) => onChange({ ...value, jerseyNumber: Number(event.target.value) })} />
</label>
<label>
<span className="field-label">Bats / Throws</span>
<select className="field-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 className="field-label">Primary Position</span>
<select className="field-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 className="field-label">Secondary Position</span>
<select
className="field-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 className="field-label">Tertiary Position</span>
<select
className="field-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="flex items-center gap-3 rounded-2xl border border-border bg-white/70 px-4 py-3 text-sm font-medium">
<input type="checkbox" checked={value.isActive} onChange={(event) => onChange({ ...value, isActive: event.target.checked })} className="h-4 w-4 rounded border-border text-primary focus:ring-primary" />
Active for selection
</label>
<label className="md:col-span-2">
<span className="field-label">Notes</span>
<Textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
</label>
<div className="md:col-span-2 space-y-4 rounded-[1.5rem] border border-border bg-muted/40 p-4">
<div>
<p className="text-sm font-semibold">Optional Player Stats</p>
<p className="mt-1 text-xs text-muted-foreground">
Leave any field blank if you do not want to track it.
</p>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Batting Totals</p>
<div className="mt-2 grid grid-cols-2 gap-3 md:grid-cols-4">
{statFields.map(([key, label]) => (
<label key={key}>
<span className="field-label">{label}</span>
<Input
type="number"
min={0}
value={value.stats?.[key] ?? ""}
onChange={(event) =>
onChange({
...value,
stats: {
...value.stats,
[key]: event.target.value === "" ? null : Number(event.target.value)
}
})
}
/>
</label>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Pitching Totals</p>
<div className="mt-2 grid grid-cols-2 gap-3 md:grid-cols-4">
{pitchingStatFields.map(([key, label]) => (
<label key={key}>
<span className="field-label">{label}</span>
<Input
type="number"
min={0}
value={value.stats?.[key] ?? ""}
onChange={(event) =>
onChange({
...value,
stats: {
...value.stats,
[key]: event.target.value === "" ? null : Number(event.target.value)
}
})
}
/>
</label>
))}
</div>
</div>
</div>
</div>
<div className="md:col-span-2">
<Button onClick={onSubmit}>
{submitLabel}
</Button>
</div>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { EmptyState } from "@/components/EmptyState";
import { PlayerForm, emptyPlayerForm, toPlayerForm } from "@/components/PlayerForm";
import { SectionCard } from "@/components/SectionCard";
import { StatusBadge } from "@/components/StatusBadge";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getBattingSummary, getPitchingSummary, hasBattingStats, hasPitchingStats } from "@/lib/baseball-stats";
import { fetchJson } from "@/lib/client";
import { formatPlayerName } from "@/lib/utils";
import type { PlayerInput, PlayerPitchingInput, 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 [showPlayerForm, setShowPlayerForm] = useState(false);
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
const [pitchingForm, setPitchingForm] = useState<PlayerPitchingInput>({
appearanceId: null,
gameDate: null,
pitchCount: null,
notes: ""
});
const playerFormRef = useRef<HTMLDivElement | null>(null);
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);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load roster.");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadPlayers();
}, []);
useEffect(() => {
if (editingPlayer) {
setPlayerForm(toPlayerForm(editingPlayer));
setPitchingForm(
editingPlayer.latestPitching ?? {
appearanceId: null,
gameDate: null,
pitchCount: null,
notes: ""
}
);
} else {
setPlayerForm(emptyPlayerForm());
setPitchingForm({
appearanceId: null,
gameDate: null,
pitchCount: null,
notes: ""
});
}
}, [editingPlayer]);
useEffect(() => {
if (editingPlayerId) {
setShowPlayerForm(true);
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [editingPlayerId]);
function startEditingPlayer(playerId: string) {
setEditingPlayerId(playerId);
setShowPlayerForm(true);
setMessage(null);
setError(null);
}
function stopEditingPlayer() {
setEditingPlayerId(null);
setShowPlayerForm(false);
setPlayerForm(emptyPlayerForm());
setPitchingForm({
appearanceId: null,
gameDate: null,
pitchCount: null,
notes: ""
});
}
function startAddingPlayer() {
setEditingPlayerId(null);
setPlayerForm(emptyPlayerForm());
setPitchingForm({
appearanceId: null,
gameDate: null,
pitchCount: null,
notes: ""
});
setShowPlayerForm((current) => {
const next = !current;
if (!current) {
requestAnimationFrame(() => {
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
return next;
});
setMessage(null);
setError(null);
}
async function submitPlayer() {
try {
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
const method = editingPlayerId ? "PATCH" : "POST";
const savedPlayer = await fetchJson<PlayerRecord>(url, { method, body: JSON.stringify(playerForm) });
if (pitchingForm.appearanceId && !pitchingForm.gameDate && !pitchingForm.pitchCount) {
await fetchJson(`/api/pitching-appearances/${pitchingForm.appearanceId}`, { method: "DELETE" });
} else if (pitchingForm.gameDate && pitchingForm.pitchCount) {
const payload = {
playerId: savedPlayer._id,
gameDate: pitchingForm.gameDate,
pitchCount: pitchingForm.pitchCount,
notes: pitchingForm.notes ?? ""
};
if (pitchingForm.appearanceId) {
await fetchJson(`/api/pitching-appearances/${pitchingForm.appearanceId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
} else {
await fetchJson("/api/pitching-appearances", {
method: "POST",
body: JSON.stringify(payload)
});
}
}
setMessage(editingPlayerId ? "Player updated." : "Player added.");
setEditingPlayerId(null);
setShowPlayerForm(false);
setPlayerForm(emptyPlayerForm());
await loadPlayers();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to save player.");
}
}
async function deletePlayer(player: PlayerRecord) {
try {
await fetchJson(`/api/players/${player._id}`, { method: "DELETE" });
setMessage("Player removed.");
setPlayerPendingDelete(null);
await loadPlayers();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete player.");
}
}
return (
<AppShell
title="Roster Management"
subtitle="Review the active roster first, then open a compact add or edit form when you need it."
>
<ConfirmDialog
open={playerPendingDelete !== null}
title="Remove Player?"
description={
playerPendingDelete
? `Remove ${formatPlayerName(playerPendingDelete.firstName, playerPendingDelete.lastName)} from the roster? This also removes their pitching history and saved lineup references.`
: ""
}
confirmLabel="Remove Player"
onCancel={() => setPlayerPendingDelete(null)}
onConfirm={() => (playerPendingDelete ? void deletePlayer(playerPendingDelete) : undefined)}
/>
{message ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard
title="Current Roster"
description="Tap edit for fast changes and check pitcher status at a glance."
>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={startAddingPlayer}>
{showPlayerForm && !editingPlayerId ? "Hide Add Player" : "Add Player"}
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading roster...</p>
) : players.length > 0 ? (
<div className="overflow-x-auto rounded-[1.5rem] border border-border bg-white/70">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-border bg-muted/60 text-xs uppercase tracking-[0.18em] text-muted-foreground">
<tr>
<th className="px-3 py-3 font-semibold">#</th>
<th className="px-3 py-3 font-semibold">Player</th>
<th className="px-3 py-3 font-semibold">Pos</th>
<th className="px-3 py-3 font-semibold">B/T</th>
<th className="px-3 py-3 font-semibold">Stats</th>
<th className="px-3 py-3 font-semibold">Pitching</th>
<th className="px-3 py-3 font-semibold">Status</th>
<th className="px-3 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{players.map((player) => (
<tr
key={player._id}
className={
editingPlayerId === player._id ? "bg-secondary/5" : ""
}
>
<td className="px-3 py-3 align-top font-medium">{player.jerseyNumber}</td>
<td className="px-3 py-3 align-top">
<div className="font-medium">{formatPlayerName(player.firstName, player.lastName)}</div>
{player.notes ? (
<div className="mt-1 max-w-[18rem] text-xs text-muted-foreground">{player.notes}</div>
) : null}
</td>
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
<div>{player.primaryPosition}</div>
{player.secondaryPosition ? <div>{player.secondaryPosition}</div> : null}
{player.tertiaryPosition ? <div>{player.tertiaryPosition}</div> : null}
</td>
<td className="px-3 py-3 align-top">{player.batsThrows}</td>
<td className="px-3 py-3 align-top">
{hasBattingStats(player.stats) || hasPitchingStats(player.stats) ? (
<div className="space-y-2 text-xs">
{hasBattingStats(player.stats) ? (
<div>
<div className="mb-1 font-semibold text-muted-foreground">Hitting</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{getBattingSummary(player.stats).map(([label, value]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{value ?? "-"}</span>
</div>
))}
</div>
</div>
) : null}
{hasPitchingStats(player.stats) ? (
<div>
<div className="mb-1 font-semibold text-muted-foreground">Pitching</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{getPitchingSummary(player.stats).map(([label, value]) => (
<div key={label}>
<span className="font-semibold text-muted-foreground">{label}</span>{" "}
<span>{value ?? "-"}</span>
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground">No stats</span>
)}
</td>
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
{player.pitcherAvailability.lastPitchedDate
? `${player.pitcherAvailability.lastPitchCount} on ${player.pitcherAvailability.lastPitchedDate}`
: "No history"}
</td>
<td className="px-3 py-3 align-top">
<div className="flex flex-col gap-2">
<StatusBadge status={player.pitcherAvailability.status} />
<span className="text-xs text-muted-foreground">
{player.isActive ? "Active" : "Inactive"}
</span>
</div>
</td>
<td className="px-3 py-3 align-top">
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
{editingPlayerId === player._id ? "Editing" : "Edit"}
</Button>
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
title="No players yet"
description="Add your first player above to start building the roster."
/>
)}
</SectionCard>
{showPlayerForm || editingPlayerId ? (
<div ref={playerFormRef}>
<SectionCard
title={editingPlayerId ? "Edit Player" : "Add Player"}
description="Use the compact form below to update the roster."
>
{editingPlayer ? (
<Alert tone="info" className="flex items-center justify-between gap-4">
<span>Editing {formatPlayerName(editingPlayer.firstName, editingPlayer.lastName)}.</span>
<Button variant="ghost" size="sm" onClick={stopEditingPlayer}>
Cancel
</Button>
</Alert>
) : null}
<PlayerForm
value={playerForm}
onChange={setPlayerForm}
onSubmit={() => void submitPlayer()}
submitLabel={editingPlayerId ? "Save Changes" : "Add Player"}
/>
<div className="grid gap-4 rounded-[1.5rem] border border-border bg-muted/30 p-4 md:grid-cols-2">
<div className="md:col-span-2">
<p className="text-sm font-semibold">Latest Pitching Appearance</p>
<p className="mt-1 text-xs text-muted-foreground">
Optional. Leave blank if the player has no recent outing to track.
</p>
</div>
<label>
<span className="field-label">Game Date</span>
<Input
type="date"
value={pitchingForm.gameDate ?? ""}
onChange={(event) =>
setPitchingForm((current) => ({
...current,
gameDate: event.target.value || null
}))
}
/>
</label>
<label>
<span className="field-label">Pitch Count</span>
<Input
type="number"
min={0}
value={pitchingForm.pitchCount ?? ""}
onChange={(event) =>
setPitchingForm((current) => ({
...current,
pitchCount: event.target.value === "" ? null : Number(event.target.value)
}))
}
/>
</label>
<label className="md:col-span-2">
<span className="field-label">Pitching Notes</span>
<Textarea
rows={2}
value={pitchingForm.notes ?? ""}
onChange={(event) =>
setPitchingForm((current) => ({
...current,
notes: event.target.value
}))
}
/>
</label>
</div>
</SectionCard>
</div>
) : null}
</AppShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function SectionCard({
title,
description,
children
}: {
title: string;
description?: string;
children?: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="px-4 pb-3 pt-4 md:px-6 md:pb-4 md:pt-6">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4 md:space-y-5 md:px-6 md:pb-6">{children}</CardContent>
</Card>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { SectionCard } from "@/components/SectionCard";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 ? <Alert tone="success">{message}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<SectionCard title="Rule Table" description="Each row maps a pitch-count range to required rest days.">
<div className="overflow-hidden rounded-[1.75rem] border border-border bg-white/70">
<div className="grid grid-cols-3 gap-4 border-b border-border bg-muted/60 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<span>Min Pitches</span>
<span>Max Pitches</span>
<span>Rest Days</span>
</div>
<div className="divide-y divide-border">
{rules.map((rule, index) => (
<div key={`${rule.minPitches}-${index}`} className="grid grid-cols-3 gap-4 px-4 py-4">
<Input
type="number"
value={rule.minPitches}
onChange={(event) =>
setRules((current) =>
current.map((entry, entryIndex) =>
entryIndex === index ? { ...entry, minPitches: Number(event.target.value) } : entry
)
)
}
/>
<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
)
)
}
/>
<Input
type="number"
value={rule.requiredRestDays}
onChange={(event) =>
setRules((current) =>
current.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, requiredRestDays: Number(event.target.value) }
: entry
)
)
}
/>
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button onClick={() => void saveRules()}>Save Rules</Button>
</div>
</SectionCard>
</AppShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { PitcherAvailability } from "@/lib/types";
import { cn, statusLabel } from "@/lib/utils";
export function StatusBadge({ status }: { status: PitcherAvailability }) {
return (
<span
className={cn(
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
status === "available" && "bg-emerald-100 text-emerald-800",
status === "needs-rest" && "bg-red-100 text-red-800",
status === "unknown" && "bg-slate-100 text-slate-700"
)}
>
{statusLabel(status)}
</span>
);
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Alert({
className,
tone = "default",
...props
}: React.HTMLAttributes<HTMLDivElement> & { tone?: "default" | "success" | "error" | "info" }) {
const toneClasses =
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-900"
: tone === "error"
? "border-red-200 bg-red-50 text-red-900"
: tone === "info"
? "border-sky-200 bg-sky-50 text-sky-900"
: "border-border bg-card text-foreground";
return <div className={cn("rounded-2xl border px-4 py-3 text-sm shadow-sm", toneClasses, className)} {...props} />;
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-full text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-panel hover:bg-primary/90",
secondary: "bg-card text-foreground border border-border hover:bg-muted",
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
destructive: "bg-destructive text-destructive-foreground shadow-panel hover:bg-destructive/90"
},
size: {
default: "h-11 px-5",
sm: "h-9 px-4 text-xs",
lg: "h-12 px-6",
icon: "h-10 w-10 rounded-full"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
)
);
Button.displayName = "Button";
export { Button, buttonVariants };
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-3xl border border-white/60 bg-card/90 text-card-foreground shadow-panel backdrop-blur",
className
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-4 md:p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-display text-2xl font-semibold tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-4 pb-4 md:px-6 md:pb-6", className)} {...props} />;
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-2xl border border-input bg-white/90 px-4 py-2 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"min-h-[120px] w-full rounded-2xl border border-input bg-white/90 px-4 py-3 text-sm text-foreground shadow-sm transition placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
+12
View File
@@ -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"
};
}
+117
View File
@@ -0,0 +1,117 @@
import type { PlayerStatsInput } from "@/lib/types";
type StatValue = number | null | undefined;
function stat(value: StatValue) {
return value ?? 0;
}
function hasValue(value: StatValue) {
return value !== null && value !== undefined;
}
function formatRate(value: number | null, digits = 3) {
if (value === null || Number.isNaN(value)) {
return null;
}
return value.toFixed(digits).replace(/^0(?=\.)/, "");
}
function formatDecimal(value: number | null, digits = 2) {
if (value === null || Number.isNaN(value)) {
return null;
}
return value.toFixed(digits);
}
export function formatInningsPitched(outs: StatValue) {
const totalOuts = stat(outs);
if (totalOuts <= 0) {
return null;
}
const wholeInnings = Math.floor(totalOuts / 3);
const remainder = totalOuts % 3;
return `${wholeInnings}.${remainder}`;
}
export function hasBattingStats(stats?: PlayerStatsInput | null) {
if (!stats) return false;
return [
stats.atBats,
stats.runs,
stats.hits,
stats.doubles,
stats.triples,
stats.homeRuns,
stats.runsBattedIn,
stats.walks,
stats.strikeouts,
stats.stolenBases
].some(hasValue);
}
export function hasPitchingStats(stats?: PlayerStatsInput | null) {
if (!stats) return false;
return [
stats.inningsPitchedOuts,
stats.earnedRunsAllowed,
stats.hitsAllowed,
stats.walksAllowed,
stats.strikeoutsPitched
].some(hasValue);
}
export function getBattingSummary(stats?: PlayerStatsInput | null) {
if (!hasBattingStats(stats)) {
return [];
}
const atBats = stat(stats?.atBats);
const hits = stat(stats?.hits);
const walks = stat(stats?.walks);
const doubles = stat(stats?.doubles);
const triples = stat(stats?.triples);
const homeRuns = stat(stats?.homeRuns);
const singles = Math.max(hits - doubles - triples - homeRuns, 0);
const totalBases = singles + doubles * 2 + triples * 3 + homeRuns * 4;
const average = atBats > 0 ? hits / atBats : null;
const onBase = atBats + walks > 0 ? (hits + walks) / (atBats + walks) : null;
const slugging = atBats > 0 ? totalBases / atBats : null;
const ops = onBase !== null || slugging !== null ? (onBase ?? 0) + (slugging ?? 0) : null;
return [
["AVG", formatRate(average)],
["OBP", formatRate(onBase)],
["SLG", formatRate(slugging)],
["OPS", formatRate(ops)]
] as const;
}
export function getPitchingSummary(stats?: PlayerStatsInput | null) {
if (!hasPitchingStats(stats)) {
return [];
}
const outs = stat(stats?.inningsPitchedOuts);
const innings = outs / 3;
const earnedRuns = stat(stats?.earnedRunsAllowed);
const hitsAllowed = stat(stats?.hitsAllowed);
const walksAllowed = stat(stats?.walksAllowed);
const strikeoutsPitched = stat(stats?.strikeoutsPitched);
const era = outs > 0 ? (earnedRuns * 27) / outs : null;
const whip = innings > 0 ? (hitsAllowed + walksAllowed) / innings : null;
return [
["IP", formatInningsPitched(outs)],
["ERA", formatDecimal(era)],
["WHIP", formatDecimal(whip)],
["K", strikeoutsPitched > 0 || hasValue(stats?.strikeoutsPitched) ? String(strikeoutsPitched) : null]
] as const;
}
+19
View File
@@ -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>;
}
+15
View File
@@ -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[];
+35
View File
@@ -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;
}
+124
View File
@@ -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
};
}
+71
View File
@@ -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)])
);
}
+3
View File
@@ -0,0 +1,3 @@
export function serialize<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
+178
View File
@@ -0,0 +1,178 @@
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 PlayerStatsInput {
gamesPlayed?: number | null;
atBats?: number | null;
runs?: number | null;
hits?: number | null;
doubles?: number | null;
triples?: number | null;
homeRuns?: number | null;
runsBattedIn?: number | null;
walks?: number | null;
strikeouts?: number | null;
stolenBases?: number | null;
inningsPitchedOuts?: number | null;
earnedRunsAllowed?: number | null;
hitsAllowed?: number | null;
walksAllowed?: number | null;
strikeoutsPitched?: number | null;
}
export interface PlayerPitchingInput {
appearanceId?: string | null;
gameDate?: string | null;
pitchCount?: number | null;
notes?: string;
}
export interface GamePlayerStatsInput {
playerId: string;
playerName: string;
pitchCount?: number | null;
atBats?: number | null;
runs?: number | null;
hits?: number | null;
doubles?: number | null;
triples?: number | null;
homeRuns?: number | null;
runsBattedIn?: number | null;
walks?: number | null;
strikeouts?: number | null;
stolenBases?: number | null;
inningsPitchedOuts?: number | null;
earnedRunsAllowed?: number | null;
hitsAllowed?: number | null;
walksAllowed?: number | null;
strikeoutsPitched?: number | null;
}
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;
stats?: PlayerStatsInput;
}
export interface PlayerRecord extends PlayerInput {
_id: string;
createdAt?: string;
updatedAt?: string;
pitcherAvailability: PlayerAvailabilitySummary;
latestPitching?: PlayerPitchingInput | null;
}
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;
lineupNumber: number;
playerIds: string[];
createdAt?: string;
updatedAt?: string;
}
export interface PlayerAvailabilitySummary {
status: PitcherAvailability;
daysSinceLastAppearance: number | null;
requiredRestDays: number | null;
lastPitchedDate: string | null;
lastPitchCount: number | null;
}
export interface GameRecord {
_id?: string;
gameDate: string;
gameNumber: number;
lineupId?: string | null;
lineupNumber?: number | null;
opponent?: string;
teamScore?: number | null;
opponentScore?: number | null;
notes?: string;
playerStats: GamePlayerStatsInput[];
createdAt?: string;
updatedAt?: string;
}
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;
}>;
}
+49
View File
@@ -0,0 +1,49 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import type { PitcherAvailability } from "@/lib/types";
export function cn(...parts: Array<string | false | null | undefined>) {
return twMerge(clsx(parts));
}
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;
});
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
const optionalStat = z.union([z.coerce.number().int().min(0), z.null()]).optional();
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(""),
stats: z
.object({
gamesPlayed: optionalStat,
atBats: optionalStat,
runs: optionalStat,
hits: optionalStat,
doubles: optionalStat,
triples: optionalStat,
homeRuns: optionalStat,
runsBattedIn: optionalStat,
walks: optionalStat,
strikeouts: optionalStat,
stolenBases: optionalStat,
inningsPitchedOuts: optionalStat,
earnedRunsAllowed: optionalStat,
hitsAllowed: optionalStat,
walksAllowed: optionalStat,
strikeoutsPitched: optionalStat
})
.optional()
});
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())
});
export const saveGameSchema = z.object({
gameDate: z.string().min(1),
lineupId: z.string().min(1),
opponent: z.string().max(120).optional().default(""),
teamScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
opponentScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
notes: z.string().max(1000).optional().default(""),
playerStats: z.array(
z.object({
playerId: z.string().min(1),
playerName: z.string().min(1),
atBats: optionalStat,
runs: optionalStat,
hits: optionalStat,
doubles: optionalStat,
triples: optionalStat,
homeRuns: optionalStat,
runsBattedIn: optionalStat,
walks: optionalStat,
strikeouts: optionalStat,
stolenBases: optionalStat,
pitchCount: optionalStat,
inningsPitchedOuts: optionalStat,
earnedRunsAllowed: optionalStat,
hitsAllowed: optionalStat,
walksAllowed: optionalStat,
strikeoutsPitched: optionalStat
})
)
});
+78
View File
@@ -0,0 +1,78 @@
import { Model, Schema, Types, model, models } from "mongoose";
export interface GameDocument {
gameDate: Date;
gameNumber: number;
activePlayerIds: Types.ObjectId[];
startingPitcherId?: Types.ObjectId | null;
lineupId?: Types.ObjectId | null;
lineupNumber?: number | null;
opponent?: string;
teamScore?: number | null;
opponentScore?: number | null;
notes?: string;
playerStats: Array<{
playerId: Types.ObjectId;
playerName: string;
pitchCount?: number | null;
atBats?: number | null;
runs?: number | null;
hits?: number | null;
doubles?: number | null;
triples?: number | null;
homeRuns?: number | null;
runsBattedIn?: number | null;
walks?: number | null;
strikeouts?: number | null;
stolenBases?: number | null;
inningsPitchedOuts?: number | null;
earnedRunsAllowed?: number | null;
hitsAllowed?: number | null;
walksAllowed?: number | null;
strikeoutsPitched?: number | null;
}>;
createdAt?: Date;
updatedAt?: Date;
}
const gameSchema = new Schema<GameDocument>(
{
gameDate: { type: Date, required: true, index: true },
gameNumber: { type: Number, required: true, min: 1 },
activePlayerIds: [{ type: Schema.Types.ObjectId, ref: "Player" }],
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
lineupId: { type: Schema.Types.ObjectId, ref: "Lineup", default: null },
lineupNumber: { type: Number, default: null },
opponent: { type: String, default: "" },
teamScore: { type: Number, default: null },
opponentScore: { type: Number, default: null },
notes: { type: String, default: "" },
playerStats: [
{
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
playerName: { type: String, required: true },
atBats: { type: Number, default: null },
runs: { type: Number, default: null },
hits: { type: Number, default: null },
doubles: { type: Number, default: null },
triples: { type: Number, default: null },
homeRuns: { type: Number, default: null },
runsBattedIn: { type: Number, default: null },
walks: { type: Number, default: null },
strikeouts: { type: Number, default: null },
stolenBases: { type: Number, default: null },
pitchCount: { type: Number, default: null },
inningsPitchedOuts: { type: Number, default: null },
earnedRunsAllowed: { type: Number, default: null },
hitsAllowed: { type: Number, default: null },
walksAllowed: { type: Number, default: null },
strikeoutsPitched: { type: Number, default: null }
}
]
},
{ timestamps: true }
);
gameSchema.index({ gameDate: 1, gameNumber: 1 }, { unique: true });
export const GameModel = (models.Game as Model<GameDocument>) || model<GameDocument>("Game", gameSchema);
+52
View File
@@ -0,0 +1,52 @@
import { Model, Schema, Types, model, models } from "mongoose";
export interface LineupDocument {
gameDate: Date;
lineupNumber: number;
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, index: true },
lineupNumber: { type: Number, required: true, min: 1 },
playerIds: [{ type: Schema.Types.ObjectId, ref: "Player", required: true }],
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
battingOrder: [
{
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 }
);
lineupSchema.index({ gameDate: 1, lineupNumber: 1 }, { unique: true });
export const LineupModel = (models.Lineup as Model<LineupDocument>) || model<LineupDocument>("Lineup", lineupSchema);

Some files were not shown because too many files have changed in this diff Show More