diff --git a/src/SportsDivision.Application/DTOs/TournamentEventLevelDto.cs b/src/SportsDivision.Application/DTOs/TournamentEventLevelDto.cs index b185b93..7a752dc 100644 --- a/src/SportsDivision.Application/DTOs/TournamentEventLevelDto.cs +++ b/src/SportsDivision.Application/DTOs/TournamentEventLevelDto.cs @@ -1,3 +1,4 @@ +using SportsDivision.Domain.Enums; namespace SportsDivision.Application.DTOs; -public class TournamentEventLevelDto { public int TournamentEventLevelId { get; set; } public int TournamentId { get; set; } public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public int EventLevelId { get; set; } public string EventLevelName { get; set; } = string.Empty; public bool AgeRestrictionWaived { get; set; } public int RegistrationCount { get; set; } } +public class TournamentEventLevelDto { public int TournamentEventLevelId { get; set; } public int TournamentId { get; set; } public string TournamentName { get; set; } = string.Empty; public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public EventCategory Category { get; set; } public int EventLevelId { get; set; } public string EventLevelName { get; set; } = string.Empty; public bool AgeRestrictionWaived { get; set; } public int RegistrationCount { get; set; } } public class TournamentEventLevelCreateDto { public int TournamentId { get; set; } public int EventId { get; set; } public int EventLevelId { get; set; } public bool AgeRestrictionWaived { get; set; } } diff --git a/src/SportsDivision.Application/Interfaces/IScoringService.cs b/src/SportsDivision.Application/Interfaces/IScoringService.cs index 4fe8c94..3224c0a 100644 --- a/src/SportsDivision.Application/Interfaces/IScoringService.cs +++ b/src/SportsDivision.Application/Interfaces/IScoringService.cs @@ -1,3 +1,3 @@ using SportsDivision.Application.DTOs; namespace SportsDivision.Application.Interfaces; -public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task> GetSchoolStandingsAsync(int tournamentId); Task> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); } +public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task> GetSchoolStandingsAsync(int tournamentId); Task> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); } diff --git a/src/SportsDivision.Application/Interfaces/ITournamentService.cs b/src/SportsDivision.Application/Interfaces/ITournamentService.cs index 1ff7055..158a1ba 100644 --- a/src/SportsDivision.Application/Interfaces/ITournamentService.cs +++ b/src/SportsDivision.Application/Interfaces/ITournamentService.cs @@ -1,4 +1,4 @@ using SportsDivision.Application.DTOs; using SportsDivision.Domain.Enums; namespace SportsDivision.Application.Interfaces; -public interface ITournamentService { Task> GetAllAsync(bool includeArchived = false); Task GetByIdAsync(int id); Task CreateAsync(TournamentCreateDto dto); Task UpdateAsync(TournamentUpdateDto dto); Task UpdateStatusAsync(int id, TournamentStatus status); Task ArchiveAsync(int id); Task UnarchiveAsync(int id); Task DeleteAsync(int id); Task> GetEventLevelsAsync(int tournamentId); Task AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); } +public interface ITournamentService { Task> GetAllAsync(bool includeArchived = false); Task GetByIdAsync(int id); Task CreateAsync(TournamentCreateDto dto); Task UpdateAsync(TournamentUpdateDto dto); Task UpdateStatusAsync(int id, TournamentStatus status); Task ArchiveAsync(int id); Task UnarchiveAsync(int id); Task DeleteAsync(int id); Task> GetEventLevelsAsync(int tournamentId); Task GetEventLevelByIdAsync(int tournamentEventLevelId); Task> GetAllEventLevelsAsync(); Task> GetEventLevelsByCategoryAsync(EventCategory category); Task AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); } diff --git a/src/SportsDivision.Application/Mappings/MappingProfile.cs b/src/SportsDivision.Application/Mappings/MappingProfile.cs index 4745167..e3fdf37 100644 --- a/src/SportsDivision.Application/Mappings/MappingProfile.cs +++ b/src/SportsDivision.Application/Mappings/MappingProfile.cs @@ -37,7 +37,9 @@ public class MappingProfile : Profile CreateMap(); CreateMap() + .ForMember(d => d.TournamentName, o => o.MapFrom(s => s.Tournament != null ? s.Tournament.Name : string.Empty)) .ForMember(d => d.EventName, o => o.MapFrom(s => s.Event != null ? s.Event.Name : string.Empty)) + .ForMember(d => d.Category, o => o.MapFrom(s => s.Event != null ? s.Event.Category : default)) .ForMember(d => d.EventLevelName, o => o.MapFrom(s => s.EventLevel != null ? s.EventLevel.Name : string.Empty)) .ForMember(d => d.RegistrationCount, o => o.MapFrom(s => s.Registrations != null ? s.Registrations.Count : 0)); CreateMap(); diff --git a/src/SportsDivision.Application/Services/EventService.cs b/src/SportsDivision.Application/Services/EventService.cs index bd73346..d43ae65 100644 --- a/src/SportsDivision.Application/Services/EventService.cs +++ b/src/SportsDivision.Application/Services/EventService.cs @@ -26,7 +26,7 @@ public class EventService : IEventService public async Task GetByIdAsync(int id) { - var evt = await _uow.Events.GetByIdAsync(id); + var evt = await _uow.Events.GetWithEventLevelsAsync(id); return evt == null ? null : _mapper.Map(evt); } diff --git a/src/SportsDivision.Application/Services/HighJumpService.cs b/src/SportsDivision.Application/Services/HighJumpService.cs index 4fecad1..d11c508 100644 --- a/src/SportsDivision.Application/Services/HighJumpService.cs +++ b/src/SportsDivision.Application/Services/HighJumpService.cs @@ -146,28 +146,32 @@ public class HighJumpService : IHighJumpService .ThenBy(r => r.TotalFails) .ToList(); - // Record scores + // Record scores: cleared height, placement, placement points, and World-Athletics points. + var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync()) + .ToDictionary(p => p.Placement, p => p.Points); + var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId); + for (int i = 0; i < sorted.Count; i++) { + int place = i + 1; + decimal cleared = sorted[i].HighestCleared; + int calc = (constant != null && cleared > constant.B) + ? (int)Math.Floor((double)constant.A * Math.Pow((double)(cleared - constant.B), (double)constant.C)) + : 0; + var score = await _uow.Scores.GetByRegistrationAsync(sorted[i].RegId); - if (score == null) - { - score = new Score - { - EventRegistrationId = sorted[i].RegId, - RawPerformance = sorted[i].HighestCleared, - RecordedBy = recordedBy, - RecordedAt = DateTime.UtcNow - }; - await _uow.Scores.AddAsync(score); - } - else - { - score.RawPerformance = sorted[i].HighestCleared; - score.RecordedBy = recordedBy; - score.RecordedAt = DateTime.UtcNow; - _uow.Scores.Update(score); - } + var isNew = score == null; + score ??= new Score { EventRegistrationId = sorted[i].RegId }; + + score.RawPerformance = cleared; + score.CalculatedPoints = calc; + score.Placement = place; + score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0; + score.RecordedBy = recordedBy; + score.RecordedAt = DateTime.UtcNow; + + if (isNew) await _uow.Scores.AddAsync(score); + else _uow.Scores.Update(score); } await _uow.SaveChangesAsync(); } diff --git a/src/SportsDivision.Application/Services/SchoolService.cs b/src/SportsDivision.Application/Services/SchoolService.cs index 673a61b..ef58276 100644 --- a/src/SportsDivision.Application/Services/SchoolService.cs +++ b/src/SportsDivision.Application/Services/SchoolService.cs @@ -26,7 +26,7 @@ public class SchoolService : ISchoolService public async Task GetByIdAsync(int id) { - var school = await _uow.Schools.GetByIdAsync(id); + var school = await _uow.Schools.GetWithStudentsAsync(id); return school == null ? null : _mapper.Map(school); } diff --git a/src/SportsDivision.Application/Services/ScoringService.cs b/src/SportsDivision.Application/Services/ScoringService.cs index 890bed7..cdbddd7 100644 --- a/src/SportsDivision.Application/Services/ScoringService.cs +++ b/src/SportsDivision.Application/Services/ScoringService.cs @@ -89,6 +89,62 @@ public class ScoringService : IScoringService await _uow.SaveChangesAsync(); } + public async Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy) + { + var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId) + ?? throw new KeyNotFoundException("Tournament event level not found."); + if (tel.Event.Category != Domain.Enums.EventCategory.Track) + throw new InvalidOperationException("Final standings can only be calculated for track events."); + + var rounds = (await _uow.Rounds.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList(); + var finalRound = rounds.FirstOrDefault(r => r.RoundType == Domain.Enums.RoundType.Final) + ?? rounds.OrderByDescending(r => r.RoundOrder).FirstOrDefault() + ?? throw new InvalidOperationException("This event has no rounds yet — seed heats and record times first."); + + // Finishers in the final = lanes with a recorded time (DNS/DNF/DQ excluded), fastest first. + var finishers = finalRound.Heats + .SelectMany(h => h.HeatLanes) + .Where(l => l.Time.HasValue && !l.IsDNS && !l.IsDNF && !l.IsDQ) + .OrderBy(l => l.Time) + .ToList(); + if (finishers.Count == 0) + throw new InvalidOperationException("The final round has no recorded times yet."); + + var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId); + var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync()) + .ToDictionary(p => p.Placement, p => p.Points); + var finalistRegIds = finishers.Select(f => f.EventRegistrationId).ToHashSet(); + + // Points come from the final only: drop scores for anyone not in the final. + var existing = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList(); + foreach (var sc in existing.Where(s => !finalistRegIds.Contains(s.EventRegistrationId))) + _uow.Scores.Remove(sc); + + for (int i = 0; i < finishers.Count; i++) + { + var lane = finishers[i]; + int place = i + 1; + int calc = constant != null + ? CalculatePoints(lane.Time!.Value, constant.A, constant.B, constant.C, isTrack: true) + : 0; + + var score = await _uow.Scores.GetByRegistrationAsync(lane.EventRegistrationId); + var isNew = score == null; + score ??= new Score { EventRegistrationId = lane.EventRegistrationId }; + + score.RawPerformance = lane.Time!.Value; + score.CalculatedPoints = calc; + score.Placement = place; + score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0; + score.RecordedBy = recordedBy; + score.RecordedAt = DateTime.UtcNow; + + if (isNew) await _uow.Scores.AddAsync(score); + else _uow.Scores.Update(score); + } + await _uow.SaveChangesAsync(); + } + public async Task CalculatePlacementsAsync(int tournamentEventLevelId) { var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)) diff --git a/src/SportsDivision.Application/Services/TournamentService.cs b/src/SportsDivision.Application/Services/TournamentService.cs index f410a6e..a96eec5 100644 --- a/src/SportsDivision.Application/Services/TournamentService.cs +++ b/src/SportsDivision.Application/Services/TournamentService.cs @@ -90,6 +90,35 @@ public class TournamentService : ITournamentService return _mapper.Map>(tels); } + public async Task GetEventLevelByIdAsync(int tournamentEventLevelId) + { + var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId); + return tel == null ? null : _mapper.Map(tel); + } + + public async Task> GetAllEventLevelsAsync() + { + var levels = await _uow.EventLevels.GetAllAsync(); + return _mapper.Map>(levels.OrderBy(l => l.SortOrder)); + } + + public async Task> GetEventLevelsByCategoryAsync(EventCategory category) + { + var tournaments = await _uow.Tournaments.GetActiveAsync(); + var result = new List(); + foreach (var t in tournaments) + { + var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(t.TournamentId); + foreach (var tel in tels.Where(x => x.Event != null && x.Event.Category == category)) + { + var dto = _mapper.Map(tel); + dto.TournamentName = t.Name; + result.Add(dto); + } + } + return result; + } + public async Task AddEventLevelAsync(TournamentEventLevelCreateDto dto) { var existing = await _uow.TournamentEventLevels.FindAsync(dto.TournamentId, dto.EventId, dto.EventLevelId); diff --git a/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs b/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs index 789f67e..2c1eadf 100644 --- a/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs @@ -8,7 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories; public class EventRegistrationRepository : Repository, IEventRegistrationRepository { public EventRegistrationRepository(ApplicationDbContext context) : base(context) { } - public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.Score).ToListAsync(); + public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync(); public async Task> GetByStudentAsync(int studentId) => await _dbSet.Where(r => r.StudentId == studentId).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Tournament).Include(r => r.Score).ToListAsync(); public async Task IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId); public async Task> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId) => await _dbSet.Where(r => r.Student != null && r.Student.SchoolId == schoolId && r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync(); diff --git a/src/SportsDivision.Infrastructure/Repositories/Repository.cs b/src/SportsDivision.Infrastructure/Repositories/Repository.cs index fbf84ff..43bc729 100644 --- a/src/SportsDivision.Infrastructure/Repositories/Repository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/Repository.cs @@ -17,7 +17,7 @@ public class Repository : IRepository where T : class } public async Task GetByIdAsync(int id) => await _dbSet.FindAsync(id); - public async Task> GetAllAsync() => await _dbSet.ToListAsync(); + public virtual async Task> GetAllAsync() => await _dbSet.ToListAsync(); public async Task> FindAsync(Expression> predicate) => await _dbSet.Where(predicate).ToListAsync(); public async Task AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; } public async Task AddRangeAsync(IEnumerable entities) => await _dbSet.AddRangeAsync(entities); diff --git a/src/SportsDivision.Infrastructure/Repositories/RoundRepository.cs b/src/SportsDivision.Infrastructure/Repositories/RoundRepository.cs index 1cc9f8e..48a949c 100644 --- a/src/SportsDivision.Infrastructure/Repositories/RoundRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/RoundRepository.cs @@ -8,6 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories; public class RoundRepository : Repository, IRoundRepository { public RoundRepository(ApplicationDbContext context) : base(context) { } - public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Heats).OrderBy(r => r.RoundOrder).ToListAsync(); + public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Heats).ThenInclude(h => h.HeatLanes).OrderBy(r => r.RoundOrder).ToListAsync(); public async Task GetWithHeatsAsync(int roundId) => await _dbSet.Include(r => r.Heats).ThenInclude(h => h.HeatLanes).ThenInclude(hl => hl.EventRegistration).ThenInclude(er => er.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).FirstOrDefaultAsync(r => r.RoundId == roundId); } diff --git a/src/SportsDivision.Infrastructure/Repositories/SchoolRepository.cs b/src/SportsDivision.Infrastructure/Repositories/SchoolRepository.cs index bdbc796..5f50ef3 100644 --- a/src/SportsDivision.Infrastructure/Repositories/SchoolRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/SchoolRepository.cs @@ -9,7 +9,8 @@ namespace SportsDivision.Infrastructure.Repositories; public class SchoolRepository : Repository, ISchoolRepository { public SchoolRepository(ApplicationDbContext context) : base(context) { } - public async Task> GetByZoneAsync(int zoneId) => await _dbSet.Where(s => s.ZoneId == zoneId).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync(); - public async Task> GetBySchoolLevelAsync(SchoolLevel level) => await _dbSet.Where(s => s.SchoolLevel == level).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync(); + public override async Task> GetAllAsync() => await _dbSet.Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync(); + public async Task> GetByZoneAsync(int zoneId) => await _dbSet.Where(s => s.ZoneId == zoneId).Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync(); + public async Task> GetBySchoolLevelAsync(SchoolLevel level) => await _dbSet.Where(s => s.SchoolLevel == level).Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync(); public async Task GetWithStudentsAsync(int schoolId) => await _dbSet.Include(s => s.Students).Include(s => s.Zone).FirstOrDefaultAsync(s => s.SchoolId == schoolId); } diff --git a/src/SportsDivision.Infrastructure/Repositories/ScoringConstantRepository.cs b/src/SportsDivision.Infrastructure/Repositories/ScoringConstantRepository.cs index 1a19603..08322a7 100644 --- a/src/SportsDivision.Infrastructure/Repositories/ScoringConstantRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/ScoringConstantRepository.cs @@ -8,5 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories; public class ScoringConstantRepository : Repository, IScoringConstantRepository { public ScoringConstantRepository(ApplicationDbContext context) : base(context) { } + public override async Task> GetAllAsync() => await _dbSet.Include(s => s.Event).OrderBy(s => s.Event!.Name).ToListAsync(); public async Task GetByEventAsync(int eventId) => await _dbSet.FirstOrDefaultAsync(s => s.EventId == eventId); } diff --git a/src/SportsDivision.Infrastructure/Repositories/TournamentRepository.cs b/src/SportsDivision.Infrastructure/Repositories/TournamentRepository.cs index a9a57bb..63fe8f9 100644 --- a/src/SportsDivision.Infrastructure/Repositories/TournamentRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/TournamentRepository.cs @@ -8,7 +8,8 @@ namespace SportsDivision.Infrastructure.Repositories; public class TournamentRepository : Repository, ITournamentRepository { public TournamentRepository(ApplicationDbContext context) : base(context) { } - public async Task> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).OrderByDescending(t => t.StartDate).ToListAsync(); + public override async Task> GetAllAsync() => await _dbSet.Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync(); + public async Task> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync(); public async Task GetWithEventLevelsAsync(int tournamentId) => await _dbSet.Include(t => t.TournamentEventLevels).ThenInclude(te => te.Event).Include(t => t.TournamentEventLevels).ThenInclude(te => te.EventLevel).Include(t => t.Zone).FirstOrDefaultAsync(t => t.TournamentId == tournamentId); public async Task GetFullTournamentAsync(int tournamentId) => await _dbSet.Include(t => t.Zone).Include(t => t.TournamentEventLevels).ThenInclude(te => te.Event).Include(t => t.TournamentEventLevels).ThenInclude(te => te.EventLevel).Include(t => t.TournamentEventLevels).ThenInclude(te => te.Registrations).ThenInclude(r => r.Student).ThenInclude(s => s!.School).FirstOrDefaultAsync(t => t.TournamentId == tournamentId); } diff --git a/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs b/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs index 680da18..25c2191 100644 --- a/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs +++ b/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs @@ -248,9 +248,9 @@ public class DatabaseSeeder Add("400m", 1.53775m, 82m, 1.81m, "seconds"); Add("1500m", 0.03768m, 480m, 1.85m, "seconds"); Add("80mH", 5.74352m, 28.5m, 1.92m, "seconds"); - Add("Long Jump", 0.14354m, 220m, 1.4m, "centimetres"); - Add("Triple Jump", 0.14354m, 220m, 1.4m, "centimetres"); - Add("High Jump", 0.8465m, 75m, 1.42m, "centimetres"); + Add("Long Jump", 90.56762m, 2.2m, 1.4m, "metres"); + Add("Triple Jump", 90.56762m, 2.2m, 1.4m, "metres"); + Add("High Jump", 585.63492m, 0.75m, 1.42m, "metres"); Add("Shot Put 3kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Shot Put 4kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Shot Put 5kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Shot Put 6kg", 51.39m, 1.5m, 1.05m, "metres"); Add("Discus 1kg", 12.91m, 4m, 1.1m, "metres"); Add("Discus 1.25kg", 12.91m, 4m, 1.1m, "metres"); diff --git a/src/SportsDivision.Web/Controllers/EventController.cs b/src/SportsDivision.Web/Controllers/EventController.cs index 2c5fbaa..67272a8 100644 --- a/src/SportsDivision.Web/Controllers/EventController.cs +++ b/src/SportsDivision.Web/Controllers/EventController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; using SportsDivision.Domain.Enums; +using SportsDivision.Web.Helpers; namespace SportsDivision.Web.Controllers; @@ -16,7 +17,7 @@ public class EventController : Controller _eventService = eventService; } - public async Task Index(EventCategory? category) + public async Task Index(EventCategory? category, int page = 1, int pageSize = PaginationHelper.PageSize) { IEnumerable events; @@ -31,7 +32,7 @@ public class EventController : Controller ViewBag.SelectedCategory = category; - return View(events); + return View(this.Page(events, page, pageSize)); } [HttpGet] diff --git a/src/SportsDivision.Web/Controllers/FieldEventController.cs b/src/SportsDivision.Web/Controllers/FieldEventController.cs index bc581d1..5f5abd8 100644 --- a/src/SportsDivision.Web/Controllers/FieldEventController.cs +++ b/src/SportsDivision.Web/Controllers/FieldEventController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; +using SportsDivision.Domain.Enums; namespace SportsDivision.Web.Controllers; @@ -25,17 +26,26 @@ public class FieldEventController : Controller [HttpGet] public async Task Index(int tournamentEventLevelId) { + if (tournamentEventLevelId <= 0) + { + ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.Field); + ViewBag.ScoringController = "FieldEvent"; + ViewBag.ScoringTitle = "Field Event Scoring"; + return View("SelectEventLevel"); + } var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId); ViewBag.TournamentEventLevelId = tournamentEventLevelId; return View(registrations); } [HttpPost] - public async Task RecordScore([FromBody] ScoreCreateDto dto) + [ValidateAntiForgeryToken] + public async Task RecordScore(ScoreCreateDto dto, int tournamentEventLevelId) { var recordedBy = User.Identity?.Name ?? "Unknown"; - var score = await _scoringService.RecordScoreAsync(dto, recordedBy); - return Json(new { success = true, score }); + await _scoringService.RecordScoreAsync(dto, recordedBy); + TempData["SuccessMessage"] = "Performance recorded."; + return RedirectToAction(nameof(Index), new { tournamentEventLevelId }); } [HttpPost] diff --git a/src/SportsDivision.Web/Controllers/HighJumpController.cs b/src/SportsDivision.Web/Controllers/HighJumpController.cs index 663e7a5..f495758 100644 --- a/src/SportsDivision.Web/Controllers/HighJumpController.cs +++ b/src/SportsDivision.Web/Controllers/HighJumpController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; +using SportsDivision.Domain.Enums; namespace SportsDivision.Web.Controllers; @@ -28,6 +29,13 @@ public class HighJumpController : Controller [HttpGet] public async Task Index(int tournamentEventLevelId) { + if (tournamentEventLevelId <= 0) + { + ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.HighJump); + ViewBag.ScoringController = "HighJump"; + ViewBag.ScoringTitle = "High Jump Scoring"; + return View("SelectEventLevel"); + } var heights = await _highJumpService.GetHeightsAsync(tournamentEventLevelId); var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId); ViewBag.TournamentEventLevelId = tournamentEventLevelId; @@ -57,12 +65,11 @@ public class HighJumpController : Controller } [HttpPost] - public async Task RecordAttempt([FromBody] HighJumpAttemptUpdateDto dto) + [ValidateAntiForgeryToken] + public async Task RecordAttempt(HighJumpAttemptUpdateDto dto, int tournamentEventLevelId) { await _highJumpService.RecordAttemptAsync(dto); - var isEliminated = await _highJumpService.IsEliminatedAsync( - dto.HighJumpHeightId, dto.EventRegistrationId); - return Json(new { success = true, isEliminated }); + return RedirectToAction(nameof(Index), new { tournamentEventLevelId }); } [HttpPost] diff --git a/src/SportsDivision.Web/Controllers/ReportController.cs b/src/SportsDivision.Web/Controllers/ReportController.cs index 2863919..1081bff 100644 --- a/src/SportsDivision.Web/Controllers/ReportController.cs +++ b/src/SportsDivision.Web/Controllers/ReportController.cs @@ -27,6 +27,7 @@ public class ReportController : Controller public async Task Index() { var tournaments = await _tournamentService.GetAllAsync(); + ViewBag.Tournaments = tournaments; return View(tournaments); } @@ -154,4 +155,65 @@ public class ReportController : Controller var pdf = document.GeneratePdf(); return File(pdf, "application/pdf", $"StudentPoints_{tournamentId}.pdf"); } + + // ----- Excel (.xlsx) exports ----- + + private async Task TournamentNameAsync(int tournamentId) + => (await _tournamentService.GetByIdAsync(tournamentId))?.Name ?? "Tournament"; + + [HttpGet] + public async Task PopularEventsExcel(int tournamentId) + { + var report = await _reportService.GetPopularEventsAsync(tournamentId); + var bytes = ReportExcelExporter.PopularEvents(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"PopularEvents_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task RegistrationByGenderExcel(int tournamentId, int? zoneId) + { + var report = await _reportService.GetRegistrationByGenderAsync(tournamentId, zoneId); + var bytes = ReportExcelExporter.RegistrationByGender(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"RegistrationByGender_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task EventSchoolReportExcel(int tournamentId, int? eventId, int? eventLevelId) + { + var report = await _reportService.GetEventSchoolReportAsync(tournamentId, eventId, eventLevelId); + var bytes = ReportExcelExporter.EventSchoolReport(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"EventSchoolReport_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task StudentsBySchoolExcel(int tournamentId, int? schoolId, int? zoneId) + { + var report = await _reportService.GetStudentsBySchoolAsync(tournamentId, schoolId, zoneId); + var bytes = ReportExcelExporter.StudentsBySchool(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"StudentsBySchool_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task ScoresByEventExcel(int tournamentId, int? eventId, int? eventLevelId) + { + var report = await _reportService.GetScoresByEventAsync(tournamentId, eventId, eventLevelId); + var bytes = ReportExcelExporter.ScoresByEvent(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"ScoresByEvent_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task StudentPointsExcel(int tournamentId, int? schoolId, int? zoneId) + { + var report = await _reportService.GetStudentPointsAsync(tournamentId, schoolId, zoneId); + var bytes = ReportExcelExporter.StudentPoints(report, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"StudentPoints_{tournamentId}.xlsx"); + } + + [HttpGet] + public async Task SchoolStandingsExcel(int tournamentId) + { + var standings = await _scoringService.GetSchoolStandingsAsync(tournamentId); + var bytes = ReportExcelExporter.SchoolStandings(standings, await TournamentNameAsync(tournamentId)); + return File(bytes, ReportExcelExporter.MimeType, $"SchoolStandings_{tournamentId}.xlsx"); + } } diff --git a/src/SportsDivision.Web/Controllers/SchoolController.cs b/src/SportsDivision.Web/Controllers/SchoolController.cs index b764e70..aee1b15 100644 --- a/src/SportsDivision.Web/Controllers/SchoolController.cs +++ b/src/SportsDivision.Web/Controllers/SchoolController.cs @@ -4,6 +4,7 @@ using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; using SportsDivision.Domain.Enums; using SportsDivision.Domain.Interfaces; +using SportsDivision.Web.Helpers; namespace SportsDivision.Web.Controllers; @@ -19,7 +20,7 @@ public class SchoolController : Controller _unitOfWork = unitOfWork; } - public async Task Index(int? zoneId, SchoolLevel? level) + public async Task Index(int? zoneId, SchoolLevel? level, int page = 1, int pageSize = PaginationHelper.PageSize) { IEnumerable schools; @@ -40,7 +41,7 @@ public class SchoolController : Controller ViewBag.SelectedZoneId = zoneId; ViewBag.SelectedLevel = level; - return View(schools); + return View(this.Page(schools, page, pageSize)); } public async Task Details(int id) @@ -174,6 +175,12 @@ public class SchoolController : Controller private async Task PopulateZonesViewBag() { var zones = await _unitOfWork.Zones.GetAllAsync(); - ViewBag.Zones = zones; + ViewBag.Zones = zones.Select(z => new ZoneDto + { + ZoneId = z.ZoneId, + Name = z.Name, + Code = z.Code, + IsActive = z.IsActive + }); } } diff --git a/src/SportsDivision.Web/Controllers/ScoringConfigController.cs b/src/SportsDivision.Web/Controllers/ScoringConfigController.cs index 1fdac80..1dfb9a4 100644 --- a/src/SportsDivision.Web/Controllers/ScoringConfigController.cs +++ b/src/SportsDivision.Web/Controllers/ScoringConfigController.cs @@ -20,6 +20,7 @@ public class ScoringConfigController : Controller { var constants = await _scoringService.GetScoringConstantsAsync(); var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync(); + ViewBag.ScoringConstants = constants; ViewBag.PlacementPointConfigs = placementConfigs; return View(constants); } diff --git a/src/SportsDivision.Web/Controllers/StudentController.cs b/src/SportsDivision.Web/Controllers/StudentController.cs index 3fc7031..cead688 100644 --- a/src/SportsDivision.Web/Controllers/StudentController.cs +++ b/src/SportsDivision.Web/Controllers/StudentController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; +using SportsDivision.Web.Helpers; namespace SportsDivision.Web.Controllers; @@ -17,7 +18,7 @@ public class StudentController : Controller _schoolService = schoolService; } - public async Task Index(int? schoolId, string? search) + public async Task Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize) { IEnumerable students; @@ -38,7 +39,7 @@ public class StudentController : Controller ViewBag.SelectedSchoolId = schoolId; ViewBag.SearchTerm = search; - return View(students); + return View(this.Page(students, page, pageSize)); } public async Task Details(int id) diff --git a/src/SportsDivision.Web/Controllers/TournamentController.cs b/src/SportsDivision.Web/Controllers/TournamentController.cs index 09b4231..ae91c7d 100644 --- a/src/SportsDivision.Web/Controllers/TournamentController.cs +++ b/src/SportsDivision.Web/Controllers/TournamentController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; using SportsDivision.Domain.Enums; +using SportsDivision.Web.Helpers; namespace SportsDivision.Web.Controllers; @@ -18,7 +19,7 @@ public class TournamentController : Controller _eventService = eventService; } - public async Task Index(TournamentStatus? status, bool showArchived = false) + public async Task Index(TournamentStatus? status, bool showArchived = false, int page = 1, int pageSize = PaginationHelper.PageSize) { var tournaments = await _tournamentService.GetAllAsync(includeArchived: showArchived); @@ -30,7 +31,7 @@ public class TournamentController : Controller ViewBag.SelectedStatus = status; ViewBag.ShowArchived = showArchived; - return View(tournaments); + return View(this.Page(tournaments, page, pageSize)); } public async Task Details(int id) @@ -45,6 +46,8 @@ public class TournamentController : Controller var eventLevels = await _tournamentService.GetEventLevelsAsync(id); ViewBag.EventLevels = eventLevels; + ViewBag.Events = await _eventService.GetAllAsync(); + ViewBag.Levels = await _tournamentService.GetAllEventLevelsAsync(); return View(tournament); } @@ -158,32 +161,6 @@ public class TournamentController : Controller return RedirectToAction(nameof(Index)); } - [HttpGet] - public async Task SetupEventLevels(int id) - { - try - { - var tournament = await _tournamentService.GetByIdAsync(id); - if (tournament == null) - { - return NotFound(); - } - - var eventLevels = await _tournamentService.GetEventLevelsAsync(id); - var events = await _eventService.GetAllAsync(); - - ViewBag.Tournament = tournament; - ViewBag.Events = events; - ViewBag.EventLevels = eventLevels; - - return View(eventLevels); - } - catch (KeyNotFoundException) - { - return NotFound(); - } - } - [HttpPost] [ValidateAntiForgeryToken] public async Task AddEventLevel(TournamentEventLevelCreateDto dto) @@ -202,12 +179,12 @@ public class TournamentController : Controller TempData["ErrorMessage"] = ex.Message; } - return RedirectToAction(nameof(SetupEventLevels), new { id = dto.TournamentId }); + return RedirectToAction(nameof(Details), new { id = dto.TournamentId }); } [HttpPost] [ValidateAntiForgeryToken] - public async Task RemoveEventLevel(int tournamentEventLevelId, int tournamentId) + public async Task RemoveEventLevel(int tournamentEventLevelId, int id) { try { @@ -223,7 +200,7 @@ public class TournamentController : Controller TempData["ErrorMessage"] = ex.Message; } - return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId }); + return RedirectToAction(nameof(Details), new { id }); } [HttpPost] @@ -291,7 +268,7 @@ public class TournamentController : Controller [HttpPost] [ValidateAntiForgeryToken] - public async Task ToggleAgeWaiver(int tournamentEventLevelId, int tournamentId) + public async Task ToggleAgeWaiver(int tournamentEventLevelId, int id) { try { @@ -307,6 +284,6 @@ public class TournamentController : Controller TempData["ErrorMessage"] = ex.Message; } - return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId }); + return RedirectToAction(nameof(Details), new { id }); } } diff --git a/src/SportsDivision.Web/Controllers/TrackEventController.cs b/src/SportsDivision.Web/Controllers/TrackEventController.cs index 5ef6ec9..114634e 100644 --- a/src/SportsDivision.Web/Controllers/TrackEventController.cs +++ b/src/SportsDivision.Web/Controllers/TrackEventController.cs @@ -12,22 +12,49 @@ public class TrackEventController : Controller private readonly IHeatManagementService _heatManagementService; private readonly ITournamentService _tournamentService; private readonly IScoringService _scoringService; + private readonly IRegistrationService _registrationService; public TrackEventController( IHeatManagementService heatManagementService, ITournamentService tournamentService, - IScoringService scoringService) + IScoringService scoringService, + IRegistrationService registrationService) { _heatManagementService = heatManagementService; _tournamentService = tournamentService; _scoringService = scoringService; + _registrationService = registrationService; } [HttpGet] public async Task Index(int tournamentEventLevelId) { + if (tournamentEventLevelId <= 0) + { + ViewBag.ScoringTargets = await _tournamentService.GetEventLevelsByCategoryAsync(EventCategory.Track); + ViewBag.ScoringController = "TrackEvent"; + ViewBag.ScoringTitle = "Track Event Scoring"; + return View("SelectEventLevel"); + } var rounds = await _heatManagementService.GetRoundsAsync(tournamentEventLevelId); ViewBag.TournamentEventLevelId = tournamentEventLevelId; + + var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId); + if (tel != null) + { + ViewBag.EventName = tel.EventName; + ViewBag.LevelName = tel.EventLevelName; + ViewBag.TournamentName = tel.TournamentName; + ViewBag.RegistrationCount = tel.RegistrationCount; + } + + // Final standings (placement points are awarded from the final round). + var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId); + ViewBag.Standings = registrations + .Where(r => r.Score?.Placement != null) + .OrderBy(r => r.Score!.Placement) + .ToList(); + return View(rounds); } @@ -40,6 +67,10 @@ public class TrackEventController : Controller return NotFound(); } + ViewBag.TournamentEventLevelId = round.TournamentEventLevelId; + var rounds = await _heatManagementService.GetRoundsAsync(round.TournamentEventLevelId); + ViewBag.HasNextRound = rounds.Any(r => r.RoundOrder == round.RoundOrder + 1); + return View(round); } @@ -65,11 +96,13 @@ public class TrackEventController : Controller } [HttpPost] - public async Task SaveTimes(int heatId, [FromBody] List lanes) + [ValidateAntiForgeryToken] + public async Task SaveTimes(int heatId, int roundId, List lanes) { var recordedBy = User.Identity?.Name ?? "Unknown"; await _heatManagementService.SaveHeatTimesAsync(heatId, lanes, recordedBy); - return Json(new { success = true }); + TempData["SuccessMessage"] = "Heat times saved."; + return RedirectToAction(nameof(ManageRound), new { roundId }); } [HttpPost] @@ -84,8 +117,17 @@ public class TrackEventController : Controller [ValidateAntiForgeryToken] public async Task PopulateNextRound(int roundId) { - await _heatManagementService.PopulateNextRoundAsync(roundId); - return RedirectToAction(nameof(ManageRound), new { roundId }); + var round = await _heatManagementService.GetRoundWithHeatsAsync(roundId); + try + { + await _heatManagementService.PopulateNextRoundAsync(roundId); + TempData["SuccessMessage"] = "Advancing athletes moved into the next round."; + } + catch (InvalidOperationException ex) + { + TempData["ErrorMessage"] = ex.Message; + } + return RedirectToAction(nameof(Index), new { tournamentEventLevelId = round?.TournamentEventLevelId }); } [HttpPost] @@ -103,4 +145,26 @@ public class TrackEventController : Controller await _heatManagementService.CompleteRoundAsync(roundId); return RedirectToAction(nameof(ManageRound), new { roundId }); } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CalculateFinalStandings(int tournamentEventLevelId) + { + try + { + var recordedBy = User.Identity?.Name ?? "Unknown"; + await _scoringService.CalculateTrackFinalResultsAsync(tournamentEventLevelId, recordedBy); + TempData["SuccessMessage"] = "Final standings calculated and placement points awarded."; + } + catch (KeyNotFoundException) + { + return NotFound(); + } + catch (InvalidOperationException ex) + { + TempData["ErrorMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Index), new { tournamentEventLevelId }); + } } diff --git a/src/SportsDivision.Web/Helpers/PaginationHelper.cs b/src/SportsDivision.Web/Helpers/PaginationHelper.cs new file mode 100644 index 0000000..cdec86a --- /dev/null +++ b/src/SportsDivision.Web/Helpers/PaginationHelper.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; + +namespace SportsDivision.Web.Helpers; + +public static class PaginationHelper +{ + public const int PageSize = 20; + + /// Page-size choices offered in the UI. 0 means "All". + public static readonly int[] PageSizeOptions = { 20, 50, 100, 500, 0 }; + + /// + /// Slices to the requested page and records paging + /// metadata in ViewData (read as ViewBag.Page / ViewBag.TotalPages / ViewBag.PageSize + /// in views). A of 0 (or less) means "show all". + /// + public static List Page(this Controller controller, IEnumerable source, int page, int pageSize = PageSize) + { + var list = source as IList ?? source.ToList(); + + // Only the offered choices are honoured; anything else falls back to the default. + if (!PageSizeOptions.Contains(pageSize)) pageSize = PageSize; + var showAll = pageSize <= 0; + + var totalPages = showAll ? 1 : (int)Math.Ceiling(list.Count / (double)pageSize); + if (page < 1) page = 1; + if (totalPages > 0 && page > totalPages) page = totalPages; + + controller.ViewData["Page"] = page; + controller.ViewData["TotalPages"] = totalPages; + controller.ViewData["TotalItems"] = list.Count; + controller.ViewData["PageSize"] = pageSize; // 0 == All + + if (showAll) return list.ToList(); + return list.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + } +} diff --git a/src/SportsDivision.Web/Reports/ReportExcelExporter.cs b/src/SportsDivision.Web/Reports/ReportExcelExporter.cs new file mode 100644 index 0000000..cde2403 --- /dev/null +++ b/src/SportsDivision.Web/Reports/ReportExcelExporter.cs @@ -0,0 +1,192 @@ +using ClosedXML.Excel; +using SportsDivision.Application.DTOs; +using SportsDivision.Application.DTOs.ReportDtos; + +namespace SportsDivision.Web.Reports; + +/// +/// Builds Microsoft Excel (.xlsx) workbooks for each report. One worksheet per +/// report, with a title, subtitle (tournament name), a styled header row, and +/// flattened data rows for the nested reports. +/// +public static class ReportExcelExporter +{ + public const string MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + private static int WriteHeader(IXLWorksheet ws, string title, string subtitle, string[] columns) + { + ws.Cell(1, 1).Value = title; + ws.Cell(1, 1).Style.Font.Bold = true; + ws.Cell(1, 1).Style.Font.FontSize = 14; + ws.Range(1, 1, 1, Math.Max(1, columns.Length)).Merge(); + + ws.Cell(2, 1).Value = subtitle; + ws.Cell(2, 1).Style.Font.Italic = true; + ws.Range(2, 1, 2, Math.Max(1, columns.Length)).Merge(); + + const int headerRow = 4; + for (int i = 0; i < columns.Length; i++) + { + var c = ws.Cell(headerRow, i + 1); + c.Value = columns[i]; + c.Style.Font.Bold = true; + c.Style.Fill.BackgroundColor = XLColor.FromHtml("#003366"); + c.Style.Font.FontColor = XLColor.White; + } + return headerRow + 1; + } + + private static byte[] Finish(XLWorkbook wb, params IXLWorksheet[] sheets) + { + foreach (var ws in sheets) + { + ws.SheetView.FreezeRows(4); + ws.Columns().AdjustToContents(); + } + using var ms = new MemoryStream(); + wb.SaveAs(ms); + return ms.ToArray(); + } + + private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; } + private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; } + + public static byte[] PopularEvents(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Popular Events"); + int r = WriteHeader(ws, "Popular Events Report", tournamentName, + new[] { "Event", "Category", "Total", "Male", "Female" }); + foreach (var e in data) + { + ws.Cell(r, 1).Value = e.EventName; + ws.Cell(r, 2).Value = e.Category; + ws.Cell(r, 3).Value = e.RegistrationCount; + ws.Cell(r, 4).Value = e.MaleCount; + ws.Cell(r, 5).Value = e.FemaleCount; + r++; + } + return Finish(wb, ws); + } + + public static byte[] RegistrationByGender(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Registration by Gender"); + int r = WriteHeader(ws, "Registration by Gender", tournamentName, + new[] { "Event", "Level", "Male", "Female", "Total" }); + foreach (var e in data) + { + ws.Cell(r, 1).Value = e.EventName; + ws.Cell(r, 2).Value = e.EventLevelName; + ws.Cell(r, 3).Value = e.MaleCount; + ws.Cell(r, 4).Value = e.FemaleCount; + ws.Cell(r, 5).Value = e.TotalCount; + r++; + } + return Finish(wb, ws); + } + + public static byte[] EventSchoolReport(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Event School Report"); + int r = WriteHeader(ws, "Event School Report", tournamentName, + new[] { "Event", "Level", "School", "Student", "Performance", "Placement", "Points" }); + foreach (var ev in data) + foreach (var school in ev.Schools) + foreach (var s in school.Students) + { + ws.Cell(r, 1).Value = ev.EventName; + ws.Cell(r, 2).Value = ev.EventLevelName; + ws.Cell(r, 3).Value = school.SchoolName; + ws.Cell(r, 4).Value = s.StudentName; + SetDec(ws.Cell(r, 5), s.RawPerformance); + SetInt(ws.Cell(r, 6), s.Placement); + ws.Cell(r, 7).Value = s.PlacementPoints; + r++; + } + return Finish(wb, ws); + } + + public static byte[] StudentsBySchool(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Students by School"); + int r = WriteHeader(ws, "Students by School", tournamentName, + new[] { "School", "Zone", "Student", "Sex", "Events" }); + foreach (var school in data) + foreach (var s in school.Students) + { + ws.Cell(r, 1).Value = school.SchoolName; + ws.Cell(r, 2).Value = school.ZoneName; + ws.Cell(r, 3).Value = s.StudentName; + ws.Cell(r, 4).Value = s.Sex; + ws.Cell(r, 5).Value = string.Join(", ", s.Events); + r++; + } + return Finish(wb, ws); + } + + public static byte[] ScoresByEvent(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Scores by Event"); + int r = WriteHeader(ws, "Scores by Event", tournamentName, + new[] { "Event", "Level", "Category", "Placement", "Student", "School", "Performance", "WA Points", "Placement Points" }); + foreach (var ev in data) + foreach (var s in ev.Scores) + { + ws.Cell(r, 1).Value = ev.EventName; + ws.Cell(r, 2).Value = ev.EventLevelName; + ws.Cell(r, 3).Value = ev.Category; + SetInt(ws.Cell(r, 4), s.Placement); + ws.Cell(r, 5).Value = s.StudentName; + ws.Cell(r, 6).Value = s.SchoolName; + ws.Cell(r, 7).Value = s.RawPerformance; + ws.Cell(r, 8).Value = s.CalculatedPoints; + ws.Cell(r, 9).Value = s.PlacementPoints; + r++; + } + return Finish(wb, ws); + } + + public static byte[] StudentPoints(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Student Points"); + int r = WriteHeader(ws, "Student Points", tournamentName, + new[] { "Student", "School", "Sex", "Total Placement Points", "Events" }); + foreach (var s in data) + { + ws.Cell(r, 1).Value = s.StudentName; + ws.Cell(r, 2).Value = s.SchoolName; + ws.Cell(r, 3).Value = s.Sex; + ws.Cell(r, 4).Value = s.TotalPlacementPoints; + ws.Cell(r, 5).Value = s.EventCount; + r++; + } + return Finish(wb, ws); + } + + public static byte[] SchoolStandings(IEnumerable data, string tournamentName) + { + using var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("School Standings"); + int r = WriteHeader(ws, "School Standings", tournamentName, + new[] { "Rank", "School", "Short Name", "Total Points", "1st Place", "2nd Place", "3rd Place" }); + int rank = 1; + foreach (var s in data) + { + ws.Cell(r, 1).Value = rank++; + ws.Cell(r, 2).Value = s.SchoolName; + ws.Cell(r, 3).Value = s.ShortName ?? string.Empty; + ws.Cell(r, 4).Value = s.TotalPoints; + ws.Cell(r, 5).Value = s.FirstPlaceCount; + ws.Cell(r, 6).Value = s.SecondPlaceCount; + ws.Cell(r, 7).Value = s.ThirdPlaceCount; + r++; + } + return Finish(wb, ws); + } +} diff --git a/src/SportsDivision.Web/SportsDivision.Web.csproj b/src/SportsDivision.Web/SportsDivision.Web.csproj index 8494fa2..f124c33 100644 --- a/src/SportsDivision.Web/SportsDivision.Web.csproj +++ b/src/SportsDivision.Web/SportsDivision.Web.csproj @@ -11,6 +11,7 @@ + diff --git a/src/SportsDivision.Web/Views/Event/Create.cshtml b/src/SportsDivision.Web/Views/Event/Create.cshtml new file mode 100644 index 0000000..8fb1227 --- /dev/null +++ b/src/SportsDivision.Web/Views/Event/Create.cshtml @@ -0,0 +1,41 @@ +@using SportsDivision.Domain.Enums +@model EventCreateDto +@{ + ViewData["Title"] = "Add Event"; +} + +

