many bug fixes and features added

This commit is contained in:
2026-06-27 12:15:37 -04:00
parent f60174625e
commit 78dbbc2051
51 changed files with 1459 additions and 124 deletions

View File

@@ -1,3 +1,4 @@
using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.DTOs; 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; } } public class TournamentEventLevelCreateDto { public int TournamentId { get; set; } public int EventId { get; set; } public int EventLevelId { get; set; } public bool AgeRestrictionWaived { get; set; } }

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces; namespace SportsDivision.Application.Interfaces;
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); } public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); }

View File

@@ -1,4 +1,4 @@
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
using SportsDivision.Domain.Enums; using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.Interfaces; namespace SportsDivision.Application.Interfaces;
public interface ITournamentService { Task<IEnumerable<TournamentDto>> GetAllAsync(bool includeArchived = false); Task<TournamentDto?> GetByIdAsync(int id); Task<TournamentDto> 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<IEnumerable<TournamentEventLevelDto>> GetEventLevelsAsync(int tournamentId); Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); } public interface ITournamentService { Task<IEnumerable<TournamentDto>> GetAllAsync(bool includeArchived = false); Task<TournamentDto?> GetByIdAsync(int id); Task<TournamentDto> 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<IEnumerable<TournamentEventLevelDto>> GetEventLevelsAsync(int tournamentId); Task<TournamentEventLevelDto?> GetEventLevelByIdAsync(int tournamentEventLevelId); Task<IEnumerable<EventLevelDto>> GetAllEventLevelsAsync(); Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category); Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto); Task RemoveEventLevelAsync(int tournamentEventLevelId); Task ToggleAgeWaiverAsync(int tournamentEventLevelId); }

View File

@@ -37,7 +37,9 @@ public class MappingProfile : Profile
CreateMap<TournamentUpdateDto, Tournament>(); CreateMap<TournamentUpdateDto, Tournament>();
CreateMap<TournamentEventLevel, TournamentEventLevelDto>() CreateMap<TournamentEventLevel, TournamentEventLevelDto>()
.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.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.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)); .ForMember(d => d.RegistrationCount, o => o.MapFrom(s => s.Registrations != null ? s.Registrations.Count : 0));
CreateMap<TournamentEventLevelCreateDto, TournamentEventLevel>(); CreateMap<TournamentEventLevelCreateDto, TournamentEventLevel>();

View File

@@ -26,7 +26,7 @@ public class EventService : IEventService
public async Task<EventDto?> GetByIdAsync(int id) public async Task<EventDto?> GetByIdAsync(int id)
{ {
var evt = await _uow.Events.GetByIdAsync(id); var evt = await _uow.Events.GetWithEventLevelsAsync(id);
return evt == null ? null : _mapper.Map<EventDto>(evt); return evt == null ? null : _mapper.Map<EventDto>(evt);
} }

View File

@@ -146,28 +146,32 @@ public class HighJumpService : IHighJumpService
.ThenBy(r => r.TotalFails) .ThenBy(r => r.TotalFails)
.ToList(); .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++) 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); var score = await _uow.Scores.GetByRegistrationAsync(sorted[i].RegId);
if (score == null) var isNew = score == null;
{ score ??= new Score { EventRegistrationId = sorted[i].RegId };
score = new Score
{ score.RawPerformance = cleared;
EventRegistrationId = sorted[i].RegId, score.CalculatedPoints = calc;
RawPerformance = sorted[i].HighestCleared, score.Placement = place;
RecordedBy = recordedBy, score.PlacementPoints = placementPoints.TryGetValue(place, out var pts) ? pts : 0;
RecordedAt = DateTime.UtcNow
};
await _uow.Scores.AddAsync(score);
}
else
{
score.RawPerformance = sorted[i].HighestCleared;
score.RecordedBy = recordedBy; score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow; score.RecordedAt = DateTime.UtcNow;
_uow.Scores.Update(score);
} if (isNew) await _uow.Scores.AddAsync(score);
else _uow.Scores.Update(score);
} }
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }

View File

@@ -26,7 +26,7 @@ public class SchoolService : ISchoolService
public async Task<SchoolDto?> GetByIdAsync(int id) public async Task<SchoolDto?> GetByIdAsync(int id)
{ {
var school = await _uow.Schools.GetByIdAsync(id); var school = await _uow.Schools.GetWithStudentsAsync(id);
return school == null ? null : _mapper.Map<SchoolDto>(school); return school == null ? null : _mapper.Map<SchoolDto>(school);
} }

View File

@@ -89,6 +89,62 @@ public class ScoringService : IScoringService
await _uow.SaveChangesAsync(); 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) public async Task CalculatePlacementsAsync(int tournamentEventLevelId)
{ {
var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)) var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId))

View File

@@ -90,6 +90,35 @@ public class TournamentService : ITournamentService
return _mapper.Map<IEnumerable<TournamentEventLevelDto>>(tels); return _mapper.Map<IEnumerable<TournamentEventLevelDto>>(tels);
} }
public async Task<TournamentEventLevelDto?> GetEventLevelByIdAsync(int tournamentEventLevelId)
{
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId);
return tel == null ? null : _mapper.Map<TournamentEventLevelDto>(tel);
}
public async Task<IEnumerable<EventLevelDto>> GetAllEventLevelsAsync()
{
var levels = await _uow.EventLevels.GetAllAsync();
return _mapper.Map<IEnumerable<EventLevelDto>>(levels.OrderBy(l => l.SortOrder));
}
public async Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category)
{
var tournaments = await _uow.Tournaments.GetActiveAsync();
var result = new List<TournamentEventLevelDto>();
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<TournamentEventLevelDto>(tel);
dto.TournamentName = t.Name;
result.Add(dto);
}
}
return result;
}
public async Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto) public async Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto)
{ {
var existing = await _uow.TournamentEventLevels.FindAsync(dto.TournamentId, dto.EventId, dto.EventLevelId); var existing = await _uow.TournamentEventLevels.FindAsync(dto.TournamentId, dto.EventId, dto.EventLevelId);

View File

@@ -8,7 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories;
public class EventRegistrationRepository : Repository<EventRegistration>, IEventRegistrationRepository public class EventRegistrationRepository : Repository<EventRegistration>, IEventRegistrationRepository
{ {
public EventRegistrationRepository(ApplicationDbContext context) : base(context) { } public EventRegistrationRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<EventRegistration>> 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<IEnumerable<EventRegistration>> 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<IEnumerable<EventRegistration>> 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<IEnumerable<EventRegistration>> 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<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId); public async Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId);
public async Task<IEnumerable<EventRegistration>> 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(); public async Task<IEnumerable<EventRegistration>> 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();

View File

@@ -17,7 +17,7 @@ public class Repository<T> : IRepository<T> where T : class
} }
public async Task<T?> GetByIdAsync(int id) => await _dbSet.FindAsync(id); public async Task<T?> GetByIdAsync(int id) => await _dbSet.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync(); public virtual async Task<IEnumerable<T>> GetAllAsync() => await _dbSet.ToListAsync();
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync(); public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync();
public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; } public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; }
public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities); public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities);

