many bug fixes
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -35,5 +35,3 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<partial name="_Pagination" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 "@t.Name"? 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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user