Add Event

+
+ +
+
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Cancel +
+
+
+ +@section Scripts { } diff --git a/src/SportsDivision.Web/Views/Event/Edit.cshtml b/src/SportsDivision.Web/Views/Event/Edit.cshtml new file mode 100644 index 0000000..2d1de17 --- /dev/null +++ b/src/SportsDivision.Web/Views/Event/Edit.cshtml @@ -0,0 +1,50 @@ +@using SportsDivision.Domain.Enums +@model EventUpdateDto +@{ + ViewData["Title"] = "Edit Event"; +} + +

Edit Event

+
+ +
+
+
+
+ + @for (var i = 0; i < Model.EventLevelIds.Count; i++) + { + + } +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Cancel +
+
+
+ +@section Scripts { } diff --git a/src/SportsDivision.Web/Views/Event/Index.cshtml b/src/SportsDivision.Web/Views/Event/Index.cshtml index f3db9bc..aabc9b0 100644 --- a/src/SportsDivision.Web/Views/Event/Index.cshtml +++ b/src/SportsDivision.Web/Views/Event/Index.cshtml @@ -35,3 +35,5 @@ } + + diff --git a/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml b/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml index 04cd1d5..f53b1c9 100644 --- a/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml +++ b/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml @@ -50,6 +50,7 @@ @r.SchoolName
+ diff --git a/src/SportsDivision.Web/Views/HighJump/Index.cshtml b/src/SportsDivision.Web/Views/HighJump/Index.cshtml index a07dcbd..fe77624 100644 --- a/src/SportsDivision.Web/Views/HighJump/Index.cshtml +++ b/src/SportsDivision.Web/Views/HighJump/Index.cshtml @@ -73,6 +73,7 @@ @for (int i = 1; i <= 3; i++) { + diff --git a/src/SportsDivision.Web/Views/Report/EventSchoolReport.cshtml b/src/SportsDivision.Web/Views/Report/EventSchoolReport.cshtml index c0769ab..ca56b2e 100644 --- a/src/SportsDivision.Web/Views/Report/EventSchoolReport.cshtml +++ b/src/SportsDivision.Web/Views/Report/EventSchoolReport.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Event School Report"; } -

Event School Report

+
+

Event School Report

+ +

@foreach (var evt in Model) diff --git a/src/SportsDivision.Web/Views/Report/PopularEvents.cshtml b/src/SportsDivision.Web/Views/Report/PopularEvents.cshtml index 1e76ccd..9f3ab6a 100644 --- a/src/SportsDivision.Web/Views/Report/PopularEvents.cshtml +++ b/src/SportsDivision.Web/Views/Report/PopularEvents.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Popular Events"; } -

Popular Events Report

+
+

Popular Events Report

+ +

diff --git a/src/SportsDivision.Web/Views/Report/RegistrationByGender.cshtml b/src/SportsDivision.Web/Views/Report/RegistrationByGender.cshtml index 3689438..67868ec 100644 --- a/src/SportsDivision.Web/Views/Report/RegistrationByGender.cshtml +++ b/src/SportsDivision.Web/Views/Report/RegistrationByGender.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Registration by Gender"; } -

Registration by Gender

+
+

Registration by Gender

+ +

diff --git a/src/SportsDivision.Web/Views/Report/SchoolStandings.cshtml b/src/SportsDivision.Web/Views/Report/SchoolStandings.cshtml index 541ff0c..7b698e4 100644 --- a/src/SportsDivision.Web/Views/Report/SchoolStandings.cshtml +++ b/src/SportsDivision.Web/Views/Report/SchoolStandings.cshtml @@ -3,7 +3,10 @@ ViewData["Title"] = "School Standings"; } -