View File

@@ -8,6 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories;
public class RoundRepository : Repository<Round>, IRoundRepository public class RoundRepository : Repository<Round>, IRoundRepository
{ {
public RoundRepository(ApplicationDbContext context) : base(context) { } public RoundRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<Round>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Heats).OrderBy(r => r.RoundOrder).ToListAsync(); public async Task<IEnumerable<Round>> 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<Round?> 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); public async Task<Round?> 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);
} }

View File

@@ -9,7 +9,8 @@ namespace SportsDivision.Infrastructure.Repositories;
public class SchoolRepository : Repository<School>, ISchoolRepository public class SchoolRepository : Repository<School>, ISchoolRepository
{ {
public SchoolRepository(ApplicationDbContext context) : base(context) { } public SchoolRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<School>> GetByZoneAsync(int zoneId) => await _dbSet.Where(s => s.ZoneId == zoneId).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync(); public override async Task<IEnumerable<School>> GetAllAsync() => await _dbSet.Include(s => s.Zone).Include(s => s.Students).OrderBy(s => s.Name).ToListAsync();
public async Task<IEnumerable<School>> GetBySchoolLevelAsync(SchoolLevel level) => await _dbSet.Where(s => s.SchoolLevel == level).Include(s => s.Zone).OrderBy(s => s.Name).ToListAsync(); public async Task<IEnumerable<School>> 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<IEnumerable<School>> 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<School?> GetWithStudentsAsync(int schoolId) => await _dbSet.Include(s => s.Students).Include(s => s.Zone).FirstOrDefaultAsync(s => s.SchoolId == schoolId); public async Task<School?> GetWithStudentsAsync(int schoolId) => await _dbSet.Include(s => s.Students).Include(s => s.Zone).FirstOrDefaultAsync(s => s.SchoolId == schoolId);
} }

View File

@@ -8,5 +8,6 @@ namespace SportsDivision.Infrastructure.Repositories;
public class ScoringConstantRepository : Repository<ScoringConstant>, IScoringConstantRepository public class ScoringConstantRepository : Repository<ScoringConstant>, IScoringConstantRepository
{ {
public ScoringConstantRepository(ApplicationDbContext context) : base(context) { } public ScoringConstantRepository(ApplicationDbContext context) : base(context) { }
public override async Task<IEnumerable<ScoringConstant>> GetAllAsync() => await _dbSet.Include(s => s.Event).OrderBy(s => s.Event!.Name).ToListAsync();
public async Task<ScoringConstant?> GetByEventAsync(int eventId) => await _dbSet.FirstOrDefaultAsync(s => s.EventId == eventId); public async Task<ScoringConstant?> GetByEventAsync(int eventId) => await _dbSet.FirstOrDefaultAsync(s => s.EventId == eventId);
} }

View File

@@ -8,7 +8,8 @@ namespace SportsDivision.Infrastructure.Repositories;
public class TournamentRepository : Repository<Tournament>, ITournamentRepository public class TournamentRepository : Repository<Tournament>, ITournamentRepository
{ {
public TournamentRepository(ApplicationDbContext context) : base(context) { } public TournamentRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<Tournament>> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).OrderByDescending(t => t.StartDate).ToListAsync(); public override async Task<IEnumerable<Tournament>> GetAllAsync() => await _dbSet.Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync();
public async Task<IEnumerable<Tournament>> GetActiveAsync() => await _dbSet.Where(t => !t.IsArchived).Include(t => t.Zone).Include(t => t.TournamentEventLevels).OrderByDescending(t => t.StartDate).ToListAsync();
public async Task<Tournament?> 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<Tournament?> 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<Tournament?> 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); public async Task<Tournament?> 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);
} }

View File

@@ -248,9 +248,9 @@ public class DatabaseSeeder
Add("400m", 1.53775m, 82m, 1.81m, "seconds"); Add("400m", 1.53775m, 82m, 1.81m, "seconds");
Add("1500m", 0.03768m, 480m, 1.85m, "seconds"); Add("1500m", 0.03768m, 480m, 1.85m, "seconds");
Add("80mH", 5.74352m, 28.5m, 1.92m, "seconds"); Add("80mH", 5.74352m, 28.5m, 1.92m, "seconds");
Add("Long Jump", 0.14354m, 220m, 1.4m, "centimetres"); Add("Long Jump", 90.56762m, 2.2m, 1.4m, "metres");
Add("Triple Jump", 0.14354m, 220m, 1.4m, "centimetres"); Add("Triple Jump", 90.56762m, 2.2m, 1.4m, "metres");
Add("High Jump", 0.8465m, 75m, 1.42m, "centimetres"); 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 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("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"); Add("Discus 1kg", 12.91m, 4m, 1.1m, "metres"); Add("Discus 1.25kg", 12.91m, 4m, 1.1m, "metres");

View File

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

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces; using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers; namespace SportsDivision.Web.Controllers;
@@ -25,17 +26,26 @@ public class FieldEventController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId) public async Task<IActionResult> 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); var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId; ViewBag.TournamentEventLevelId = tournamentEventLevelId;
return View(registrations); return View(registrations);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> RecordScore([FromBody] ScoreCreateDto dto) [ValidateAntiForgeryToken]
public async Task<IActionResult> RecordScore(ScoreCreateDto dto, int tournamentEventLevelId)
{ {
var recordedBy = User.Identity?.Name ?? "Unknown"; var recordedBy = User.Identity?.Name ?? "Unknown";
var score = await _scoringService.RecordScoreAsync(dto, recordedBy); await _scoringService.RecordScoreAsync(dto, recordedBy);
return Json(new { success = true, score }); TempData["SuccessMessage"] = "Performance recorded.";
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
} }
[HttpPost] [HttpPost]

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces; using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers; namespace SportsDivision.Web.Controllers;
@@ -28,6 +29,13 @@ public class HighJumpController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId) public async Task<IActionResult> 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 heights = await _highJumpService.GetHeightsAsync(tournamentEventLevelId);
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId); var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId; ViewBag.TournamentEventLevelId = tournamentEventLevelId;
@@ -57,12 +65,11 @@ public class HighJumpController : Controller
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> RecordAttempt([FromBody] HighJumpAttemptUpdateDto dto) [ValidateAntiForgeryToken]
public async Task<IActionResult> RecordAttempt(HighJumpAttemptUpdateDto dto, int tournamentEventLevelId)
{ {
await _highJumpService.RecordAttemptAsync(dto); await _highJumpService.RecordAttemptAsync(dto);
var isEliminated = await _highJumpService.IsEliminatedAsync( return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
dto.HighJumpHeightId, dto.EventRegistrationId);
return Json(new { success = true, isEliminated });
} }
[HttpPost] [HttpPost]

