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;
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;
}
public async Task CalculateResultsAsync(int tournamentEventLevelId, string recordedBy)
public async Task<string?> 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<int>(); // 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;
}
}

View File

@@ -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();

View File

@@ -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<IActionResult> Index(EventCategory? category, int page = 1, int pageSize = PaginationHelper.PageSize)
public async Task<IActionResult> Index(EventCategory? category)
{
IEnumerable<EventDto> events;
@@ -32,7 +31,7 @@ public class EventController : Controller
ViewBag.SelectedCategory = category;
return View(this.Page(events, page, pageSize));
return View(events);
}
[HttpGet]

View File

@@ -77,7 +77,11 @@ public class HighJumpController : Controller
public async Task<IActionResult> 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 });
}
}

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)
{
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));
}

View File

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

View File

@@ -5,6 +5,9 @@
var eventName = ViewBag.EventName as string;
var levelName = ViewBag.LevelName as string;
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">
@@ -95,3 +98,44 @@
</table>
</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>
}
@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>
document.addEventListener('DOMContentLoaded', 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) {
var bsAlert = new bootstrap.Alert(alert);
bsAlert.close();

View File

@@ -72,7 +72,14 @@
</form>
}
<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">
<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>
</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>

View File

@@ -3,6 +3,7 @@
ViewData["Title"] = "Tournaments";
var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?;
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">
@@ -24,14 +25,30 @@
</select>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="showArchived" value="true"
<label class="form-label mb-0 small text-muted d-block">Archived</label>
<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()" />
<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>
</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">
<thead class="table-dark">
<tr>
@@ -67,7 +84,14 @@
<td>
<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>
@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">
<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>
</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>
</tr>
}

View File

@@ -52,7 +52,6 @@ INSERT INTO _ev (ename, base, step, is_track) VALUES
('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),
@@ -70,10 +69,8 @@ INSERT INTO _ev (ename, base, step, is_track) VALUES
('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);
('High Jump', 1.85, 0.04, false);
-- ---------------------------------------------------------------------------
-- 1. STUDENTS (60: TD-0001 .. TD-0060; first 30 Male, next 30 Female)