School Standings

+
+

School Standings

+ Export to Excel +

diff --git a/src/SportsDivision.Web/Views/Report/ScoresByEvent.cshtml b/src/SportsDivision.Web/Views/Report/ScoresByEvent.cshtml index 9f3e526..a2d454a 100644 --- a/src/SportsDivision.Web/Views/Report/ScoresByEvent.cshtml +++ b/src/SportsDivision.Web/Views/Report/ScoresByEvent.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Scores by Event"; } -

Scores by Event

+
+

Scores by Event

+ +

@foreach (var evt in Model) diff --git a/src/SportsDivision.Web/Views/Report/StudentPoints.cshtml b/src/SportsDivision.Web/Views/Report/StudentPoints.cshtml index 44e7183..6e74161 100644 --- a/src/SportsDivision.Web/Views/Report/StudentPoints.cshtml +++ b/src/SportsDivision.Web/Views/Report/StudentPoints.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Student Points"; } -

Student Points

+
+

Student Points

+ +

diff --git a/src/SportsDivision.Web/Views/Report/StudentsBySchool.cshtml b/src/SportsDivision.Web/Views/Report/StudentsBySchool.cshtml index 08f5332..681aecb 100644 --- a/src/SportsDivision.Web/Views/Report/StudentsBySchool.cshtml +++ b/src/SportsDivision.Web/Views/Report/StudentsBySchool.cshtml @@ -3,7 +3,13 @@ ViewData["Title"] = "Students by School"; } -