View File

@@ -27,6 +27,7 @@ public class ReportController : Controller
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var tournaments = await _tournamentService.GetAllAsync(); var tournaments = await _tournamentService.GetAllAsync();
ViewBag.Tournaments = tournaments;
return View(tournaments); return View(tournaments);
} }
@@ -154,4 +155,65 @@ public class ReportController : Controller
var pdf = document.GeneratePdf(); var pdf = document.GeneratePdf();
return File(pdf, "application/pdf", $"StudentPoints_{tournamentId}.pdf"); return File(pdf, "application/pdf", $"StudentPoints_{tournamentId}.pdf");
} }
// ----- Excel (.xlsx) exports -----
private async Task<string> TournamentNameAsync(int tournamentId)
=> (await _tournamentService.GetByIdAsync(tournamentId))?.Name ?? "Tournament";
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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");
}
} }

View File

@@ -4,6 +4,7 @@ using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces; using SportsDivision.Application.Interfaces;
using SportsDivision.Domain.Enums; using SportsDivision.Domain.Enums;
using SportsDivision.Domain.Interfaces; using SportsDivision.Domain.Interfaces;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers; namespace SportsDivision.Web.Controllers;
@@ -19,7 +20,7 @@ public class SchoolController : Controller
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
} }
public async Task<IActionResult> Index(int? zoneId, SchoolLevel? level) public async Task<IActionResult> Index(int? zoneId, SchoolLevel? level, int page = 1, int pageSize = PaginationHelper.PageSize)
{ {
IEnumerable<SchoolDto> schools; IEnumerable<SchoolDto> schools;
@@ -40,7 +41,7 @@ public class SchoolController : Controller
ViewBag.SelectedZoneId = zoneId; ViewBag.SelectedZoneId = zoneId;
ViewBag.SelectedLevel = level; ViewBag.SelectedLevel = level;
return View(schools); return View(this.Page(schools, page, pageSize));
} }
public async Task<IActionResult> Details(int id) public async Task<IActionResult> Details(int id)
@@ -174,6 +175,12 @@ public class SchoolController : Controller
private async Task PopulateZonesViewBag() private async Task PopulateZonesViewBag()
{ {
var zones = await _unitOfWork.Zones.GetAllAsync(); 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
});
} }
} }

View File

@@ -20,6 +20,7 @@ public class ScoringConfigController : Controller
{ {
var constants = await _scoringService.GetScoringConstantsAsync(); var constants = await _scoringService.GetScoringConstantsAsync();
var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync(); var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync();
ViewBag.ScoringConstants = constants;
ViewBag.PlacementPointConfigs = placementConfigs; ViewBag.PlacementPointConfigs = placementConfigs;
return View(constants); return View(constants);
} }

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs; using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces; using SportsDivision.Application.Interfaces;
using SportsDivision.Web.Helpers;
namespace SportsDivision.Web.Controllers; namespace SportsDivision.Web.Controllers;
@@ -17,7 +18,7 @@ public class StudentController : Controller
_schoolService = schoolService; _schoolService = schoolService;
} }
public async Task<IActionResult> Index(int? schoolId, string? search) public async Task<IActionResult> Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize)
{ {
IEnumerable<StudentDto> students; IEnumerable<StudentDto> students;
@@ -38,7 +39,7 @@ public class StudentController : Controller
ViewBag.SelectedSchoolId = schoolId; ViewBag.SelectedSchoolId = schoolId;
ViewBag.SearchTerm = search; ViewBag.SearchTerm = search;
return View(students); return View(this.Page(students, page, pageSize));
} }
public async Task<IActionResult> Details(int id) public async Task<IActionResult> Details(int id)

View File

@@ -3,6 +3,7 @@ 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;
@@ -18,7 +19,7 @@ public class TournamentController : Controller
_eventService = eventService; _eventService = eventService;
} }
public async Task<IActionResult> Index(TournamentStatus? status, bool showArchived = false) 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 tournaments = await _tournamentService.GetAllAsync(includeArchived: showArchived);
@@ -30,7 +31,7 @@ public class TournamentController : Controller
ViewBag.SelectedStatus = status; ViewBag.SelectedStatus = status;
ViewBag.ShowArchived = showArchived; ViewBag.ShowArchived = showArchived;
return View(tournaments); return View(this.Page(tournaments, page, pageSize));
} }
public async Task<IActionResult> Details(int id) public async Task<IActionResult> Details(int id)
@@ -45,6 +46,8 @@ public class TournamentController : Controller
var eventLevels = await _tournamentService.GetEventLevelsAsync(id); var eventLevels = await _tournamentService.GetEventLevelsAsync(id);
ViewBag.EventLevels = eventLevels; ViewBag.EventLevels = eventLevels;
ViewBag.Events = await _eventService.GetAllAsync();
ViewBag.Levels = await _tournamentService.GetAllEventLevelsAsync();
return View(tournament); return View(tournament);
} }
@@ -158,32 +161,6 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
[HttpGet]
public async Task<IActionResult> 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] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddEventLevel(TournamentEventLevelCreateDto dto) public async Task<IActionResult> AddEventLevel(TournamentEventLevelCreateDto dto)
@@ -202,12 +179,12 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message; TempData["ErrorMessage"] = ex.Message;
} }
return RedirectToAction(nameof(SetupEventLevels), new { id = dto.TournamentId }); return RedirectToAction(nameof(Details), new { id = dto.TournamentId });
} }
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int tournamentId) public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int id)
{ {
try try
{ {
@@ -223,7 +200,7 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message; TempData["ErrorMessage"] = ex.Message;
} }
return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId }); return RedirectToAction(nameof(Details), new { id });
} }
[HttpPost] [HttpPost]
@@ -291,7 +268,7 @@ public class TournamentController : Controller
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int tournamentId) public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int id)
{ {
try try
{ {
@@ -307,6 +284,6 @@ public class TournamentController : Controller
TempData["ErrorMessage"] = ex.Message; TempData["ErrorMessage"] = ex.Message;
} }
return RedirectToAction(nameof(SetupEventLevels), new { id = tournamentId }); return RedirectToAction(nameof(Details), new { id });
} }
} }

