405 lines
16 KiB
Plaintext
405 lines
16 KiB
Plaintext
@page "/calendar/state-schedule-handout"
|
||
@attribute [Authorize]
|
||
@using Microsoft.EntityFrameworkCore
|
||
@using Microsoft.Extensions.Options
|
||
@using System.Globalization
|
||
@using WebApp.Models
|
||
@using WebApp.Utility
|
||
@using WebApp.Services
|
||
@inject AppDbContext Context
|
||
@inject IConfiguration Configuration
|
||
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
|
||
@inject IEventOccurrenceService EventOccurrenceService
|
||
|
||
<div class="no-print">
|
||
<PageHeader
|
||
Title="State schedule handout"
|
||
Description="Print per-student schedules and the combined master list."
|
||
Icon="@Icons.Material.Filled.Print"
|
||
ShowBackButton="true"
|
||
BackButtonUrl="/calendar" />
|
||
</div>
|
||
|
||
@if (_students == null || _allOccurrences == null)
|
||
{
|
||
<p><em>Loading...</em></p>
|
||
}
|
||
else
|
||
{
|
||
var opts = HandoutOptionsMonitor.CurrentValue;
|
||
|
||
<MudContainer Class="state-schedule-handout">
|
||
@foreach (var student in _students)
|
||
{
|
||
<MudContainer Class="pagebreak">
|
||
<MudText Typo="Typo.h5">
|
||
@if (string.IsNullOrWhiteSpace(student.StateId))
|
||
{
|
||
@student.Name
|
||
}
|
||
else
|
||
{
|
||
@($"{student.Name} - {student.StateId}")
|
||
}
|
||
</MudText>
|
||
<MudText Typo="Typo.h6" Class="mb-3">
|
||
TSA @_competitionYear @_stateAbbrev State Schedule
|
||
</MudText>
|
||
|
||
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
|
||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
|
||
<thead>
|
||
<tr>
|
||
<th>State ID</th>
|
||
<th>Event</th>
|
||
<th>Activity</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var eventRow in GetEventSummaryRows(student))
|
||
{
|
||
<tr>
|
||
<td>@eventRow.StateRegistrationId</td>
|
||
<td>@eventRow.EventName</td>
|
||
<td>@eventRow.Activity</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
|
||
@{
|
||
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
|
||
}
|
||
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
|
||
@if (scheduleRows.Count == 0)
|
||
{
|
||
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
|
||
}
|
||
else
|
||
{
|
||
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
|
||
{
|
||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Event</th>
|
||
<th>Location</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
|
||
{
|
||
<tr>
|
||
<td>@FormatTimeDisplay(occ)</td>
|
||
<td>@FormatEventColumn(occ)</td>
|
||
<td>@(occ.Location ?? "")</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
}
|
||
</MudContainer>
|
||
}
|
||
|
||
<MudContainer Class="pagebreak">
|
||
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
|
||
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">All imported occurrences for this chapter database.</MudText>
|
||
@if (_allOccurrences.Count == 0)
|
||
{
|
||
<MudText Class="mud-text-secondary">No event occurrences in the database. Import a schedule from the calendar first.</MudText>
|
||
}
|
||
@foreach (var dateGroup in _allOccurrences.GroupBy(o => o.StartTime.Date))
|
||
{
|
||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Event</th>
|
||
<th>Location</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (var tlGroup in dateGroup
|
||
.OrderBy(o => o.StartTime)
|
||
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
|
||
.Select(g => g.ToList()))
|
||
{
|
||
if (tlGroup.Count == 1)
|
||
{
|
||
var occ = tlGroup[0];
|
||
<tr>
|
||
<td>@FormatTimeDisplay(occ)</td>
|
||
<td>@FormatCombinedScheduleEventCell(occ)</td>
|
||
<td>@(occ.Location ?? "")</td>
|
||
</tr>
|
||
}
|
||
else
|
||
{
|
||
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
|
||
var specificOccs = tlGroup
|
||
.Where(o => o.EventDefinitionId.HasValue)
|
||
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
|
||
var representative = genericOcc ?? specificOccs[0];
|
||
|
||
if (genericOcc != null)
|
||
{
|
||
<tr>
|
||
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
||
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
|
||
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
||
</tr>
|
||
@foreach (var sub in specificOccs)
|
||
{
|
||
<tr>
|
||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
||
</tr>
|
||
}
|
||
}
|
||
else
|
||
{
|
||
<tr>
|
||
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
|
||
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
||
</tr>
|
||
@foreach (var sub in specificOccs.Skip(1))
|
||
{
|
||
<tr>
|
||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
||
</tr>
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudContainer>
|
||
</MudContainer>
|
||
}
|
||
|
||
@code {
|
||
private Student[]? _students;
|
||
private List<EventOccurrence>? _allOccurrences;
|
||
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
|
||
private string _competitionYear = "";
|
||
private string _stateAbbrev = "";
|
||
private string? _chapterStateId;
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
|
||
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
|
||
_chapterStateId = Configuration["ChapterSettings:StateId"];
|
||
|
||
_allOccurrences = await Context.EventOccurrences
|
||
.AsNoTracking()
|
||
.Include(eo => eo.EventDefinition)
|
||
.OrderBy(eo => eo.StartTime)
|
||
.ToListAsync();
|
||
|
||
var eventDefIds = _allOccurrences
|
||
.Where(o => o.EventDefinitionId.HasValue)
|
||
.Select(o => o.EventDefinitionId!.Value)
|
||
.Distinct()
|
||
.ToList();
|
||
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
|
||
|
||
// Tracking required: Include Teams->Students creates a graph cycle (Student–Team–Student) that EF disallows with AsNoTracking().
|
||
_students = await Context.Students
|
||
.Include(s => s.Teams)
|
||
.ThenInclude(t => t!.Event)
|
||
.Include(s => s.Teams)
|
||
.ThenInclude(t => t!.Captain)
|
||
.Include(s => s.Teams)
|
||
.ThenInclude(t => t!.Students)
|
||
.OrderBy(s => s.FirstName)
|
||
.ThenBy(s => s.LastName)
|
||
.ToArrayAsync();
|
||
}
|
||
|
||
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
|
||
{
|
||
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
|
||
|
||
var competition = _allOccurrences!
|
||
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
|
||
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
|
||
|
||
var special = _allOccurrences!
|
||
.Where(o => o.EventDefinitionId == null)
|
||
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
|
||
|
||
return competition
|
||
.Concat(special)
|
||
.OrderBy(o => o.StartTime)
|
||
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
|
||
}
|
||
|
||
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
|
||
{
|
||
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
|
||
{
|
||
yield return new EventSummaryRow(
|
||
StateRegistrationId: FormatStateRegistrationId(team, student),
|
||
EventName: team.Event.Name,
|
||
Activity: FormatActivitySummary(team, student));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
|
||
/// Individual events: competitor's <see cref="Student.StateId"/>.
|
||
/// </summary>
|
||
private string FormatStateRegistrationId(Team team, Student student)
|
||
{
|
||
if (team.Event.EventFormat == EventFormat.Individual)
|
||
{
|
||
return string.IsNullOrWhiteSpace(student.StateId)
|
||
? "—"
|
||
: student.StateId.Trim();
|
||
}
|
||
|
||
var chap = _chapterStateId?.Trim();
|
||
var ident = team.Identifier?.Trim();
|
||
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
|
||
return "—";
|
||
|
||
// Already a full registration id (e.g. "12227-1" or state id stored on team)
|
||
if (!string.IsNullOrEmpty(ident))
|
||
{
|
||
if (ident.Contains('-', StringComparison.Ordinal))
|
||
return ident;
|
||
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
|
||
return ident;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
|
||
return $"{chap}-{ident}";
|
||
return !string.IsNullOrEmpty(chap) ? chap : ident!;
|
||
}
|
||
|
||
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
|
||
private static string FormatActivitySummary(Team team, Student student)
|
||
{
|
||
var parts = new List<string>();
|
||
if (team.Captain?.Id == student.Id)
|
||
parts.Add("(Cpt.)");
|
||
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
|
||
parts.Add(team.Event.SemifinalistActivity!);
|
||
return string.Join(" ", parts).Trim();
|
||
}
|
||
|
||
private static string FormatDateHeading(DateTime date) =>
|
||
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
|
||
|
||
private static string FormatTimeDisplay(EventOccurrence o)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(o.Time))
|
||
return o.Time.Trim();
|
||
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
|
||
}
|
||
|
||
private static string FormatEventColumn(EventOccurrence o)
|
||
{
|
||
if (o.EventDefinition != null)
|
||
{
|
||
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
|
||
? o.EventDefinition.ShortName
|
||
: o.EventDefinition.Name;
|
||
if (string.IsNullOrWhiteSpace(o.Name))
|
||
return ev;
|
||
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
|
||
return o.Name.Trim();
|
||
return $"{ev} {o.Name}".Trim();
|
||
}
|
||
|
||
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
|
||
}
|
||
|
||
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
|
||
{
|
||
var baseText = FormatEventColumn(occ);
|
||
if (!occ.EventDefinitionId.HasValue)
|
||
return baseText;
|
||
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
|
||
return baseText;
|
||
|
||
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
|
||
|
||
var orderedTeams = teams
|
||
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
|
||
{
|
||
var cmp = CombinedScheduleTeamSortOrder(a, b);
|
||
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
|
||
}))
|
||
.ToList();
|
||
|
||
var rosterStrings = orderedTeams
|
||
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
|
||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||
.ToList();
|
||
|
||
if (rosterStrings.Count == 0)
|
||
return baseText;
|
||
|
||
var suffix = rosterStrings.Count == 1
|
||
? rosterStrings[0]
|
||
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
|
||
|
||
return $"{baseText} — {suffix}";
|
||
}
|
||
|
||
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
|
||
{
|
||
var ka = a.Identifier?.Trim() ?? "";
|
||
var kb = b.Identifier?.Trim() ?? "";
|
||
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
|
||
return na.CompareTo(nb);
|
||
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
|
||
{
|
||
var students = team.Students?.ToList() ?? [];
|
||
if (students.Count == 0)
|
||
return "";
|
||
|
||
if (isIndividual)
|
||
{
|
||
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||
}
|
||
|
||
var cap = team.Captain;
|
||
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
|
||
IEnumerable<Student> orderedTeam = capInRoster
|
||
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
|
||
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||
|
||
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||
}
|
||
|
||
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
|
||
{
|
||
if (isIndividual)
|
||
{
|
||
var sid = student.StateId?.Trim();
|
||
return !string.IsNullOrEmpty(sid)
|
||
? $"{student.FirstName} ({sid})"
|
||
: student.FirstName;
|
||
}
|
||
|
||
var isCpt = team.Captain?.Id == student.Id;
|
||
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
|
||
}
|
||
|
||
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
|
||
}
|