Students by School

+
+

Students by School

+ +

@foreach (var school in Model) diff --git a/src/SportsDivision.Web/Views/School/Details.cshtml b/src/SportsDivision.Web/Views/School/Details.cshtml new file mode 100644 index 0000000..96b88ee --- /dev/null +++ b/src/SportsDivision.Web/Views/School/Details.cshtml @@ -0,0 +1,45 @@ +@model SchoolDto +@{ + ViewData["Title"] = Model.Name; +} + +
+

@Model.Name

+
+ Edit + Students + Back +
+
+ + + +
+
+
+
Short Name
+
@(string.IsNullOrEmpty(Model.ShortName) ? "—" : Model.ShortName)
+ +
Level
+
@Model.SchoolLevel
+ +
Zone
+
@Model.ZoneName
+ +
Students
+
@Model.StudentCount
+ +
Status
+
+ @if (Model.IsActive) + { + Active + } + else + { + Inactive + } +
+
+
+
diff --git a/src/SportsDivision.Web/Views/School/Index.cshtml b/src/SportsDivision.Web/Views/School/Index.cshtml index 50b99ca..2da3978 100644 --- a/src/SportsDivision.Web/Views/School/Index.cshtml +++ b/src/SportsDivision.Web/Views/School/Index.cshtml @@ -61,3 +61,5 @@ }
+ + diff --git a/src/SportsDivision.Web/Views/ScoringConfig/Index.cshtml b/src/SportsDivision.Web/Views/ScoringConfig/Index.cshtml index c0ff998..5959d32 100644 --- a/src/SportsDivision.Web/Views/ScoringConfig/Index.cshtml +++ b/src/SportsDivision.Web/Views/ScoringConfig/Index.cshtml @@ -16,6 +16,15 @@
Scoring Constants (World Athletics)
+ @if (constants != null) + { + @foreach (var c in constants) + { + + + + } + } @@ -24,15 +33,12 @@ @foreach (var c in constants) { - - - - - - - - - + + + + + + } } @@ -47,6 +53,16 @@
Placement Points
+ @if (placements != null) + { + @foreach (var p in placements.OrderBy(x => x.Placement)) + { +
+ + + + } + }
EventABCUnitAction
@c.EventName@c.Unit@c.EventName@c.Unit
@@ -55,13 +71,9 @@ @foreach (var p in placements.OrderBy(x => x.Placement)) { - - - - - - - + + + } } diff --git a/src/SportsDivision.Web/Views/Shared/SelectEventLevel.cshtml b/src/SportsDivision.Web/Views/Shared/SelectEventLevel.cshtml new file mode 100644 index 0000000..cedf360 --- /dev/null +++ b/src/SportsDivision.Web/Views/Shared/SelectEventLevel.cshtml @@ -0,0 +1,49 @@ +@{ + var title = ViewBag.ScoringTitle as string ?? "Scoring"; + var controller = ViewBag.ScoringController as string ?? "TrackEvent"; + var targets = ViewBag.ScoringTargets as IEnumerable ?? Enumerable.Empty(); + ViewData["Title"] = title; +} + +

