Add state schedule handout feature and configuration options

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.
This commit is contained in:
2026-04-06 23:33:57 -04:00
parent 4dcd9e5aab
commit a9036d5d04
10 changed files with 513 additions and 12 deletions
@@ -14,6 +14,9 @@
<MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip>
<MudTooltip Text="Schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
@@ -0,0 +1,349 @@
@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 (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);
}
@@ -19,6 +19,9 @@
<MudTooltip Text="Handout">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</MudTooltip>
<MudTooltip Text="State schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
</MudTooltip>
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
+5
View File
@@ -37,6 +37,11 @@ public class ChapterSettings
/// </summary>
public string CompetitionYear { get; set; } = "2026";
/// <summary>
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
/// </summary>
public string StateAbbrev { get; set; } = "ST";
/// <summary>
/// School level for the chapter (null = import both MS and HS events)
/// </summary>
@@ -0,0 +1,33 @@
namespace WebApp.Models;
/// <summary>
/// Per-student handout filters; section <see cref="SectionName"/>. Edit in Data/appsettings.json, save, refresh the page (no redeploy).
/// </summary>
public class StateScheduleHandoutOptions
{
public const string SectionName = "ChapterSettings:StateScheduleHandout";
/// <summary>
/// <see cref="Core.Entities.EventOccurrence.SpecialEventType"/> values allowed on student pages.
/// Gated in code (omit here): VotingDelegateMeeting, MeetTheCandidates, ChapterOfficerMeeting.
/// </summary>
public string[] StudentSpecialEventTypes { get; set; } =
[
"GeneralSchedule",
"SocialGathering"
];
/// <summary>
/// Occurrence <see cref="Core.Entities.EventOccurrence.Name"/> substrings that exclude a row from student pages (case-insensitive).
/// Master schedule still lists all occurrences.
/// </summary>
public string[] StudentExcludeOccurrenceNameSubstrings { get; set; } =
[
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
];
}
+12 -9
View File
@@ -11,6 +11,7 @@ using WebApp;
using WebApp.Authentication;
using WebApp.Components;
using WebApp.Logging;
using WebApp.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
});
// Configure authentication secrets for production (Docker, etc.)
// Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(authSecretsPath))
{
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
}
if (builder.Environment.IsProduction())
{
// Option 1: Load from volume-mounted secrets file in Data directory
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(secretsPath))
{
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
}
// Option 2: Environment variables with prefix
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
}
@@ -202,6 +202,9 @@ builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
// State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>();
+3 -2
View File
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
}
var teams = await _context.Teams
.Include(t => t.Event)
.Include(t => t.Students)
.Include(t => t.Captain)
.Where(t => ids.Contains(t.Event.Id))
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
.ToListAsync();
return teams
.GroupBy(t => t.Event.Id)
.GroupBy(t => t.Event!.Id)
.ToDictionary(g => g.Key, g => g.ToList());
}
}
@@ -0,0 +1,79 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Utility;
/// <summary>
/// Determines which special <see cref="EventOccurrence"/> rows belong on a per-student handout.
/// </summary>
public static class StateScheduleOccurrenceFilter
{
public static bool NameMatchesStudentExclude(string? name, StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(name)) return false;
foreach (var sub in options.StudentExcludeOccurrenceNameSubstrings ?? [])
{
if (string.IsNullOrWhiteSpace(sub)) continue;
if (name.Contains(sub.Trim(), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// True when the occurrence name refers to Meet the Candidates (covers General Schedule lines duplicated under another type).
/// </summary>
public static bool NameLooksLikeMeetTheCandidates(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Meet the Candidate", StringComparison.OrdinalIgnoreCase);
public static bool NameLooksLikeChapterOfficerMeeting(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Special rows have <see cref="EventOccurrence.EventDefinitionId"/> null and <see cref="EventOccurrence.SpecialEventType"/> set.
/// </summary>
public static bool IncludeSpecialOccurrenceForStudent(
EventOccurrence occurrence,
Student student,
StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(occurrence.SpecialEventType))
return false;
if (NameMatchesStudentExclude(occurrence.Name, options))
return false;
if (occurrence.SpecialEventType == "VotingDelegateMeeting")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "MeetTheCandidates")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "ChapterOfficerMeeting")
return student.OfficerRole.HasValue;
// Same event often appears once as MeetTheCandidates and again under GeneralSchedule; non-delegates should see neither.
if (!student.VotingDelegate && NameLooksLikeMeetTheCandidates(occurrence.Name))
return false;
// Chapter Officer Meeting lines under GeneralSchedule for non-officers
if (!student.OfficerRole.HasValue && NameLooksLikeChapterOfficerMeeting(occurrence.Name))
return false;
var allowed = options.StudentSpecialEventTypes ?? [];
return allowed.Contains(occurrence.SpecialEventType);
}
/// <summary>
/// Competition rows: optional name-based exclusion on student pages.
/// </summary>
public static bool IncludeCompetitionOccurrenceForStudent(EventOccurrence occurrence, StateScheduleHandoutOptions options)
{
if (occurrence.EventDefinitionId == null)
return false;
return !NameMatchesStudentExclude(occurrence.Name, options);
}
}
+16 -1
View File
@@ -16,7 +16,22 @@
"NationalId": "0000",
"StateId": "00000",
"RegionalId": "00000",
"CompetitionYear": "2026"
"CompetitionYear": "2026",
"StateAbbrev": "ST",
"StateScheduleHandout": {
"StudentSpecialEventTypes": [
"GeneralSchedule",
"SocialGathering"
],
"StudentExcludeOccurrenceNameSubstrings": [
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
]
}
},
"ValidationSettings": {
"MinRecommendedEvents": 2,
+10
View File
@@ -48,6 +48,16 @@
white-space: pre-wrap;
}
.state-schedule-table {
width: 100%;
}
.state-schedule-table th,
.state-schedule-table td {
vertical-align: top;
padding: 4px 8px;
}
.page-header {
margin-bottom: 1.5rem;
}