View File

@@ -12,22 +12,49 @@ public class TrackEventController : Controller
private readonly IHeatManagementService _heatManagementService; private readonly IHeatManagementService _heatManagementService;
private readonly ITournamentService _tournamentService; private readonly ITournamentService _tournamentService;
private readonly IScoringService _scoringService; private readonly IScoringService _scoringService;
private readonly IRegistrationService _registrationService;
public TrackEventController( public TrackEventController(
IHeatManagementService heatManagementService, IHeatManagementService heatManagementService,
ITournamentService tournamentService, ITournamentService tournamentService,
IScoringService scoringService) IScoringService scoringService,
IRegistrationService registrationService)
{ {
_heatManagementService = heatManagementService; _heatManagementService = heatManagementService;
_tournamentService = tournamentService; _tournamentService = tournamentService;
_scoringService = scoringService; _scoringService = scoringService;
_registrationService = registrationService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Index(int tournamentEventLevelId) public async Task<IActionResult> 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); var rounds = await _heatManagementService.GetRoundsAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = 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); return View(rounds);
} }
@@ -40,6 +67,10 @@ public class TrackEventController : Controller
return NotFound(); 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); return View(round);
} }
@@ -65,11 +96,13 @@ public class TrackEventController : Controller
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> SaveTimes(int heatId, [FromBody] List<HeatLaneUpdateDto> lanes) [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveTimes(int heatId, int roundId, List<HeatLaneUpdateDto> lanes)
{ {
var recordedBy = User.Identity?.Name ?? "Unknown"; var recordedBy = User.Identity?.Name ?? "Unknown";
await _heatManagementService.SaveHeatTimesAsync(heatId, lanes, recordedBy); await _heatManagementService.SaveHeatTimesAsync(heatId, lanes, recordedBy);
return Json(new { success = true }); TempData["SuccessMessage"] = "Heat times saved.";
return RedirectToAction(nameof(ManageRound), new { roundId });
} }
[HttpPost] [HttpPost]
@@ -83,9 +116,18 @@ public class TrackEventController : Controller
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> PopulateNextRound(int roundId) public async Task<IActionResult> PopulateNextRound(int roundId)
{
var round = await _heatManagementService.GetRoundWithHeatsAsync(roundId);
try
{ {
await _heatManagementService.PopulateNextRoundAsync(roundId); await _heatManagementService.PopulateNextRoundAsync(roundId);
return RedirectToAction(nameof(ManageRound), new { 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] [HttpPost]
@@ -103,4 +145,26 @@ public class TrackEventController : Controller
await _heatManagementService.CompleteRoundAsync(roundId); await _heatManagementService.CompleteRoundAsync(roundId);
return RedirectToAction(nameof(ManageRound), new { roundId }); return RedirectToAction(nameof(ManageRound), new { roundId });
} }
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
} }

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
namespace SportsDivision.Web.Helpers;
public static class PaginationHelper
{
public const int PageSize = 20;
/// <summary>Page-size choices offered in the UI. 0 means "All".</summary>
public static readonly int[] PageSizeOptions = { 20, 50, 100, 500, 0 };
/// <summary>
/// Slices <paramref name="source"/> to the requested page and records paging
/// metadata in ViewData (read as ViewBag.Page / ViewBag.TotalPages / ViewBag.PageSize
/// in views). A <paramref name="pageSize"/> of 0 (or less) means "show all".
/// </summary>
public static List<T> Page<T>(this Controller controller, IEnumerable<T> source, int page, int pageSize = PageSize)
{
var list = source as IList<T> ?? 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();
}
}

View File

@@ -0,0 +1,192 @@
using ClosedXML.Excel;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.DTOs.ReportDtos;
namespace SportsDivision.Web.Reports;
/// <summary>
/// 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.
/// </summary>
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<PopularEventsReportDto> 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<RegistrationByGenderReportDto> 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<EventSchoolReportDto> 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<StudentsBySchoolReportDto> 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<ScoresByEventReportDto> 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<StudentPointsReportDto> 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<SchoolPointsSummaryDto> 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);
}
}

View File

@@ -11,6 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="QuestPDF" Version="2024.*" /> <PackageReference Include="QuestPDF" Version="2024.*" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.*" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">

View File

@@ -0,0 +1,41 @@
@using SportsDivision.Domain.Enums
@model EventCreateDto
@{
ViewData["Title"] = "Add Event";
}
<h2>Add Event</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Create" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label asp-for="Name" class="form-label">Name</label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Category" class="form-label">Category</label>
<select asp-for="Category" asp-items="Html.GetEnumSelectList<EventCategory>()" class="form-select"></select>
</div>
<div class="form-check mb-2">
<input asp-for="IsRelay" class="form-check-input" />
<label asp-for="IsRelay" class="form-check-label">Relay event</label>
</div>
<div class="form-check mb-2">
<input asp-for="PrimarySchool" class="form-check-input" />
<label asp-for="PrimarySchool" class="form-check-label">Available to primary schools</label>
</div>
<div class="form-check mb-3">
<input asp-for="SecondarySchool" class="form-check-input" />
<label asp-for="SecondarySchool" class="form-check-label">Available to secondary schools</label>
</div>
<button type="submit" class="btn btn-primary">Create</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
@section Scripts { <partial name="_ValidationScriptsPartial" /> }

View File

@@ -0,0 +1,50 @@
@using SportsDivision.Domain.Enums
@model EventUpdateDto
@{
ViewData["Title"] = "Edit Event";
}
<h2>Edit Event</h2>
<hr />
<div class="row">
<div class="col-md-6">
<form asp-action="Edit" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<input type="hidden" asp-for="EventId" />
@for (var i = 0; i < Model.EventLevelIds.Count; i++)
{
<input type="hidden" asp-for="EventLevelIds[i]" />
}
<div class="mb-3">
<label asp-for="Name" class="form-label">Name</label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Category" class="form-label">Category</label>
<select asp-for="Category" asp-items="Html.GetEnumSelectList<EventCategory>()" class="form-select"></select>
</div>
<div class="form-check mb-2">
<input asp-for="IsRelay" class="form-check-input" />
<label asp-for="IsRelay" class="form-check-label">Relay event</label>
</div>
<div class="form-check mb-2">
<input asp-for="PrimarySchool" class="form-check-input" />
<label asp-for="PrimarySchool" class="form-check-label">Available to primary schools</label>
</div>
<div class="form-check mb-2">
<input asp-for="SecondarySchool" class="form-check-input" />
<label asp-for="SecondarySchool" class="form-check-label">Available to secondary schools</label>
</div>
<div class="form-check mb-3">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
@section Scripts { <partial name="_ValidationScriptsPartial" /> }