@title

+

Select an event level to score.

+
+ +@if (!targets.Any()) +{ +
+ No event levels are available for scoring yet. Add event levels to an active tournament from the + Tournaments page. +
+} +else +{ + @foreach (var grp in targets.GroupBy(t => new { t.TournamentId, t.TournamentName })) + { +
+
+
@grp.Key.TournamentName
+
+
+
PlacePointsAction
#@p.Placement#@p.Placement
+ + + + + @foreach (var t in grp.OrderBy(x => x.EventName).ThenBy(x => x.EventLevelName)) + { + + + + + + + } + +
EventLevelRegistrations
@t.EventName@t.EventLevelName@t.RegistrationCount + Score +
+
+ + } +} diff --git a/src/SportsDivision.Web/Views/Shared/_Pagination.cshtml b/src/SportsDivision.Web/Views/Shared/_Pagination.cshtml new file mode 100644 index 0000000..ac03514 --- /dev/null +++ b/src/SportsDivision.Web/Views/Shared/_Pagination.cshtml @@ -0,0 +1,82 @@ +@{ + var pg = ViewBag.Page as int? ?? 1; + var totalPages = ViewBag.TotalPages as int? ?? 0; + var totalItems = ViewBag.TotalItems as int? ?? 0; + var pageSize = ViewBag.PageSize as int? ?? 20; // 0 == All + var request = ViewContext.HttpContext.Request; + + // Preserve existing query-string filters (zoneId, level, search, etc.), swapping out + // "page" and "pageSize". + var baseParams = request.Query + .Where(kv => kv.Key != "page" && kv.Key != "pageSize") + .SelectMany(kv => kv.Value.Select(v => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(v ?? string.Empty)}")) + .ToList(); + Func url = (p, ps) => + { + var ps2 = new List(baseParams) { $"pageSize={ps}", $"page={p}" }; + return $"{request.Path}?{string.Join("&", ps2)}"; + }; + Func pageUrl = p => url(p, pageSize); + + var prevHref = pg <= 1 ? "#" : pageUrl(pg - 1); + var nextHref = pg >= totalPages ? "#" : pageUrl(pg + 1); + var start = Math.Max(1, pg - 2); + var end = Math.Min(totalPages, pg + 2); + + var firstItem = totalItems == 0 ? 0 : (pageSize <= 0 ? 1 : (pg - 1) * pageSize + 1); + var lastItem = pageSize <= 0 ? totalItems : Math.Min(pg * pageSize, totalItems); +} + +@if (totalItems > 0) +{ +
+
+ Show + + per page +
+ Showing @firstItem–@lastItem of @totalItems +
+ + @if (totalPages > 1) + { + + } +} diff --git a/src/SportsDivision.Web/Views/Student/Index.cshtml b/src/SportsDivision.Web/Views/Student/Index.cshtml index d774ccf..cb003c6 100644 --- a/src/SportsDivision.Web/Views/Student/Index.cshtml +++ b/src/SportsDivision.Web/Views/Student/Index.cshtml @@ -56,3 +56,5 @@ } + + diff --git a/src/SportsDivision.Web/Views/Tournament/Details.cshtml b/src/SportsDivision.Web/Views/Tournament/Details.cshtml index f4c68d0..957a278 100644 --- a/src/SportsDivision.Web/Views/Tournament/Details.cshtml +++ b/src/SportsDivision.Web/Views/Tournament/Details.cshtml @@ -6,12 +6,25 @@ var levels = ViewBag.Levels as IEnumerable; } +@{ + var statusClass = Model.Status switch { + TournamentStatus.Draft => "bg-secondary", + TournamentStatus.Registration => "bg-info", + TournamentStatus.InProgress => "bg-primary", + TournamentStatus.Completed => "bg-success", + _ => "bg-dark" + }; +}
-

