many bug fixes

This commit is contained in:
2026-06-27 13:14:39 -04:00
parent 78dbbc2051
commit ddb2d58e2f
12 changed files with 170 additions and 42 deletions

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces; namespace SportsDivision.Application.Interfaces;
public interface IHighJumpService { Task<IEnumerable<HighJumpHeightDto>> GetHeightsAsync(int tournamentEventLevelId); Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto); Task RemoveHeightAsync(int heightId); Task RecordAttemptAsync(HighJumpAttemptUpdateDto dto); Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId); Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy); } public interface IHighJumpService { Task<IEnumerable<HighJumpHeightDto>> GetHeightsAsync(int tournamentEventLevelId); Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto); Task RemoveHeightAsync(int heightId); Task RecordAttemptAsync(HighJumpAttemptUpdateDto dto); Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId); Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy); }

View File

@@ -103,7 +103,7 @@ public class HighJumpService : IHighJumpService
return false; return false;
} }
public async Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy) public async Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy)
{ {
var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId)) var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId))
.OrderBy(h => h.SortOrder).ToList(); .OrderBy(h => h.SortOrder).ToList();
@@ -139,34 +139,74 @@ public class HighJumpService : IHighJumpService
results.Add((reg.EventRegistrationId, highestCleared, totalFails, failsAtHighest)); 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 var sorted = results
.OrderByDescending(r => r.HighestCleared) .OrderByDescending(r => r.HighestCleared)
.ThenBy(r => r.FailsAtHighest) .ThenBy(r => r.FailsAtHighest)
.ThenBy(r => r.TotalFails) .ThenBy(r => r.TotalFails)
.ToList(); .ToList();
// Record scores: cleared height, placement, placement points, and World-Athletics points.
var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync()) var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
.ToDictionary(p => p.Placement, p => p.Points); .ToDictionary(p => p.Placement, p => p.Points);
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId); 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<int>(); // RegIds sharing 1st place
int i = 0;
while (i < sorted.Count)
{ {
int place = i + 1; int start = i;
decimal cleared = sorted[i].HighestCleared; 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 calc = (constant != null && cleared > constant.B)
? (int)Math.Floor((double)constant.A * Math.Pow((double)(cleared - constant.B), (double)constant.C)) ? (int)Math.Floor((double)constant.A * Math.Pow((double)(cleared - constant.B), (double)constant.C))
: 0; : 0;
var score = await _uow.Scores.GetByRegistrationAsync(sorted[i].RegId); var score = await _uow.Scores.GetByRegistrationAsync(sorted[j].RegId);
var isNew = score == null; var isNew = score == null;
score ??= new Score { EventRegistrationId = sorted[i].RegId }; score ??= new Score { EventRegistrationId = sorted[j].RegId };
score.RawPerformance = cleared; score.RawPerformance = cleared;
score.CalculatedPoints = calc; score.CalculatedPoints = calc;
score.Placement = place; score.Placement = placeByIndex[j];
score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0; score.PlacementPoints = pointsByIndex[j];
score.RecordedBy = recordedBy; score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow; score.RecordedAt = DateTime.UtcNow;
@@ -174,5 +214,19 @@ public class HighJumpService : IHighJumpService
else _uow.Scores.Update(score); else _uow.Scores.Update(score);
} }
await _uow.SaveChangesAsync(); 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;
} }
} }

View File

@@ -62,6 +62,8 @@ public class TournamentService : ITournamentService
{ {
var tournament = await _uow.Tournaments.GetByIdAsync(id) var tournament = await _uow.Tournaments.GetByIdAsync(id)
?? throw new KeyNotFoundException("Tournament not found."); ?? 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; tournament.IsArchived = true;
_uow.Tournaments.Update(tournament); _uow.Tournaments.Update(tournament);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();

View File

@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces; using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums; using SportsDivision.Domain.Enums;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers; namespace SportsDivision.Web.Controllers;
@@ -17,7 +16,7 @@ public class EventController : Controller
_eventService = eventService; _eventService = eventService;
} }
public async Task<IActionResult> Index(EventCategory? category, int page = 1, int pageSize = PaginationHelper.PageSize) public async Task<IActionResult> Index(EventCategory? category)
{ {
IEnumerable<EventDto> events; IEnumerable<EventDto> events;
@@ -32,7 +31,7 @@ public class EventController : Controller
ViewBag.SelectedCategory = category; ViewBag.SelectedCategory = category;
return View(this.Page(events, page, pageSize)); return View(events);
} }
[HttpGet] [HttpGet]

View File