View File

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

View File

@@ -50,6 +50,7 @@
<td>@r.SchoolName</td> <td>@r.SchoolName</td>
<td> <td>
<form asp-action="RecordScore" method="post" class="d-flex gap-1"> <form asp-action="RecordScore" method="post" class="d-flex gap-1">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="EventRegistrationId" value="@r.EventRegistrationId" /> <input type="hidden" name="EventRegistrationId" value="@r.EventRegistrationId" />
<input type="number" step="0.01" name="RawPerformance" value="@r.Score?.RawPerformance" class="form-control form-control-sm" style="width:100px" /> <input type="number" step="0.01" name="RawPerformance" value="@r.Score?.RawPerformance" class="form-control form-control-sm" style="width:100px" />
<button type="submit" class="btn btn-sm btn-outline-primary">Save</button> <button type="submit" class="btn btn-sm btn-outline-primary">Save</button>

View File

@@ -73,6 +73,7 @@
@for (int i = 1; i <= 3; i++) @for (int i = 1; i <= 3; i++)
{ {
<form asp-action="RecordAttempt" method="post" class="d-inline"> <form asp-action="RecordAttempt" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" /> <input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" />
<input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" /> <input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" />
<input type="hidden" name="AttemptNumber" value="@i" /> <input type="hidden" name="AttemptNumber" value="@i" />

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Event School Report"; ViewData["Title"] = "Event School Report";
} }
<h2>Event School Report</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Event School Report</h2>
<div class="d-flex gap-2">
<a asp-action="EventSchoolReportExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="EventSchoolReportPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
@foreach (var evt in Model) @foreach (var evt in Model)

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Popular Events"; ViewData["Title"] = "Popular Events";
} }
<h2>Popular Events Report</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Popular Events Report</h2>
<div class="d-flex gap-2">
<a asp-action="PopularEventsExcel" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="PopularEventsPdf" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Registration by Gender"; ViewData["Title"] = "Registration by Gender";
} }
<h2>Registration by Gender</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Registration by Gender</h2>
<div class="d-flex gap-2">
<a asp-action="RegistrationByGenderExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="RegistrationByGenderPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">

View File

@@ -3,7 +3,10 @@
ViewData["Title"] = "School Standings"; ViewData["Title"] = "School Standings";
} }
<h2>School Standings</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">School Standings</h2>
<a asp-action="SchoolStandingsExcel" asp-route-tournamentId="@ViewBag.TournamentId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
</div>
<hr /> <hr />
<div class="card shadow-sm"> <div class="card shadow-sm">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Scores by Event"; ViewData["Title"] = "Scores by Event";
} }
<h2>Scores by Event</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Scores by Event</h2>
<div class="d-flex gap-2">
<a asp-action="ScoresByEventExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="ScoresByEventPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-eventId="@ViewBag.EventId" asp-route-eventLevelId="@ViewBag.EventLevelId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
@foreach (var evt in Model) @foreach (var evt in Model)

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Student Points"; ViewData["Title"] = "Student Points";
} }
<h2>Student Points</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Student Points</h2>
<div class="d-flex gap-2">
<a asp-action="StudentPointsExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="StudentPointsPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">

View File

@@ -3,7 +3,13 @@
ViewData["Title"] = "Students by School"; ViewData["Title"] = "Students by School";
} }
<h2>Students by School</h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0">Students by School</h2>
<div class="d-flex gap-2">
<a asp-action="StudentsBySchoolExcel" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
<a asp-action="StudentsBySchoolPdf" asp-route-tournamentId="@ViewBag.TournamentId" asp-route-schoolId="@ViewBag.SchoolId" asp-route-zoneId="@ViewBag.ZoneId" class="btn btn-outline-danger"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
</div>
</div>
<hr /> <hr />
@foreach (var school in Model) @foreach (var school in Model)

View File

@@ -0,0 +1,45 @@
@model SchoolDto
@{
ViewData["Title"] = Model.Name;
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>@Model.Name</h2>
<div class="d-flex gap-2">
<a asp-action="Edit" asp-route-id="@Model.SchoolId" class="btn btn-outline-primary">Edit</a>
<a asp-controller="Student" asp-action="Index" asp-route-schoolId="@Model.SchoolId" class="btn btn-outline-secondary">Students</a>
<a asp-action="Index" class="btn btn-secondary">Back</a>
</div>
</div>
<partial name="_Notification" />
<div class="card shadow-sm" style="max-width: 600px;">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Short Name</dt>
<dd class="col-sm-8">@(string.IsNullOrEmpty(Model.ShortName) ? "—" : Model.ShortName)</dd>
<dt class="col-sm-4">Level</dt>
<dd class="col-sm-8">@Model.SchoolLevel</dd>
<dt class="col-sm-4">Zone</dt>
<dd class="col-sm-8">@Model.ZoneName</dd>
<dt class="col-sm-4">Students</dt>
<dd class="col-sm-8">@Model.StudentCount</dd>
<dt class="col-sm-4">Status</dt>
<dd class="col-sm-8">
@if (Model.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-secondary">Inactive</span>
}
</dd>
</dl>
</div>
</div>

View File

@@ -61,3 +61,5 @@
} }
</tbody> </tbody>
</table> </table>
<partial name="_Pagination" />

View File