@Model.Name

+

+ @Model.Name + @Model.Status + @if (Model.IsArchived) { Archived } +

@Model.StartDate.ToString("MMMM d") - @Model.EndDate.ToString("MMMM d, yyyy")

-
+
@if (Model.Status == TournamentStatus.Draft) {
@@ -27,16 +40,53 @@
+
+ + + +
} @if (Model.Status == TournamentStatus.InProgress) {
- + +
+
+ + + +
+ } + @if (Model.Status == TournamentStatus.Completed) + { +
+ + +
} Edit + @if (!Model.IsArchived) + { +
+ + +
+ } + else + { +
+ + +
+ }
@@ -76,7 +126,15 @@ - Scoring + @{ + var scoringController = tel.Category switch + { + EventCategory.Field => "FieldEvent", + EventCategory.HighJump => "HighJump", + _ => "TrackEvent" + }; + } + Scoring
diff --git a/src/SportsDivision.Web/Views/Tournament/Index.cshtml b/src/SportsDivision.Web/Views/Tournament/Index.cshtml index 564e7f0..4307694 100644 --- a/src/SportsDivision.Web/Views/Tournament/Index.cshtml +++ b/src/SportsDivision.Web/Views/Tournament/Index.cshtml @@ -1,6 +1,8 @@ @model IEnumerable @{ ViewData["Title"] = "Tournaments"; + var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?; + var showArchived = ViewBag.ShowArchived as bool? ?? false; }
@@ -10,6 +12,26 @@ + +
+ + +
+
+
+ + +
+
+ + @@ -45,8 +67,25 @@ }
Edit Details + @if (!t.IsArchived) + { +
+ + +
+ } + else + { +
+ + +
+ }
+ + diff --git a/src/SportsDivision.Web/Views/TrackEvent/Index.cshtml b/src/SportsDivision.Web/Views/TrackEvent/Index.cshtml index aca3e80..a3a2f0c 100644 --- a/src/SportsDivision.Web/Views/TrackEvent/Index.cshtml +++ b/src/SportsDivision.Web/Views/TrackEvent/Index.cshtml @@ -4,13 +4,29 @@ var telId = ViewBag.TournamentEventLevelId as int?; var eventName = ViewBag.EventName as string; var levelName = ViewBag.LevelName as string; + var tournamentName = ViewBag.TournamentName as string; + var regCount = ViewBag.RegistrationCount as int? ?? 0; + var standings = ViewBag.Standings as IEnumerable ?? Enumerable.Empty(); }
-

Track Event Scoring

-

@eventName - @levelName

+

@(string.IsNullOrEmpty(eventName) ? "Track Event Scoring" : $"{eventName} — {levelName}")

+

+ @if (!string.IsNullOrEmpty(tournamentName)) { @tournamentName } + @regCount registered + @Model.Count() round(s) +

