From a9036d5d0417d16b2112a79cb8332956836f25ff Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Mon, 6 Apr 2026 23:33:57 -0400 Subject: [PATCH] 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. --- .../Components/Features/Calendar/Index.razor | 3 + .../Calendar/StateScheduleHandout.razor | 349 ++++++++++++++++++ WebApp/Components/Features/Teams/Index.razor | 3 + WebApp/Models/ChapterSettings.cs | 5 + WebApp/Models/StateScheduleHandoutOptions.cs | 33 ++ WebApp/Program.cs | 21 +- WebApp/Services/EventOccurrenceService.cs | 5 +- .../Utility/StateScheduleOccurrenceFilter.cs | 79 ++++ WebApp/appsettings.json | 17 +- WebApp/wwwroot/app.css | 10 + 10 files changed, 513 insertions(+), 12 deletions(-) create mode 100644 WebApp/Components/Features/Calendar/StateScheduleHandout.razor create mode 100644 WebApp/Models/StateScheduleHandoutOptions.cs create mode 100644 WebApp/Utility/StateScheduleOccurrenceFilter.cs diff --git a/WebApp/Components/Features/Calendar/Index.razor b/WebApp/Components/Features/Calendar/Index.razor index b530457..db10f67 100644 --- a/WebApp/Components/Features/Calendar/Index.razor +++ b/WebApp/Components/Features/Calendar/Index.razor @@ -14,6 +14,9 @@ Import + + Schedule handout + Admin diff --git a/WebApp/Components/Features/Calendar/StateScheduleHandout.razor b/WebApp/Components/Features/Calendar/StateScheduleHandout.razor new file mode 100644 index 0000000..af74d3e --- /dev/null +++ b/WebApp/Components/Features/Calendar/StateScheduleHandout.razor @@ -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 HandoutOptionsMonitor +@inject IEventOccurrenceService EventOccurrenceService + + + +@if (_students == null || _allOccurrences == null) +{ +

Loading...

+} +else +{ + var opts = HandoutOptionsMonitor.CurrentValue; + + + @foreach (var student in _students) + { + + + @if (string.IsNullOrWhiteSpace(student.StateId)) + { + @student.Name + } + else + { + @($"{student.Name} - {student.StateId}") + } + + + TSA @_competitionYear @_stateAbbrev State Schedule + + + Events + + + State ID + Event + Activity + + + @context.StateRegistrationId + @context.EventName + @context.Activity + + + + @{ + var scheduleRows = BuildStudentSchedule(student, opts).ToList(); + } + Schedule + @if (scheduleRows.Count == 0) + { + No schedule entries for imported occurrences. + } + else + { + @foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date)) + { + @FormatDateHeading(dateGroup.Key) + + + + Time + Event + Location + + + + @foreach (var occ in dateGroup.OrderBy(o => o.StartTime)) + { + + @FormatTimeDisplay(occ) + @FormatEventColumn(occ) + @(occ.Location ?? "") + + } + + + } + } + + } + + + Combined schedule + All imported occurrences for this chapter database. + @if (_allOccurrences.Count == 0) + { + No event occurrences in the database. Import a schedule from the calendar first. + } + @foreach (var dateGroup in _allOccurrences.GroupBy(o => o.StartTime.Date)) + { + @FormatDateHeading(dateGroup.Key) + + + + Time + Event + Location + + + + @foreach (var occ in dateGroup.OrderBy(o => o.StartTime)) + { + + @FormatTimeDisplay(occ) + @FormatCombinedScheduleEventCell(occ) + @(occ.Location ?? "") + + } + + + } + + +} + +@code { + private Student[]? _students; + private List? _allOccurrences; + private Dictionary> _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 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 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)); + } + } + + /// + /// Team events: chapter ChapterSettings:StateId + (e.g. 12227-1). + /// Individual events: competitor's . + /// + 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(); + 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.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 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); +} diff --git a/WebApp/Components/Features/Teams/Index.razor b/WebApp/Components/Features/Teams/Index.razor index a4c0ae8..c740991 100644 --- a/WebApp/Components/Features/Teams/Index.razor +++ b/WebApp/Components/Features/Teams/Index.razor @@ -19,6 +19,9 @@ Handout + + State schedule + public string CompetitionYear { get; set; } = "2026"; + /// + /// Postal state abbreviation for printed schedules (example placeholder: "ST"). + /// + public string StateAbbrev { get; set; } = "ST"; + /// /// School level for the chapter (null = import both MS and HS events) /// diff --git a/WebApp/Models/StateScheduleHandoutOptions.cs b/WebApp/Models/StateScheduleHandoutOptions.cs new file mode 100644 index 0000000..aab7221 --- /dev/null +++ b/WebApp/Models/StateScheduleHandoutOptions.cs @@ -0,0 +1,33 @@ +namespace WebApp.Models; + +/// +/// Per-student handout filters; section . Edit in Data/appsettings.json, save, refresh the page (no redeploy). +/// +public class StateScheduleHandoutOptions +{ + public const string SectionName = "ChapterSettings:StateScheduleHandout"; + + /// + /// values allowed on student pages. + /// Gated in code (omit here): VotingDelegateMeeting, MeetTheCandidates, ChapterOfficerMeeting. + /// + public string[] StudentSpecialEventTypes { get; set; } = + [ + "GeneralSchedule", + "SocialGathering" + ]; + + /// + /// Occurrence substrings that exclude a row from student pages (case-insensitive). + /// Master schedule still lists all occurrences. + /// + public string[] StudentExcludeOccurrenceNameSubstrings { get; set; } = + [ + "Store", + "TECHSPO", + "Tech Expo", + "Senior Social", + "Help Desk", + "Mandatory Advisor Meeting" + ]; +} diff --git a/WebApp/Program.cs b/WebApp/Program.cs index fb9ca34..c9626f4 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -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(); builder.Services.AddScoped(); +builder.Services.Configure( + builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); + // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); diff --git a/WebApp/Services/EventOccurrenceService.cs b/WebApp/Services/EventOccurrenceService.cs index 4b68798..e4decce 100644 --- a/WebApp/Services/EventOccurrenceService.cs +++ b/WebApp/Services/EventOccurrenceService.cs @@ -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()); } } diff --git a/WebApp/Utility/StateScheduleOccurrenceFilter.cs b/WebApp/Utility/StateScheduleOccurrenceFilter.cs new file mode 100644 index 0000000..6f3ebe5 --- /dev/null +++ b/WebApp/Utility/StateScheduleOccurrenceFilter.cs @@ -0,0 +1,79 @@ +using Core.Entities; +using WebApp.Models; + +namespace WebApp.Utility; + +/// +/// Determines which special rows belong on a per-student handout. +/// +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; + } + + /// + /// True when the occurrence name refers to Meet the Candidates (covers General Schedule lines duplicated under another type). + /// + 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); + + /// + /// Special rows have null and set. + /// + 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); + } + + /// + /// Competition rows: optional name-based exclusion on student pages. + /// + public static bool IncludeCompetitionOccurrenceForStudent(EventOccurrence occurrence, StateScheduleHandoutOptions options) + { + if (occurrence.EventDefinitionId == null) + return false; + return !NameMatchesStudentExclude(occurrence.Name, options); + } +} diff --git a/WebApp/appsettings.json b/WebApp/appsettings.json index 91d81b9..5a7697d 100644 --- a/WebApp/appsettings.json +++ b/WebApp/appsettings.json @@ -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, diff --git a/WebApp/wwwroot/app.css b/WebApp/wwwroot/app.css index 6185f5d..c77ef66 100644 --- a/WebApp/wwwroot/app.css +++ b/WebApp/wwwroot/app.css @@ -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; }