@@ -16,6 +16,15 @@
<h5 class="mb-0">Scoring Constants (World Athletics)</h5> <h5 class="mb-0">Scoring Constants (World Athletics)</h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (constants != null)
{
@foreach (var c in constants)
{
<form id="scf-@c.ScoringConstantId" asp-action="UpdateScoringConstant" method="post" class="d-none">
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
</form>
}
}
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Action</th></tr></thead> <thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Action</th></tr></thead>
<tbody> <tbody>
@@ -24,15 +33,12 @@
@foreach (var c in constants) @foreach (var c in constants)
{ {
<tr> <tr>
<form asp-action="UpdateScoringConstant" method="post">
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
<td>@c.EventName</td> <td>@c.EventName</td>
<td><input type="number" step="0.00001" name="A" value="@c.A" class="form-control form-control-sm" style="width:100px" /></td> <td><input type="number" step="0.00001" name="A" value="@c.A" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:100px" /></td>
<td><input type="number" step="0.1" name="B" value="@c.B" class="form-control form-control-sm" style="width:80px" /></td> <td><input type="number" step="0.1" name="B" value="@c.B" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td><input type="number" step="0.01" name="C" value="@c.C" class="form-control form-control-sm" style="width:80px" /></td> <td><input type="number" step="0.01" name="C" value="@c.C" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td>@c.Unit</td> <td>@c.Unit</td>
<td><button type="submit" class="btn btn-sm btn-outline-primary">Save</button></td> <td><button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button></td>
</form>
</tr> </tr>
} }
} }
@@ -47,6 +53,16 @@
<h5 class="mb-0">Placement Points</h5> <h5 class="mb-0">Placement Points</h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (placements != null)
{
@foreach (var p in placements.OrderBy(x => x.Placement))
{
<form id="ppf-@p.PlacementPointConfigId" asp-action="UpdatePlacementPointConfig" method="post" class="d-none">
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
<input type="hidden" name="Placement" value="@p.Placement" />
</form>
}
}
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead><tr><th>Place</th><th>Points</th><th>Action</th></tr></thead> <thead><tr><th>Place</th><th>Points</th><th>Action</th></tr></thead>
<tbody> <tbody>
@@ -55,13 +71,9 @@
@foreach (var p in placements.OrderBy(x => x.Placement)) @foreach (var p in placements.OrderBy(x => x.Placement))
{ {
<tr> <tr>
<form asp-action="UpdatePlacementPointConfig" method="post">
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
<input type="hidden" name="Placement" value="@p.Placement" />
<td>#@p.Placement</td> <td>#@p.Placement</td>
<td><input type="number" name="Points" value="@p.Points" class="form-control form-control-sm" style="width:60px" /></td> <td><input type="number" name="Points" value="@p.Points" form="ppf-@p.PlacementPointConfigId" class="form-control form-control-sm" style="width:60px" /></td>
<td><button type="submit" class="btn btn-sm btn-outline-primary">Save</button></td> <td><button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button></td>
</form>
</tr> </tr>
} }
} }

View File

@@ -0,0 +1,49 @@
@{
var title = ViewBag.ScoringTitle as string ?? "Scoring";
var controller = ViewBag.ScoringController as string ?? "TrackEvent";
var targets = ViewBag.ScoringTargets as IEnumerable<TournamentEventLevelDto> ?? Enumerable.Empty<TournamentEventLevelDto>();
ViewData["Title"] = title;
}
<h2>@title</h2>
<p class="text-muted">Select an event level to score.</p>
<hr />
@if (!targets.Any())
{
<div class="alert alert-info">
No event levels are available for scoring yet. Add event levels to an active tournament from the
<a asp-controller="Tournament" asp-action="Index">Tournaments</a> page.
</div>
}
else
{
@foreach (var grp in targets.GroupBy(t => new { t.TournamentId, t.TournamentName }))
{
<div class="card shadow-sm mb-3">
<div class="card-header text-white" style="background-color: #003366;">
<h5 class="mb-0">@grp.Key.TournamentName</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr><th>Event</th><th>Level</th><th>Registrations</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in grp.OrderBy(x => x.EventName).ThenBy(x => x.EventLevelName))
{
<tr>
<td>@t.EventName</td>
<td>@t.EventLevelName</td>
<td>@t.RegistrationCount</td>
<td class="text-end">
<a asp-controller="@controller" asp-action="Index" asp-route-tournamentEventLevelId="@t.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Score</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}

View File

@@ -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<int, int, string> url = (p, ps) =>
{
var ps2 = new List<string>(baseParams) { $"pageSize={ps}", $"page={p}" };
return $"{request.Path}?{string.Join("&", ps2)}";
};
Func<int, string> 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)
{
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3">
<div class="d-flex align-items-center gap-2">
<span class="small text-muted">Show</span>
<select class="form-select form-select-sm" style="width:auto" onchange="location.href=this.value" aria-label="Items per page">
@foreach (var opt in new[] { 20, 50, 100, 500 })
{
<option value="@url(1, opt)" selected="@(pageSize == opt)">@opt</option>
}
<option value="@url(1, 0)" selected="@(pageSize <= 0)">All</option>
</select>
<span class="small text-muted">per page</span>
</div>
<span class="small text-muted">Showing @firstItem@lastItem of @totalItems</span>
</div>
@if (totalPages > 1)
{
<nav aria-label="Page navigation" class="mt-2">
<ul class="pagination justify-content-center mb-0">
<li class="page-item @(pg <= 1 ? "disabled" : "")">
<a class="page-link" href="@prevHref">Previous</a>
</li>
@if (start > 1)
{
<li class="page-item"><a class="page-link" href="@pageUrl(1)">1</a></li>
@if (start > 2)
{
<li class="page-item disabled"><span class="page-link">…</span></li>
}
}
@for (int i = start; i <= end; i++)
{
<li class="page-item @(i == pg ? "active" : "")">
<a class="page-link" href="@pageUrl(i)">@i</a>
</li>
}
@if (end < totalPages)
{
@if (end < totalPages - 1)
{
<li class="page-item disabled"><span class="page-link">…</span></li>
}
<li class="page-item"><a class="page-link" href="@pageUrl(totalPages)">@totalPages</a></li>
}
<li class="page-item @(pg >= totalPages ? "disabled" : "")">
<a class="page-link" href="@nextHref">Next</a>
</li>
</ul>
</nav>
}
}

View File

@@ -56,3 +56,5 @@
} }
</tbody> </tbody>
</table> </table>
<partial name="_Pagination" />

View File

