Files
chapter-organizer/WebApp/Components/Features/Calendar/StateScheduleHandout.razor
T

359 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 occ in dateGroup.OrderBy(o => o.StartTime))
{
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatCombinedScheduleEventCell(occ)</td>
<td>@(occ.Location ?? "")</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 (StudentTeamStudent) 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);
}