@@ -77,7 +77,11 @@ public class HighJumpController : Controller
public async Task<IActionResult> CalculateResults(int tournamentEventLevelId) public async Task<IActionResult> CalculateResults(int tournamentEventLevelId)
{ {
var recordedBy = User.Identity?.Name ?? "Unknown"; 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 }); return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
} }
} }

View File

@@ -21,7 +21,10 @@ public class TournamentController : Controller
public async Task<IActionResult> Index(TournamentStatus? status, bool showArchived = false, int page = 1, int pageSize = PaginationHelper.PageSize) public async Task<IActionResult> 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) if (status.HasValue)
{ {
@@ -30,6 +33,7 @@ public class TournamentController : Controller
ViewBag.SelectedStatus = status; ViewBag.SelectedStatus = status;
ViewBag.ShowArchived = showArchived; ViewBag.ShowArchived = showArchived;
ViewBag.ArchivedCount = archivedCount;
return View(this.Page(tournaments, page, pageSize)); return View(this.Page(tournaments, page, pageSize));
} }

View File

@@ -35,5 +35,3 @@
</div> </div>
} }
</div> </div>
<partial name="_Pagination" />

View File

@@ -5,6 +5,9 @@
var eventName = ViewBag.EventName as string; var eventName = ViewBag.EventName as string;
var levelName = ViewBag.LevelName as string; var levelName = ViewBag.LevelName as string;
var registrations = ViewBag.Registrations as IEnumerable<EventRegistrationDto>; var registrations = ViewBag.Registrations as IEnumerable<EventRegistrationDto>;
var results = registrations?.Where(r => r.Score != null && r.Score.Placement != null)
.OrderBy(r => r.Score!.Placement)
.ToList() ?? new List<EventRegistrationDto>();
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -95,3 +98,44 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card shadow-sm mt-4">
<div class="card-header d-flex justify-content-between align-items-center" style="background-color:#003366; color:white;">
<h5 class="mb-0">Results</h5>
<span class="small">Best cleared height ranks athletes; points use the World Athletics formula.</span>
</div>
<div class="card-body p-0">
@if (results.Any())
{
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Place</th>
<th>Student</th>
<th>School</th>
<th>Best Height</th>
<th>Points</th>
<th>Placement Pts</th>
</tr>
</thead>
<tbody>
@foreach (var r in results)
{
<tr>
<td><span class="badge bg-warning text-dark">#@r.Score!.Placement</span></td>
<td>@r.StudentName</td>
<td>@r.SchoolName</td>
<td>@r.Score.RawPerformance.ToString("0.00")m</td>
<td>@r.Score.CalculatedPoints</td>
<td>@r.Score.PlacementPoints</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted p-3 mb-0">No results yet. Record clearances above, then click <strong>Calculate Results</strong> to rank athletes and award points.</p>
}
</div>
</div>

View File

@@ -14,10 +14,19 @@
</div> </div>
} }
@if (TempData["WarningMessage"] != null)
{
<div class="alert alert-warning alert-dismissible fade show" role="alert" data-no-autodismiss="true">
@TempData["WarningMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
setTimeout(function () { setTimeout(function () {
var alerts = document.querySelectorAll('.alert'); // Auto-dismiss transient alerts, but keep ones that need the user's attention.
var alerts = document.querySelectorAll('.alert:not([data-no-autodismiss])');
alerts.forEach(function (alert) { alerts.forEach(function (alert) {
var bsAlert = new bootstrap.Alert(alert); var bsAlert = new bootstrap.Alert(alert);
bsAlert.close(); bsAlert.close();

View File

@@ -72,7 +72,14 @@
</form> </form>
} }
<a asp-action="Edit" asp-route-id="@Model.TournamentId" class="btn btn-outline-primary">Edit</a> <a asp-action="Edit" asp-route-id="@Model.TournamentId" class="btn btn-outline-primary">Edit</a>
@if (!Model.IsArchived) @if (Model.IsArchived)
{
<form asp-action="Unarchive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<button type="submit" class="btn btn-outline-dark">Unarchive</button>
</form>
}
else if (Model.Status == TournamentStatus.Completed)
{ {
<form asp-action="Archive" method="post"> <form asp-action="Archive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" /> <input type="hidden" name="id" value="@Model.TournamentId" />
@@ -80,13 +87,6 @@
onclick="return confirm('Archive this tournament? It will be hidden from the default list but not deleted, and can be restored later.')">Archive</button> onclick="return confirm('Archive this tournament? It will be hidden from the default list but not deleted, and can be restored later.')">Archive</button>
</form> </form>
} }
else
{
<form asp-action="Unarchive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<button type="submit" class="btn btn-outline-dark">Unarchive</button>
</form>
}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
ViewData["Title"] = "Tournaments"; ViewData["Title"] = "Tournaments";
var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?; var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?;
var showArchived = ViewBag.ShowArchived as bool? ?? false; var showArchived = ViewBag.ShowArchived as bool? ?? false;
var archivedCount = ViewBag.ArchivedCount as int? ?? 0;
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -24,14 +25,30 @@
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="form-check"> <label class="form-label mb-0 small text-muted d-block">Archived</label>
<input class="form-check-input" type="checkbox" name="showArchived" value="true" <div class="form-check form-switch d-inline-flex align-items-center gap-2 border rounded px-2 py-1 @(showArchived ? "border-primary bg-light" : (archivedCount > 0 ? "border-warning" : ""))" style="min-height:31px">
<input class="form-check-input m-0" type="checkbox" role="switch" name="showArchived" value="true"
id="showArchived" @(showArchived ? "checked" : "") onchange="this.form.submit()" /> id="showArchived" @(showArchived ? "checked" : "") onchange="this.form.submit()" />
<label class="form-check-label" for="showArchived">Show archived</label> <label class="form-check-label fw-semibold" for="showArchived" style="cursor:pointer">
Show archived
@if (archivedCount > 0)
{
<span class="badge rounded-pill bg-warning text-dark" title="@archivedCount archived tournament(s) hidden">@archivedCount</span>
}
</label>
</div> </div>
</div> </div>
</form> </form>
@if (!showArchived && archivedCount > 0)
{
<p class="text-muted small mb-3">
<i class="bi bi-archive"></i>
@archivedCount archived tournament@(archivedCount == 1 ? "" : "s") @(archivedCount == 1 ? "is" : "are") hidden.
Flip <strong>Show archived</strong> above to view @(archivedCount == 1 ? "it" : "them").
</p>
}
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
@@ -67,7 +84,14 @@
<td> <td>
<a asp-action="Edit" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-primary">Edit</a> <a asp-action="Edit" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-primary">Edit</a>
<a asp-action="Details" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-info">Details</a> <a asp-action="Details" asp-route-id="@t.TournamentId" class="btn btn-sm btn-outline-info">Details</a>
@if (!t.IsArchived) @if (t.IsArchived)
{
<form asp-action="Unarchive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" />
<button type="submit" class="btn btn-sm btn-outline-dark">Unarchive</button>
</form>
}
else if (t.Status == TournamentStatus.Completed)
{ {
<form asp-action="Archive" method="post" class="d-inline"> <form asp-action="Archive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" /> <input type="hidden" name="id" value="@t.TournamentId" />
@@ -75,13 +99,6 @@
onclick="return confirm('Archive &quot;@t.Name&quot;? It will be hidden from the default list but can be restored.')">Archive</button> onclick="return confirm('Archive &quot;@t.Name&quot;? It will be hidden from the default list but can be restored.')">Archive</button>
</form> </form>
} }
else
{
<form asp-action="Unarchive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" />
<button type="submit" class="btn btn-sm btn-outline-dark">Unarchive</button>
</form>
}
</td> </td>
</tr> </tr>
} }

View File

@@ -52,7 +52,6 @@ INSERT INTO _ev (ename, base, step, is_track) VALUES
('3000m', 560.00, 6.00, true), ('3000m', 560.00, 6.00, true),
('5000m', 980.00, 10.0, true), ('5000m', 980.00, 10.0, true),
('80mH', 13.00, 0.25, true), ('80mH', 13.00, 0.25, true),
('QC 60m Sprint', 7.50, 0.12, true),
-- Field (metres) -- Field (metres)
('Long Jump', 6.40, 0.15, false), ('Long Jump', 6.40, 0.15, false),
('Triple Jump', 13.00, 0.30, false), ('Triple Jump', 13.00, 0.30, false),
@@ -70,10 +69,8 @@ INSERT INTO _ev (ename, base, step, is_track) VALUES
('Javelin 700g', 40.00, 1.40, false), ('Javelin 700g', 40.00, 1.40, false),
('Javelin 800g', 38.00, 1.30, false), ('Javelin 800g', 38.00, 1.30, false),
('Throwing the Cricket Ball', 65.00, 2.00, false), ('Throwing the Cricket Ball', 65.00, 2.00, false),
('QC Javelin Throw', 40.00, 1.40, false),
-- High jump (metres) -- High jump (metres)
('High Jump', 1.85, 0.04, false), ('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) -- 1. STUDENTS (60: TD-0001 .. TD-0060; first 30 Male, next 30 Female)