From ddb2d58e2f2388029f9dffcdfd308eeaeefadd81 Mon Sep 17 00:00:00 2001 From: warringtond Date: Sat, 27 Jun 2026 13:14:39 -0400 Subject: [PATCH] many bug fixes --- .../Interfaces/IHighJumpService.cs | 2 +- .../Services/HighJumpService.cs | 74 ++++++++++++++++--- .../Services/TournamentService.cs | 2 + .../Controllers/EventController.cs | 5 +- .../Controllers/HighJumpController.cs | 6 +- .../Controllers/TournamentController.cs | 6 +- .../Views/Event/Index.cshtml | 2 - .../Views/HighJump/Index.cshtml | 44 +++++++++++ .../Views/Shared/_Notification.cshtml | 11 ++- .../Views/Tournament/Details.cshtml | 16 ++-- .../Views/Tournament/Index.cshtml | 39 +++++++--- test-data.sql | 5 +- 12 files changed, 170 insertions(+), 42 deletions(-) diff --git a/src/SportsDivision.Application/Interfaces/IHighJumpService.cs b/src/SportsDivision.Application/Interfaces/IHighJumpService.cs index 7bfbe17..c975713 100644 --- a/src/SportsDivision.Application/Interfaces/IHighJumpService.cs +++ b/src/SportsDivision.Application/Interfaces/IHighJumpService.cs @@ -1,3 +1,3 @@ using SportsDivision.Application.DTOs; namespace SportsDivision.Application.Interfaces; -public interface IHighJumpService { Task> GetHeightsAsync(int tournamentEventLevelId); Task AddHeightAsync(HighJumpHeightCreateDto dto); Task RemoveHeightAsync(int heightId); Task RecordAttemptAsync(HighJumpAttemptUpdateDto dto); Task IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId); Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy); } +public interface IHighJumpService { Task> GetHeightsAsync(int tournamentEventLevelId); Task AddHeightAsync(HighJumpHeightCreateDto dto); Task RemoveHeightAsync(int heightId); Task RecordAttemptAsync(HighJumpAttemptUpdateDto dto); Task IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId); Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy); } diff --git a/src/SportsDivision.Application/Services/HighJumpService.cs b/src/SportsDivision.Application/Services/HighJumpService.cs index d11c508..087f719 100644 --- a/src/SportsDivision.Application/Services/HighJumpService.cs +++ b/src/SportsDivision.Application/Services/HighJumpService.cs @@ -103,7 +103,7 @@ public class HighJumpService : IHighJumpService return false; } - public async Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy) + public async Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy) { var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId)) .OrderBy(h => h.SortOrder).ToList(); @@ -139,34 +139,74 @@ public class HighJumpService : IHighJumpService results.Add((reg.EventRegistrationId, highestCleared, totalFails, failsAtHighest)); } - // Sort: highest cleared DESC, then fewest fails at that height, then fewest total fails + // Rank by World Athletics high-jump countback: + // 1. highest bar cleared (desc) + // 2. fewest misses at that bar + // 3. fewest total misses in the competition var sorted = results .OrderByDescending(r => r.HighestCleared) .ThenBy(r => r.FailsAtHighest) .ThenBy(r => r.TotalFails) .ToList(); - // 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++) + // Assign placements with proper tie handling. Athletes identical on all three + // countback keys are a genuine tie: they share one placement (standard competition + // ranking, e.g. 1, 2, 3, 3, 5) and share placement points — the points for the + // positions the tie-group occupies are pooled and divided equally. + static bool SameRank( + (int RegId, decimal HighestCleared, int TotalFails, int FailsAtHighest) a, + (int RegId, decimal HighestCleared, int TotalFails, int FailsAtHighest) b) + => a.HighestCleared == b.HighestCleared + && a.FailsAtHighest == b.FailsAtHighest + && a.TotalFails == b.TotalFails; + + var placeByIndex = new int[sorted.Count]; + var pointsByIndex = new int[sorted.Count]; + var firstPlaceTie = new List(); // RegIds sharing 1st place + + int i = 0; + while (i < sorted.Count) { - int place = i + 1; - decimal cleared = sorted[i].HighestCleared; + int start = i; + int place = start + 1; + while (i + 1 < sorted.Count && SameRank(sorted[i + 1], sorted[start])) i++; + int size = i - start + 1; + + // Pool the points for the positions this tie-group occupies, then split equally. + int pooled = 0; + for (int p = place; p < place + size; p++) + pooled += placementPoints.TryGetValue(p, out var pp) ? pp : 0; + int shared = (int)Math.Round((double)pooled / size, MidpointRounding.AwayFromZero); + + for (int k = start; k <= i; k++) + { + placeByIndex[k] = place; + pointsByIndex[k] = shared; + } + if (place == 1 && size > 1) + firstPlaceTie.AddRange(sorted.GetRange(start, size).Select(r => r.RegId)); + i++; + } + + for (int j = 0; j < sorted.Count; j++) + { + decimal cleared = sorted[j].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); + var score = await _uow.Scores.GetByRegistrationAsync(sorted[j].RegId); var isNew = score == null; - score ??= new Score { EventRegistrationId = sorted[i].RegId }; + score ??= new Score { EventRegistrationId = sorted[j].RegId }; score.RawPerformance = cleared; score.CalculatedPoints = calc; - score.Placement = place; - score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0; + score.Placement = placeByIndex[j]; + score.PlacementPoints = pointsByIndex[j]; score.RecordedBy = recordedBy; score.RecordedAt = DateTime.UtcNow; @@ -174,5 +214,19 @@ public class HighJumpService : IHighJumpService else _uow.Scores.Update(score); } await _uow.SaveChangesAsync(); + + // A tie for 1st cannot be settled by countback — World Athletics requires a jump-off. + // Flag it so an official can arrange one; the tied athletes currently share 1st place. + if (firstPlaceTie.Count > 1) + { + var names = tel.Registrations + .Where(r => firstPlaceTie.Contains(r.EventRegistrationId)) + .Select(r => r.Student?.FullName ?? "Unknown") + .OrderBy(n => n); + return $"Tie for 1st place between {string.Join(" and ", names)}. " + + "Countback cannot separate them — a jump-off is required to decide the winner. " + + "They currently share 1st place."; + } + return null; } } diff --git a/src/SportsDivision.Application/Services/TournamentService.cs b/src/SportsDivision.Application/Services/TournamentService.cs index a96eec5..58cb476 100644 --- a/src/SportsDivision.Application/Services/TournamentService.cs +++ b/src/SportsDivision.Application/Services/TournamentService.cs @@ -62,6 +62,8 @@ public class TournamentService : ITournamentService { var tournament = await _uow.Tournaments.GetByIdAsync(id) ?? throw new KeyNotFoundException("Tournament not found."); + if (tournament.Status != TournamentStatus.Completed) + throw new InvalidOperationException("Only tournaments marked as Completed can be archived."); tournament.IsArchived = true; _uow.Tournaments.Update(tournament); await _uow.SaveChangesAsync(); diff --git a/src/SportsDivision.Web/Controllers/EventController.cs b/src/SportsDivision.Web/Controllers/EventController.cs index 67272a8..2c5fbaa 100644 --- a/src/SportsDivision.Web/Controllers/EventController.cs +++ b/src/SportsDivision.Web/Controllers/EventController.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using SportsDivision.Application.DTOs; using SportsDivision.Application.Interfaces; using SportsDivision.Domain.Enums; -using SportsDivision.Web.Helpers; namespace SportsDivision.Web.Controllers; @@ -17,7 +16,7 @@ public class EventController : Controller _eventService = eventService; } - public async Task Index(EventCategory? category, int page = 1, int pageSize = PaginationHelper.PageSize) + public async Task Index(EventCategory? category) { IEnumerable events; @@ -32,7 +31,7 @@ public class EventController : Controller ViewBag.SelectedCategory = category; - return View(this.Page(events, page, pageSize)); + return View(events); } [HttpGet] diff --git a/src/SportsDivision.Web/Controllers/HighJumpController.cs b/src/SportsDivision.Web/Controllers/HighJumpController.cs index f495758..bc677cb 100644 --- a/src/SportsDivision.Web/Controllers/HighJumpController.cs +++ b/src/SportsDivision.Web/Controllers/HighJumpController.cs @@ -77,7 +77,11 @@ public class HighJumpController : Controller public async Task CalculateResults(int tournamentEventLevelId) { var recordedBy = User.Identity?.Name ?? "Unknown"; - await _highJumpService.CalculateResultsAsync(tournamentEventLevelId, recordedBy); + var warning = await _highJumpService.CalculateResultsAsync(tournamentEventLevelId, recordedBy); + if (warning != null) + TempData["WarningMessage"] = warning; + else + TempData["SuccessMessage"] = "High jump results calculated."; return RedirectToAction(nameof(Index), new { tournamentEventLevelId }); } } diff --git a/src/SportsDivision.Web/Controllers/TournamentController.cs b/src/SportsDivision.Web/Controllers/TournamentController.cs index ae91c7d..1f0ecc6 100644 --- a/src/SportsDivision.Web/Controllers/TournamentController.cs +++ b/src/SportsDivision.Web/Controllers/TournamentController.cs @@ -21,7 +21,10 @@ public class TournamentController : Controller public async Task Index(TournamentStatus? status, bool showArchived = false, int page = 1, int pageSize = PaginationHelper.PageSize) { - var tournaments = await _tournamentService.GetAllAsync(includeArchived: showArchived); + var all = await _tournamentService.GetAllAsync(includeArchived: true); + var archivedCount = all.Count(t => t.IsArchived); + + var tournaments = showArchived ? all : all.Where(t => !t.IsArchived); if (status.HasValue) { @@ -30,6 +33,7 @@ public class TournamentController : Controller ViewBag.SelectedStatus = status; ViewBag.ShowArchived = showArchived; + ViewBag.ArchivedCount = archivedCount; return View(this.Page(tournaments, page, pageSize)); } diff --git a/src/SportsDivision.Web/Views/Event/Index.cshtml b/src/SportsDivision.Web/Views/Event/Index.cshtml index aabc9b0..f3db9bc 100644 --- a/src/SportsDivision.Web/Views/Event/Index.cshtml +++ b/src/SportsDivision.Web/Views/Event/Index.cshtml @@ -35,5 +35,3 @@ } - - diff --git a/src/SportsDivision.Web/Views/HighJump/Index.cshtml b/src/SportsDivision.Web/Views/HighJump/Index.cshtml index fe77624..20109d3 100644 --- a/src/SportsDivision.Web/Views/HighJump/Index.cshtml +++ b/src/SportsDivision.Web/Views/HighJump/Index.cshtml @@ -5,6 +5,9 @@ var eventName = ViewBag.EventName as string; var levelName = ViewBag.LevelName as string; var registrations = ViewBag.Registrations as IEnumerable; + var results = registrations?.Where(r => r.Score != null && r.Score.Placement != null) + .OrderBy(r => r.Score!.Placement) + .ToList() ?? new List(); }
@@ -95,3 +98,44 @@
+ +
+
+
Results
+ Best cleared height ranks athletes; points use the World Athletics formula. +
+
+ @if (results.Any()) + { + + + + + + + + + + + + + @foreach (var r in results) + { + + + + + + + + + } + +
PlaceStudentSchoolBest HeightPointsPlacement Pts
#@r.Score!.Placement@r.StudentName@r.SchoolName@r.Score.RawPerformance.ToString("0.00")m@r.Score.CalculatedPoints@r.Score.PlacementPoints
+ } + else + { +

No results yet. Record clearances above, then click Calculate Results to rank athletes and award points.

+ } +
+
diff --git a/src/SportsDivision.Web/Views/Shared/_Notification.cshtml b/src/SportsDivision.Web/Views/Shared/_Notification.cshtml index 0165ce5..42736ef 100644 --- a/src/SportsDivision.Web/Views/Shared/_Notification.cshtml +++ b/src/SportsDivision.Web/Views/Shared/_Notification.cshtml @@ -14,10 +14,19 @@ } +@if (TempData["WarningMessage"] != null) +{ + +} +