a9036d5d04
This commit introduces a new StateScheduleHandout component for generating printable schedules for students, including a combined master list of events. It adds configuration options in appsettings.json for state abbreviations and special event filters, enhancing the scheduling functionality. The Program.cs file is updated to register the new StateScheduleHandoutOptions, and the Calendar and Teams components are modified to include links to the new handout feature. Additionally, utility methods for filtering event occurrences are implemented to support the new functionality, improving the overall user experience in managing state schedules.
350 lines
14 KiB
Plaintext
350 lines
14 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
|
||
|
||
<PageHeader
|
||
Title="State schedule handout"
|
||
Description="Print per-student schedules and the combined master list (browser Print)."
|
||
Icon="@Icons.Material.Filled.Print"
|
||
ShowBackButton="true"
|
||
BackButtonUrl="/calendar" />
|
||
|
||
@if (_students == null || _allOccurrences == null)
|
||
{
|
||
<p><em>Loading...</em></p>
|
||
}
|
||
else
|
||
{
|
||
var opts = HandoutOptionsMonitor.CurrentValue;
|
||
|
||
<MudContainer>
|
||
@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>
|
||
<MudTable Items="@GetEventSummaryRows(student)" Dense="true" Hover="false" Class="mb-4 nobrk">
|
||
<HeaderContent>
|
||
<MudTh>State ID</MudTh>
|
||
<MudTh>Event</MudTh>
|
||
<MudTh>Activity</MudTh>
|
||
</HeaderContent>
|
||
<RowTemplate>
|
||
<MudTd DataLabel="State ID">@context.StateRegistrationId</MudTd>
|
||
<MudTd DataLabel="Event">@context.EventName</MudTd>
|
||
<MudTd DataLabel="Activity">@context.Activity</MudTd>
|
||
</RowTemplate>
|
||
</MudTable>
|
||
|
||
@{
|
||
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 (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);
|
||
}
|