+ @if (Model.Any(r => r.RoundType == RoundType.Final)) + { +
+ + +
+ }
@@ -18,31 +34,50 @@
Rounds
-
+ - + - - - + + + +
- + @foreach (var r in Model.OrderBy(r => r.RoundOrder)) { + var competitors = r.Heats.Sum(h => h.HeatLanes.Count); + var isFinal = r.RoundType == RoundType.Final; - - + + +
RoundTypeStatusHeatsAdvanceActions
RoundTypeStatusHeatsCompetitorsAdvances to next roundActions
Round @r.RoundOrder @r.RoundType @r.Status@r.Heats.Count heatsTop @(r.AdvanceTopN ?? 0) + @(r.AdvanceFastestLosers ?? 0) FL@r.Heats.Count@competitors + @if (isFinal) + { + Final — points awarded + } + else if ((r.AdvanceTopN ?? 0) > 0 || (r.AdvanceFastestLosers ?? 0) > 0) + { + + Top @(r.AdvanceTopN ?? 0)/heat + @(r.AdvanceFastestLosers ?? 0) fastest losers + + } + else + { + + } + Manage @if (r.Heats.Count == 0) @@ -59,4 +94,63 @@
+
+ +@if (standings.Any()) +{ +
+
+
Final Standings
+
+
+ + + + + + @foreach (var r in standings) + { + + + + + + + + } + +
PlaceAthleteSchoolTime (s)Points
+ @if (r.Score!.Placement <= 3) { #@r.Score.Placement } + else { @r.Score.Placement } + @r.StudentName@r.SchoolName@r.Score.RawPerformance.ToString("0.00")@r.Score.PlacementPoints
+
+
+} + +@section Scripts { + +} diff --git a/src/SportsDivision.Web/Views/TrackEvent/ManageRound.cshtml b/src/SportsDivision.Web/Views/TrackEvent/ManageRound.cshtml index d6d0108..e88c890 100644 --- a/src/SportsDivision.Web/Views/TrackEvent/ManageRound.cshtml +++ b/src/SportsDivision.Web/Views/TrackEvent/ManageRound.cshtml @@ -16,12 +16,19 @@ + @if (ViewBag.HasNextRound == true) + { +
+ + +
+ }
} - Back + Back
@@ -54,6 +61,7 @@
+ diff --git a/test-data.sql b/test-data.sql new file mode 100644 index 0000000..7e0ff69 --- /dev/null +++ b/test-data.sql @@ -0,0 +1,355 @@ +-- ============================================================================= +-- test-data.sql +-- Bulk sample data for the Dominica Sports Division app. +-- +-- Adds, for BOTH seeded tournaments (1 = Inter-Zone Championship 2026, +-- 2 = National Championship 2026): +-- * 60 students (30 male / 30 female) spread across the secondary schools +-- * Under-16 Boys & Under-16 Girls event levels for EVERY individual +-- (non-relay) event in the catalogue +-- * ~16 registrations per event level +-- * Results recorded in the form each discipline actually uses: +-- - Field events: a Score per athlete (mark -> WA points, placement, points) +-- - High jump: a per-bar attempt grid (clears / fails), then a Score +-- - Track: a preliminary Heat round (heats of <= 8) feeding an +-- 8-athlete Final; placement points are awarded ONLY from +-- the final. +-- +-- Marks are VARIED per event level (a deterministic per-registration jitter and +-- a per-level base offset) so no two competitions look identical. +-- +-- The students / event levels / registrations are inserted idempotently. The +-- scoring artifacts (Scores, heats, heights, attempts) are rebuilt on every run +-- (deterministically), so re-running reproduces the same data -- but any manual +-- scoring done on these U16 test event levels via the UI will be overwritten. +-- +-- Field / jump marks are in METRES. Relays are skipped (they need RelayTeams). +-- +-- Run with: +-- PGPASSWORD=... psql -h localhost -U postgres -d sportsdivision -f test-data.sql +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- Single source of truth: every individual event to populate, with the base +-- mark for the winner and the step between consecutive places. +-- is_track = true -> seconds, lower is better +-- is_track = false -> metres, higher is better +-- --------------------------------------------------------------------------- +CREATE TEMP TABLE _ev (ename text, base numeric, step numeric, is_track boolean) ON COMMIT DROP; +INSERT INTO _ev (ename, base, step, is_track) VALUES + -- Track (seconds) + ('80m', 10.00, 0.15, true), + ('100m', 11.50, 0.18, true), + ('150m', 17.50, 0.25, true), + ('200m', 23.50, 0.30, true), + ('300m', 38.00, 0.45, true), + ('400m', 52.00, 0.70, true), + ('800m', 125.00, 1.50, true), + ('1200m', 200.00, 2.50, true), + ('1500m', 255.00, 3.00, true), + ('3000m', 560.00, 6.00, true), + ('5000m', 980.00, 10.0, true), + ('80mH', 13.00, 0.25, true), + ('QC 60m Sprint', 7.50, 0.12, true), + -- Field (metres) + ('Long Jump', 6.40, 0.15, false), + ('Triple Jump', 13.00, 0.30, false), + ('Shot Put 3kg', 12.50, 0.40, false), + ('Shot Put 4kg', 11.50, 0.38, false), + ('Shot Put 5kg', 10.50, 0.35, false), + ('Shot Put 6kg', 9.50, 0.33, false), + ('Discus 1kg', 38.00, 1.20, false), + ('Discus 1.25kg', 34.00, 1.10, false), + ('Discus 1.5kg', 30.00, 1.00, false), + ('Discus 1.75kg', 27.00, 0.90, false), + ('Javelin 400g', 42.00, 1.50, false), + ('Javelin 500g', 48.00, 1.60, false), + ('Javelin 600g', 45.00, 1.50, false), + ('Javelin 700g', 40.00, 1.40, false), + ('Javelin 800g', 38.00, 1.30, false), + ('Throwing the Cricket Ball', 65.00, 2.00, false), + ('QC Javelin Throw', 40.00, 1.40, false), + -- High jump (metres) + ('High Jump', 1.85, 0.04, false), + ('QC High Jump Open', 1.80, 0.04, false); + +-- --------------------------------------------------------------------------- +-- 1. STUDENTS (60: TD-0001 .. TD-0060; first 30 Male, next 30 Female) +-- --------------------------------------------------------------------------- +WITH secids AS ( + SELECT array_agg("SchoolId" ORDER BY "SchoolId") AS ids, count(*)::int AS c + FROM "Schools" + WHERE "SchoolLevel" = 'Secondary' AND "IsActive" +) +INSERT INTO "Students" + ("ExistingStudentId", "FirstName", "LastName", "DateOfBirth", "Sex", "SchoolId", "IsActive") +SELECT + 'TD-' || lpad(g::text, 4, '0'), + (ARRAY['Liam','Noah','Ethan','Mason','Logan','Lucas','Jack','Aiden','Caleb','Isaac', + 'Ava','Mia','Zoe','Lily','Emma','Sofia','Maya','Chloe','Ella','Grace'])[1 + (g % 20)], + (ARRAY['Joseph','Charles','Baptiste','Pierre','Williams','John','Thomas','James','Henry','George', + 'Edwards','Francis','Lewis','Daniel','Peters','Roberts','Andrew','Paul','Mark','Joseph'])[1 + ((g * 7) % 20)], + make_date(2010 + (g % 2), 1 + (g % 12), 1 + (g % 27)), + CASE WHEN g <= 30 THEN 'Male' ELSE 'Female' END, + secids.ids[1 + (g % secids.c)], + true +FROM generate_series(1, 60) AS g +CROSS JOIN secids +WHERE NOT EXISTS ( + SELECT 1 FROM "Students" s WHERE s."ExistingStudentId" = 'TD-' || lpad(g::text, 4, '0') +); + +-- --------------------------------------------------------------------------- +-- 2. TOURNAMENT EVENT LEVELS (every _ev event x U16 Boys/Girls x 2 tournaments) +-- Age restriction waived so the U16 sample athletes are always eligible. +-- --------------------------------------------------------------------------- +WITH combos AS ( + SELECT t.tid, e."EventId" AS eid, el."EventLevelId" AS lid + FROM (VALUES (1), (2)) AS t(tid) + CROSS JOIN _ev + CROSS JOIN (VALUES ('Under 16 Boys'), ('Under 16 Girls')) AS lv(name) + JOIN "Events" e ON e."Name" = _ev.ename + JOIN "EventLevels" el ON el."Name" = lv.name +) +INSERT INTO "TournamentEventLevels" + ("TournamentId", "EventId", "EventLevelId", "AgeRestrictionWaived") +SELECT c.tid, c.eid, c.lid, true +FROM combos c +WHERE NOT EXISTS ( + SELECT 1 FROM "TournamentEventLevels" x + WHERE x."TournamentId" = c.tid AND x."EventId" = c.eid AND x."EventLevelId" = c.lid +); + +-- --------------------------------------------------------------------------- +-- 3. REGISTRATIONS (~16 sample students per event level, sex-matched) +-- --------------------------------------------------------------------------- +WITH testtels AS ( + SELECT tel."TournamentEventLevelId" AS tid, el."Sex" AS sex + FROM "TournamentEventLevels" tel + JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId" + JOIN "Events" e ON e."EventId" = tel."EventId" + WHERE tel."TournamentId" IN (1, 2) + AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls') + AND e."Name" IN (SELECT ename FROM _ev) +), +studs AS ( + SELECT "StudentId", "Sex", + row_number() OVER (PARTITION BY "Sex" ORDER BY "StudentId") AS rn + FROM "Students" + WHERE "ExistingStudentId" LIKE 'TD-%' +) +INSERT INTO "EventRegistrations" + ("TournamentEventLevelId", "StudentId", "RegisteredBy", "RegisteredAt") +SELECT t.tid, s."StudentId", 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-01 09:00:00+00' +FROM testtels t +JOIN studs s ON s."Sex" = t.sex +WHERE ((s.rn + t.tid) % 30) < 16 -- 16 of the 30 per level, varied per TEL + AND NOT EXISTS ( + SELECT 1 FROM "EventRegistrations" r + WHERE r."TournamentEventLevelId" = t.tid AND r."StudentId" = s."StudentId" + ); + +-- =========================================================================== +-- SCORING (rebuilt from scratch each run for the U16 test event levels) +-- =========================================================================== + +-- The test event levels we own (U16 Boys/Girls, both tournaments, _ev events). +CREATE TEMP TABLE _tel ON COMMIT DROP AS + SELECT tel."TournamentEventLevelId" AS tid, e."EventId" AS eid, e."Name" AS ename, e."Category" AS cat + FROM "TournamentEventLevels" tel + JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId" + JOIN "Events" e ON e."EventId" = tel."EventId" + WHERE tel."TournamentId" IN (1, 2) + AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls') + AND e."Name" IN (SELECT ename FROM _ev); + +-- Clear any existing scoring artifacts for those event levels (FK-safe order). +DELETE FROM "HeatLanes" WHERE "HeatId" IN ( + SELECT h."HeatId" FROM "Heats" h JOIN "Rounds" r ON r."RoundId" = h."RoundId" + WHERE r."TournamentEventLevelId" IN (SELECT tid FROM _tel)); +DELETE FROM "Heats" WHERE "RoundId" IN ( + SELECT "RoundId" FROM "Rounds" WHERE "TournamentEventLevelId" IN (SELECT tid FROM _tel)); +DELETE FROM "Rounds" WHERE "TournamentEventLevelId" IN (SELECT tid FROM _tel); +DELETE FROM "HighJumpAttempts" WHERE "HighJumpHeightId" IN ( + SELECT "HighJumpHeightId" FROM "HighJumpHeights" WHERE "TournamentEventLevelId" IN (SELECT tid FROM _tel)); +DELETE FROM "HighJumpHeights" WHERE "TournamentEventLevelId" IN (SELECT tid FROM _tel); +DELETE FROM "Scores" WHERE "EventRegistrationId" IN ( + SELECT er."EventRegistrationId" FROM "EventRegistrations" er WHERE er."TournamentEventLevelId" IN (SELECT tid FROM _tel)); + +-- Per-athlete performance, VARIED: a per-level base offset (+/-6% by tid) and a +-- deterministic per-registration jitter shuffle finishing order and marks, so +-- different athletes win different events and no two competitions are identical. +CREATE TEMP TABLE _perf ON COMMIT DROP AS +WITH b AS ( + SELECT er."EventRegistrationId" AS erid, t.tid, t.eid, t.ename, t.cat, + p.base, p.step, p.is_track, + (p.base * (1 + (((t.tid % 7) - 3) * 0.02))) AS base_v, + ((er."EventRegistrationId"::bigint * 48271) % 100000) AS seed, + ((((er."EventRegistrationId"::bigint * 1103515245 + 12345) % 1000)::numeric / 1000.0) - 0.5) AS jit + FROM _tel t + JOIN "EventRegistrations" er ON er."TournamentEventLevelId" = t.tid AND er."StudentId" IS NOT NULL + JOIN _ev p ON p.ename = t.ename +), +o AS (SELECT *, row_number() OVER (PARTITION BY tid ORDER BY seed) AS pos0 FROM b), +m AS ( + SELECT *, + CASE WHEN is_track THEN base_v + step * (pos0 - 1) + jit * step + ELSE base_v - step * (pos0 - 1) + jit * step END AS raw0 + FROM o +) +SELECT erid, tid, eid, ename, cat, is_track, jit, + round(raw0::numeric, 2) AS raw, + row_number() OVER (PARTITION BY tid ORDER BY CASE WHEN is_track THEN raw0 ELSE -raw0 END) AS pos, + count(*) OVER (PARTITION BY tid) AS n +FROM m; + +-- --------------------------------------------------------------------------- +-- 4a. FIELD events (single competition; everyone placed, top 8 score points) +-- --------------------------------------------------------------------------- +INSERT INTO "Scores" + ("EventRegistrationId", "RawPerformance", "CalculatedPoints", "Placement", "PlacementPoints", "RecordedBy", "RecordedAt") +SELECT pf.erid, pf.raw, + COALESCE(CASE WHEN sc."A" IS NOT NULL AND (pf.raw - sc."B") > 0 + THEN floor(sc."A" * power(pf.raw - sc."B", sc."C"))::int ELSE 0 END, 0), + pf.pos, COALESCE(ppc."Points", 0), + 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-02 14:00:00+00' +FROM _perf pf +LEFT JOIN "ScoringConstants" sc ON sc."EventId" = pf.eid +LEFT JOIN "PlacementPointConfigs" ppc ON ppc."Placement" = pf.pos +WHERE pf.cat = 'Field' AND pf.raw > 0; + +-- --------------------------------------------------------------------------- +-- 4b. HIGH JUMP (per-level bar ladder, jittered clearances with ties) +-- --------------------------------------------------------------------------- +CREATE TEMP TABLE _hj ON COMMIT DROP AS +SELECT pf.erid, pf.tid, pf.eid, pf.pos, + (1.30 + (pf.tid % 6) * 0.05)::numeric AS start_h, + GREATEST(0, LEAST(7, + (round((pf.n - pf.pos)::numeric / pf.n * 7) + round(pf.jit * 2))::int)) AS cidx +FROM _perf pf +WHERE pf.cat = 'HighJump'; + +-- bar ladder: 8 bars at 0.05 spacing from the per-level start height +INSERT INTO "HighJumpHeights" ("TournamentEventLevelId", "Height", "SortOrder") +SELECT s.tid, round((s.start_h + 0.05 * k)::numeric, 2), k + 1 +FROM (SELECT DISTINCT tid, start_h FROM _hj) s +CROSS JOIN generate_series(0, 7) AS k; + +-- attempts: clear every bar up to the athlete's height, fail (XXX) the next one +INSERT INTO "HighJumpAttempts" ("HighJumpHeightId", "EventRegistrationId", "Attempt1", "Attempt2", "Attempt3") +SELECT hh."HighJumpHeightId", a.erid, + CASE WHEN hh."Height" <= a.cleared THEN 'Clear' ELSE 'Fail' END, + CASE WHEN hh."Height" > a.cleared THEN 'Fail' END, + CASE WHEN hh."Height" > a.cleared THEN 'Fail' END +FROM (SELECT erid, tid, round((start_h + 0.05 * cidx)::numeric, 2) AS cleared FROM _hj) a +JOIN "HighJumpHeights" hh + ON hh."TournamentEventLevelId" = a.tid + AND hh."Height" <= a.cleared + 0.05; + +-- high jump scores: rank by cleared height (ties broken by finishing order) +INSERT INTO "Scores" + ("EventRegistrationId", "RawPerformance", "CalculatedPoints", "Placement", "PlacementPoints", "RecordedBy", "RecordedAt") +SELECT z.erid, z.cleared, + COALESCE(CASE WHEN sc."A" IS NOT NULL AND (z.cleared - sc."B") > 0 + THEN floor(sc."A" * power(z.cleared - sc."B", sc."C"))::int ELSE 0 END, 0), + z.place, COALESCE(ppc."Points", 0), + 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-02 14:00:00+00' +FROM ( + SELECT h.erid, h.eid, round((h.start_h + 0.05 * h.cidx)::numeric, 2) AS cleared, + row_number() OVER (PARTITION BY h.tid ORDER BY (h.start_h + 0.05 * h.cidx) DESC, h.pos ASC) AS place + FROM _hj h +) z +LEFT JOIN "ScoringConstants" sc ON sc."EventId" = z.eid +LEFT JOIN "PlacementPointConfigs" ppc ON ppc."Placement" = z.place; + +-- --------------------------------------------------------------------------- +-- 4c. TRACK (prelim Heat round of <=8-lane heats -> 8-athlete Final) +-- Placement points are awarded only from the Final round. +-- --------------------------------------------------------------------------- +CREATE TEMP TABLE _trk ON COMMIT DROP AS +SELECT pf.erid, pf.tid, pf.eid, pf.pos, pf.n, + pf.raw AS prelim_time, + round((pf.raw * 0.985)::numeric, 2) AS final_time, + ceil(pf.n / 8.0)::int AS heats +FROM _perf pf +WHERE pf.cat = 'Track'; + +-- prelim round (Top 3 per heat + 2 fastest losers advance to the final) +INSERT INTO "Rounds" ("TournamentEventLevelId", "RoundType", "RoundOrder", "AdvanceTopN", "AdvanceFastestLosers", "Status") +SELECT DISTINCT tid, 'Heat', 1, 3, 2, 'Completed' FROM _trk; +-- final round +INSERT INTO "Rounds" ("TournamentEventLevelId", "RoundType", "RoundOrder", "AdvanceTopN", "AdvanceFastestLosers", "Status") +SELECT DISTINCT tid, 'Final', 2, NULL::int, NULL::int, 'Completed' FROM _trk; + +-- prelim heats: ceil(n/8) heats so no heat exceeds 8 lanes +INSERT INTO "Heats" ("RoundId", "HeatNumber", "Status") +SELECT r."RoundId", hn.n, 'Completed' +FROM "Rounds" r +JOIN (SELECT DISTINCT tid, heats FROM _trk) d ON d.tid = r."TournamentEventLevelId" +CROSS JOIN LATERAL generate_series(1, d.heats) AS hn(n) +WHERE r."RoundOrder" = 1; +-- final heat (single heat of <= 8) +INSERT INTO "Heats" ("RoundId", "HeatNumber", "Status") +SELECT r."RoundId", 1, 'Completed' +FROM "Rounds" r +JOIN (SELECT DISTINCT tid FROM _trk) d ON d.tid = r."TournamentEventLevelId" +WHERE r."RoundOrder" = 2; + +-- prelim lanes: round-robin athletes into the heats; fastest 8 overall advance +INSERT INTO "HeatLanes" + ("HeatId", "EventRegistrationId", "LaneNumber", "Time", "IsAdvanced", "AdvanceReason", + "IsDNS", "IsDNF", "IsDQ", "RecordedBy", "RecordedAt") +SELECT h."HeatId", t.erid, + row_number() OVER (PARTITION BY h."HeatId" ORDER BY t.prelim_time), + t.prelim_time, + (t.pos <= 8), + CASE WHEN t.pos <= 6 THEN 'TopN' WHEN t.pos <= 8 THEN 'FastestLoser' ELSE NULL END, + false, false, false, 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-02 14:00:00+00' +FROM _trk t +JOIN "Rounds" r ON r."TournamentEventLevelId" = t.tid AND r."RoundOrder" = 1 +JOIN "Heats" h ON h."RoundId" = r."RoundId" AND h."HeatNumber" = ((t.pos - 1) % t.heats) + 1; + +-- final lanes: the 8 finalists, lane = final placement +INSERT INTO "HeatLanes" + ("HeatId", "EventRegistrationId", "LaneNumber", "Time", "IsAdvanced", "AdvanceReason", + "IsDNS", "IsDNF", "IsDQ", "RecordedBy", "RecordedAt") +SELECT h."HeatId", t.erid, t.pos, t.final_time, false, NULL, + false, false, false, 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-02 14:00:00+00' +FROM _trk t +JOIN "Rounds" r ON r."TournamentEventLevelId" = t.tid AND r."RoundOrder" = 2 +JOIN "Heats" h ON h."RoundId" = r."RoundId" +WHERE t.pos <= 8; + +-- track scores: ONLY the 8 finalists, placement & points from the final +INSERT INTO "Scores" + ("EventRegistrationId", "RawPerformance", "CalculatedPoints", "Placement", "PlacementPoints", "RecordedBy", "RecordedAt") +SELECT t.erid, t.final_time, + COALESCE(CASE WHEN sc."A" IS NOT NULL AND (sc."B" - t.final_time) > 0 + THEN floor(sc."A" * power(sc."B" - t.final_time, sc."C"))::int ELSE 0 END, 0), + t.pos, COALESCE(ppc."Points", 0), + 'seed@sportsdivision.dm', TIMESTAMP WITH TIME ZONE '2026-06-02 14:00:00+00' +FROM _trk t +LEFT JOIN "ScoringConstants" sc ON sc."EventId" = t.eid +LEFT JOIN "PlacementPointConfigs" ppc ON ppc."Placement" = t.pos +WHERE t.pos <= 8; + +COMMIT; + +-- --------------------------------------------------------------------------- +-- Summary (informational; safe to run repeatedly) +-- --------------------------------------------------------------------------- +SELECT 'students (TD-)' AS metric, count(*) AS value FROM "Students" WHERE "ExistingStudentId" LIKE 'TD-%' +UNION ALL SELECT 'event levels (t1)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 1 +UNION ALL SELECT 'event levels (t2)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 2 +UNION ALL SELECT 'registrations (total)', count(*) FROM "EventRegistrations" +UNION ALL SELECT 'scores (total)', count(*) FROM "Scores" +UNION ALL SELECT 'track heat lanes', count(*) FROM "HeatLanes" +UNION ALL SELECT 'high jump heights', count(*) FROM "HighJumpHeights" +UNION ALL SELECT 'high jump attempts', count(*) FROM "HighJumpAttempts" +UNION ALL SELECT 'individual events scored', + count(DISTINCT tel."EventId") + FROM "Scores" s + JOIN "EventRegistrations" er ON er."EventRegistrationId" = s."EventRegistrationId" + JOIN "TournamentEventLevels" tel ON tel."TournamentEventLevelId" = er."TournamentEventLevelId";