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; }
Loading...