@@ -6,12 +6,25 @@
var levels = ViewBag.Levels as IEnumerable<EventLevelDto>; var levels = ViewBag.Levels as IEnumerable<EventLevelDto>;
} }
@{
var statusClass = Model.Status switch {
TournamentStatus.Draft => "bg-secondary",
TournamentStatus.Registration => "bg-info",
TournamentStatus.InProgress => "bg-primary",
TournamentStatus.Completed => "bg-success",
_ => "bg-dark"
};
}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h2>@Model.Name</h2> <h2 class="mb-1">
@Model.Name
<span class="badge @statusClass align-middle">@Model.Status</span>
@if (Model.IsArchived) { <span class="badge bg-dark align-middle">Archived</span> }
</h2>
<p class="text-muted mb-0">@Model.StartDate.ToString("MMMM d") - @Model.EndDate.ToString("MMMM d, yyyy")</p> <p class="text-muted mb-0">@Model.StartDate.ToString("MMMM d") - @Model.EndDate.ToString("MMMM d, yyyy")</p>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2 flex-wrap justify-content-end">
@if (Model.Status == TournamentStatus.Draft) @if (Model.Status == TournamentStatus.Draft)
{ {
<form asp-action="UpdateStatus" method="post"> <form asp-action="UpdateStatus" method="post">
@@ -27,16 +40,53 @@
<input type="hidden" name="status" value="InProgress" /> <input type="hidden" name="status" value="InProgress" />
<button type="submit" class="btn btn-primary">Start Tournament</button> <button type="submit" class="btn btn-primary">Start Tournament</button>
</form> </form>
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Draft" />
<button type="submit" class="btn btn-outline-secondary"
onclick="return confirm('Revert this tournament back to Draft?')">Back to Draft</button>
</form>
} }
@if (Model.Status == TournamentStatus.InProgress) @if (Model.Status == TournamentStatus.InProgress)
{ {
<form asp-action="UpdateStatus" method="post"> <form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" /> <input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Completed" /> <input type="hidden" name="status" value="Completed" />
<button type="submit" class="btn btn-success">Complete</button> <button type="submit" class="btn btn-success"
onclick="return confirm('Mark &quot;@Model.Name&quot; as Completed?\n\nYou can reopen it later if this was a mistake.')">Complete</button>
</form>
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="Registration" />
<button type="submit" class="btn btn-outline-secondary"
onclick="return confirm('Move this tournament back to Registration?')">Back to Registration</button>
</form>
}
@if (Model.Status == TournamentStatus.Completed)
{
<form asp-action="UpdateStatus" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<input type="hidden" name="status" value="InProgress" />
<button type="submit" class="btn btn-warning"
onclick="return confirm('Reopen this completed tournament and set it back to In Progress?')">Reopen Tournament</button>
</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)
{
<form asp-action="Archive" method="post">
<input type="hidden" name="id" value="@Model.TournamentId" />
<button type="submit" class="btn btn-outline-dark"
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>
</div> </div>
@@ -76,7 +126,15 @@
</form> </form>
</td> </td>
<td> <td>
<a asp-controller="TrackEvent" asp-action="Index" asp-route-tournamentEventLevelId="@tel.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Scoring</a> @{
var scoringController = tel.Category switch
{
EventCategory.Field => "FieldEvent",
EventCategory.HighJump => "HighJump",
_ => "TrackEvent"
};
}
<a asp-controller="@scoringController" asp-action="Index" asp-route-tournamentEventLevelId="@tel.TournamentEventLevelId" class="btn btn-sm btn-outline-success">Scoring</a>
<form asp-action="RemoveEventLevel" method="post" class="d-inline"> <form asp-action="RemoveEventLevel" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@tel.TournamentEventLevelId" /> <input type="hidden" name="tournamentEventLevelId" value="@tel.TournamentEventLevelId" />
<input type="hidden" name="id" value="@Model.TournamentId" /> <input type="hidden" name="id" value="@Model.TournamentId" />

View File

@@ -1,6 +1,8 @@
@model IEnumerable<TournamentDto> @model IEnumerable<TournamentDto>
@{ @{
ViewData["Title"] = "Tournaments"; ViewData["Title"] = "Tournaments";
var selectedStatus = ViewBag.SelectedStatus as TournamentStatus?;
var showArchived = ViewBag.ShowArchived as bool? ?? false;
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -10,6 +12,26 @@
<partial name="_Notification" /> <partial name="_Notification" />
<form asp-action="Index" method="get" class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label mb-0 small text-muted">Status</label>
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All statuses</option>
@foreach (var s in Enum.GetValues<TournamentStatus>())
{
<option value="@s" selected="@(selectedStatus == s)">@s</option>
}
</select>
</div>
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="showArchived" value="true"
id="showArchived" @(showArchived ? "checked" : "") onchange="this.form.submit()" />
<label class="form-check-label" for="showArchived">Show archived</label>
</div>
</div>
</form>
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
@@ -45,8 +67,25 @@
<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)
{
<form asp-action="Archive" method="post" class="d-inline">
<input type="hidden" name="id" value="@t.TournamentId" />
<button type="submit" class="btn btn-sm btn-outline-dark"
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> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
<partial name="_Pagination" />

View File

@@ -4,13 +4,29 @@
var telId = ViewBag.TournamentEventLevelId as int?; var telId = ViewBag.TournamentEventLevelId as int?;
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 tournamentName = ViewBag.TournamentName as string;
var regCount = ViewBag.RegistrationCount as int? ?? 0;
var standings = ViewBag.Standings as IEnumerable<EventRegistrationDto> ?? Enumerable.Empty<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">
<div> <div>
<h2>Track Event Scoring</h2> <h2 class="mb-1">@(string.IsNullOrEmpty(eventName) ? "Track Event Scoring" : $"{eventName} — {levelName}")</h2>
<p class="text-muted">@eventName - @levelName</p> <p class="text-muted mb-0">
@if (!string.IsNullOrEmpty(tournamentName)) { <span><i class="bi bi-trophy"></i> @tournamentName</span> }
<span class="ms-2"><i class="bi bi-people"></i> @regCount registered</span>
<span class="ms-2"><i class="bi bi-stopwatch"></i> @Model.Count() round(s)</span>
</p>
</div> </div>
@if (Model.Any(r => r.RoundType == RoundType.Final))
{
<form asp-action="CalculateFinalStandings" method="post">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<button type="submit" class="btn btn-warning" title="Rank the final-round times and award placement points">
<i class="bi bi-trophy"></i> Calculate Final Standings
</button>
</form>
}
</div> </div>
<partial name="_Notification" /> <partial name="_Notification" />
@@ -18,31 +34,50 @@
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Rounds</h5> <h5 class="mb-0">Rounds</h5>
<form asp-action="CreateRound" method="post" class="d-flex gap-2"> <form asp-action="CreateRound" method="post" class="d-flex gap-2 align-items-center">
<input type="hidden" name="TournamentEventLevelId" value="@telId" /> <input type="hidden" name="TournamentEventLevelId" value="@telId" />
<select name="RoundType" class="form-select form-select-sm" style="width:120px"> <select name="RoundType" id="roundTypeSelect" class="form-select form-select-sm" style="width:120px">
<option value="Heats">Heats</option> <option value="Heat">Heats</option>
<option value="SemiFinal">Semi-Final</option> <option value="SemiFinal">Semi-Final</option>
<option value="Final">Final</option> <option value="Final">Final</option>
</select> </select>
<input type="number" name="RoundOrder" value="@(Model.Count() + 1)" class="form-control form-control-sm" style="width:60px" /> <input type="number" name="RoundOrder" value="@(Model.Count() + 1)" class="form-control form-control-sm" style="width:60px" title="Round order" />
<input type="number" name="AdvanceTopN" value="3" class="form-control form-control-sm" style="width:60px" placeholder="Top N" /> <input type="number" name="AdvanceTopN" id="advanceTopN" value="3" class="form-control form-control-sm advance-field" style="width:60px" placeholder="Top N" title="Advance top N per heat" />
<input type="number" name="AdvanceFastestLosers" value="2" class="form-control form-control-sm" style="width:60px" placeholder="FL" /> <input type="number" name="AdvanceFastestLosers" id="advanceFL" value="2" class="form-control form-control-sm advance-field" style="width:60px" placeholder="FL" title="Fastest losers" />
<span id="finalNote" class="text-light small fst-italic" style="display:none;">no advancement</span>
<button type="submit" class="btn btn-sm btn-success">Add Round</button> <button type="submit" class="btn btn-sm btn-success">Add Round</button>
</form> </form>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead><tr><th>Round</th><th>Type</th><th>Status</th><th>Heats</th><th>Advance</th><th>Actions</th></tr></thead> <thead><tr><th>Round</th><th>Type</th><th>Status</th><th>Heats</th><th>Competitors</th><th>Advances to next round</th><th>Actions</th></tr></thead>
<tbody> <tbody>
@foreach (var r in Model.OrderBy(r => r.RoundOrder)) @foreach (var r in Model.OrderBy(r => r.RoundOrder))
{ {
var competitors = r.Heats.Sum(h => h.HeatLanes.Count);
var isFinal = r.RoundType == RoundType.Final;
<tr> <tr>
<td><strong>Round @r.RoundOrder</strong></td> <td><strong>Round @r.RoundOrder</strong></td>
<td><span class="badge bg-info">@r.RoundType</span></td> <td><span class="badge bg-info">@r.RoundType</span></td>
<td><span class="badge @(r.Status == RoundStatus.Completed ? "bg-success" : r.Status == RoundStatus.InProgress ? "bg-primary" : "bg-secondary")">@r.Status</span></td> <td><span class="badge @(r.Status == RoundStatus.Completed ? "bg-success" : r.Status == RoundStatus.InProgress ? "bg-primary" : "bg-secondary")">@r.Status</span></td>
<td>@r.Heats.Count heats</td> <td>@r.Heats.Count</td>
<td>Top @(r.AdvanceTopN ?? 0) + @(r.AdvanceFastestLosers ?? 0) FL</td> <td>@competitors</td>
<td>
@if (isFinal)
{
<span class="badge bg-warning text-dark">Final — points awarded</span>
}
else if ((r.AdvanceTopN ?? 0) > 0 || (r.AdvanceFastestLosers ?? 0) > 0)
{
<span title="Top @(r.AdvanceTopN ?? 0) per heat plus @(r.AdvanceFastestLosers ?? 0) fastest losers overall">
Top @(r.AdvanceTopN ?? 0)/heat + @(r.AdvanceFastestLosers ?? 0) fastest losers
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td> <td>
<a asp-action="ManageRound" asp-route-roundId="@r.RoundId" class="btn btn-sm btn-primary">Manage</a> <a asp-action="ManageRound" asp-route-roundId="@r.RoundId" class="btn btn-sm btn-primary">Manage</a>
@if (r.Heats.Count == 0) @if (r.Heats.Count == 0)
@@ -59,4 +94,63 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card-footer text-muted small">
Athletes run preliminary heats (max 8 per heat); the fastest advance to the Final.
<strong>Placement points are awarded from the Final round only.</strong>
</div>
</div> </div>
@if (standings.Any())
{
<div class="card shadow-sm mb-3">
<div class="card-header text-white" style="background-color:#003366;">
<h5 class="mb-0">Final Standings</h5>
</div>
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr><th>Place</th><th>Athlete</th><th>School</th><th>Time (s)</th><th>Points</th></tr>
</thead>
<tbody>
@foreach (var r in standings)
{
<tr>
<td>
@if (r.Score!.Placement <= 3) { <span class="badge bg-warning text-dark">#@r.Score.Placement</span> }
else { <span>@r.Score.Placement</span> }
</td>
<td>@r.StudentName</td>
<td>@r.SchoolName</td>
<td>@r.Score.RawPerformance.ToString("0.00")</td>
<td><strong>@r.Score.PlacementPoints</strong></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@section Scripts {
<script>
(function () {
var sel = document.getElementById('roundTypeSelect');
if (!sel) return;
var topN = document.getElementById('advanceTopN');
var fl = document.getElementById('advanceFL');
var note = document.getElementById('finalNote');
function sync() {
var isFinal = sel.value === 'Final';
// Finals don't advance anyone, so hide the advancement inputs.
[topN, fl].forEach(function (el) {
if (!el) return;
el.style.display = isFinal ? 'none' : '';
el.disabled = isFinal;
});
if (note) note.style.display = isFinal ? '' : 'none';
}
sel.addEventListener('change', sync);
sync();
})();
</script>
}

View File

@@ -16,12 +16,19 @@
<input type="hidden" name="roundId" value="@Model.RoundId" /> <input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-warning" onclick="return confirm('Calculate advancement?')">Calculate Advancement</button> <button type="submit" class="btn btn-warning" onclick="return confirm('Calculate advancement?')">Calculate Advancement</button>
</form> </form>
@if (ViewBag.HasNextRound == true)
{
<form asp-action="PopulateNextRound" method="post">
<input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-info" onclick="return confirm('Move the advancing athletes into the next round?')">Send Advancers to Next Round</button>
</form>
}
<form asp-action="CompleteRound" method="post"> <form asp-action="CompleteRound" method="post">
<input type="hidden" name="roundId" value="@Model.RoundId" /> <input type="hidden" name="roundId" value="@Model.RoundId" />
<button type="submit" class="btn btn-success" onclick="return confirm('Complete this round?')">Complete Round</button> <button type="submit" class="btn btn-success" onclick="return confirm('Complete this round?')">Complete Round</button>
</form> </form>
} }
<a asp-action="Index" asp-route-tournamentEventLevelId="@telId" class="btn btn-secondary">Back</a> <a asp-action="Index" asp-route-tournamentEventLevelId="@Model.TournamentEventLevelId" class="btn btn-secondary">Back</a>
</div> </div>
</div> </div>
@@ -54,6 +61,7 @@
<div class="card-body"> <div class="card-body">
<form asp-action="SaveTimes" method="post"> <form asp-action="SaveTimes" method="post">
<input type="hidden" name="heatId" value="@heat.HeatId" /> <input type="hidden" name="heatId" value="@heat.HeatId" />
<input type="hidden" name="roundId" value="@Model.RoundId" />
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>

355
test-data.sql Normal file
View File

@@ -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";