diff --git a/Core/Entities/Team.cs b/Core/Entities/Team.cs index 43efc9a..3b57ede 100644 --- a/Core/Entities/Team.cs +++ b/Core/Entities/Team.cs @@ -63,17 +63,4 @@ public class Team { return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}"; } - - public string StudentsFirstNames - { - get - { - return - string.Join(", ", - Students.Select(e => - e.FirstName - + (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : "")) - ); - } - } } \ No newline at end of file diff --git a/Core/Utility/StudentNameFormatter.cs b/Core/Utility/StudentNameFormatter.cs new file mode 100644 index 0000000..5044a5a --- /dev/null +++ b/Core/Utility/StudentNameFormatter.cs @@ -0,0 +1,53 @@ +using Core.Entities; + +namespace Core.Utility; + +/// +/// Utility class for formatting individual student names with overlap and absent markers. +/// +public static class StudentNameFormatter +{ + /// + /// Options for formatting student names. + /// + public record FormatOptions + { + /// + /// Whether the student is absent. If true, adds "(absent)" suffix. Default is false. + /// + public bool IsAbsent { get; init; } = false; + + /// + /// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false. + /// + public bool HasOverlap { get; init; } = false; + } + + /// + /// Formats a single student name with overlap and absent markers. + /// + /// The student to format. + /// Formatting options. + /// Formatted student name. + public static string FormatStudentName(Student student, FormatOptions options) + { + if (student == null) + return string.Empty; + + var name = student.FirstName; + + // Add overlap marker + if (options.HasOverlap) + { + name += "*"; + } + + // Add absent marker + if (options.IsAbsent) + { + name += " (absent)"; + } + + return name; + } +} diff --git a/Core/Utility/TeamStudentNameFormatter.cs b/Core/Utility/TeamStudentNameFormatter.cs new file mode 100644 index 0000000..d7a838a --- /dev/null +++ b/Core/Utility/TeamStudentNameFormatter.cs @@ -0,0 +1,261 @@ +using Core.Entities; + +namespace Core.Utility; + +/// +/// Utility class for formatting student names for teams with various formatting options. +/// +public static class TeamStudentNameFormatter +{ + /// + /// Options for formatting student names. + /// + public record FormatOptions + { + /// + /// Style for indicating the team captain. Default is None. + /// + public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None; + + /// + /// How to order the students. Default is None (preserve original order). + /// + public OrderingStyle Ordering { get; init; } = OrderingStyle.None; + + /// + /// Whether to add "*" suffix for students with schedule overlaps. Default is false. + /// + public bool MarkOverlaps { get; init; } = false; + + /// + /// Whether to add "(absent)" suffix for absent students. Default is false. + /// + public bool MarkAbsent { get; init; } = false; + + /// + /// Function to determine if a student has overlaps. Required if MarkOverlaps is true. + /// + public Func? HasOverlaps { get; init; } + + /// + /// Collection of absent students. Required if MarkAbsent is true. + /// + public ICollection? AbsentStudents { get; init; } + + /// + /// Whether to only show captain indicator for team events (EventFormat.Team). Default is true. + /// + public bool OnlyTeamEventsForCaptain { get; init; } = true; + } + + /// + /// Style for indicating the team captain. + /// + public enum CaptainIndicatorStyle + { + /// + /// No captain indicator. + /// + None, + + /// + /// Use asterisk (*) for captain. + /// + Star, + + /// + /// Use "(Cpt)" for captain. + /// + Captain + } + + /// + /// Style for ordering students. + /// + public enum OrderingStyle + { + /// + /// Preserve original order. + /// + None, + + /// + /// Captain first, then alphabetical by first name. + /// + CaptainFirst, + + /// + /// Alphabetical by first name. + /// + Alphabetical, + + /// + /// By grade + TSA year descending (highest first). + /// + GradeDescending + } + + /// + /// Formats a single student name with the specified options. + /// + /// The student to format. + /// The team the student belongs to. + /// Formatting options. + /// Formatted student name. + public static string FormatStudentName(Student student, Team team, FormatOptions options) + { + if (student == null) + return string.Empty; + + var name = student.FirstName; + + // Add captain indicator (before overlap/absent markers) + if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student)) + { + var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team); + if (shouldShow) + { + name += options.CaptainIndicator switch + { + CaptainIndicatorStyle.Star => "*", + CaptainIndicatorStyle.Captain => "(Cpt)", + _ => string.Empty + }; + } + } + + // Convert collections/functions to booleans for StudentNameFormatter + var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student); + var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student); + + // Use StudentNameFormatter for overlap/absent markers (appended after captain indicator) + var studentNameOptions = new StudentNameFormatter.FormatOptions + { + HasOverlap = hasOverlap, + IsAbsent = isAbsent + }; + + // Get the suffix from StudentNameFormatter (overlap/absent markers) + var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions); + var suffix = baseFormatted.Substring(student.FirstName.Length); + + return name + suffix; + } + + /// + /// Formats all students for a team as a comma-separated string. + /// + /// The team to format students for. + /// Formatting options. + /// Comma-separated string of formatted student names. + public static string FormatStudentList(Team team, FormatOptions options) + { + if (team?.Students == null || !team.Students.Any()) + return string.Empty; + + var students = ApplyOrdering(team.Students, team, options.Ordering); + + return string.Join(", ", students.Select(s => FormatStudentName(s, team, options))); + } + + /// + /// Formats all students for a team as a list of strings. + /// + /// The team to format students for. + /// Formatting options. + /// List of formatted student names. + public static List FormatStudentListAsList(Team team, FormatOptions options) + { + if (team?.Students == null || !team.Students.Any()) + return []; + + var students = ApplyOrdering(team.Students, team, options.Ordering); + + return students.Select(s => FormatStudentName(s, team, options)).ToList(); + } + + /// + /// Formats all unique students across multiple teams for an event definition. + /// Returns a list of unique student names (by student ID). + /// + /// The event definition. + /// The teams for this event. + /// Formatting options. + /// List of unique formatted student names. + public static List FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable teams, FormatOptions options) + { + if (eventDefinition == null || teams == null) + return []; + + var teamsList = teams.ToList(); + if (!teamsList.Any()) + return []; + + // Get all unique students from all teams for this event + var allStudents = teamsList + .SelectMany(t => t.Students) + .DistinctBy(s => s.Id) + .ToList(); + + if (!allStudents.Any()) + return []; + + // Determine if each student is a captain in any team + var studentsWithCaptainInfo = allStudents.Select(s => + { + var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s)); + // Find a team this student belongs to for formatting context + var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s)); + return new { Student = s, IsCaptain = isCaptain, Team = studentTeam }; + }).ToList(); + + // Apply ordering + var orderedStudents = options.Ordering switch + { + OrderingStyle.CaptainFirst => studentsWithCaptainInfo + .OrderBy(x => !x.IsCaptain) + .ThenBy(x => x.Student.FirstName) + .Select(x => x.Student), + OrderingStyle.Alphabetical => studentsWithCaptainInfo + .OrderBy(x => x.Student.FirstName) + .Select(x => x.Student), + OrderingStyle.GradeDescending => studentsWithCaptainInfo + .OrderByDescending(x => x.Student.Grade + x.Student.TsaYear) + .Select(x => x.Student), + _ => studentsWithCaptainInfo.Select(x => x.Student) + }; + + // Format names - for event-level formatting, we need to handle captain indicator specially + // since a student might be captain in one team but not another + return orderedStudents.Select(s => + { + var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s)); + // Use the student's team if available, otherwise create a minimal team for formatting context + var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition }; + + // Create options that handle captain indicator for event-level + var eventOptions = options with + { + CaptainIndicator = studentInfo.IsCaptain && + (!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team) + ? options.CaptainIndicator + : CaptainIndicatorStyle.None + }; + + return FormatStudentName(s, team, eventOptions); + }).ToList(); + } + + private static IEnumerable ApplyOrdering(IEnumerable students, Team team, OrderingStyle ordering) + { + return ordering switch + { + OrderingStyle.CaptainFirst => students + .OrderBy(s => team.Captain == null || !team.Captain.Equals(s)) + .ThenBy(s => s.FirstName), + OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName), + OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear), + _ => students + }; + } +} diff --git a/WebApp/Components/Features/Calendar/Index.razor b/WebApp/Components/Features/Calendar/Index.razor index 81d2297..1feb986 100644 --- a/WebApp/Components/Features/Calendar/Index.razor +++ b/WebApp/Components/Features/Calendar/Index.razor @@ -5,6 +5,7 @@ @using Heron.MudCalendar @using Microsoft.Extensions.Logging @using WebApp.Authentication +@using Core.Utility @inject IEventOccurrenceService EventOccurrenceService @inject ILogger Logger @inject IDialogService DialogService @@ -110,8 +111,15 @@ } // Get student first names for this event definition - var studentFirstNames = occ.EventDefinition != null - ? StudentFirstNames(occ.EventDefinition, teamsByEventId) + var studentFirstNames = occ.EventDefinition != null && teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams) + ? TeamStudentNameFormatter.FormatStudentListForEvent( + occ.EventDefinition, + teams, + new TeamStudentNameFormatter.FormatOptions + { + CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star, + Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical + }) : []; var calendarItem = new CalendarEventItem(occ, studentFirstNames); @@ -143,34 +151,6 @@ } } - private static List StudentFirstNames(EventDefinition ed, Dictionary> teamsByEventId) - { - List studentFirstNames = []; - if (ed?.Id == null || !teamsByEventId.TryGetValue(ed.Id, out var teams)) return studentFirstNames; - - // Get all unique student first names from all teams for this event - // Include captain indicator (*) for team events - var allStudents = teams - .SelectMany(t => t.Students) - .DistinctBy(s => s.Id) // Ensure uniqueness by student ID - .Select(s => - { - var isCaptain = teams.Any(t => t.Captain?.Id == s.Id); - var name = s.FirstName; - // Add star for captain in team events (EventFormat == Team) - if (isCaptain && ed.EventFormat == Core.Entities.EventFormat.Team) - { - name += "*"; - } - return name; - }) - .OrderBy(name => name) - .ToList(); - - studentFirstNames = allStudents; - - return studentFirstNames; - } private DateTime GetNextDateWithEvents() { diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index 5ad3abe..cbc8755 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -4,6 +4,7 @@ @using Core.Calculation @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components +@using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @inject ClipboardService ClipboardService @@ -380,21 +381,44 @@ private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot) { - return string.Join(", ", - team.Students - .OrderBy(e => e == team.Captain) - .ThenBy(e => e.FirstName) - .Select(e => FormatStudentName(e, timeslot))); + return TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst, + MarkOverlaps = true, + HasOverlaps = timeslot.StudentHasOverlaps, + MarkAbsent = true, + AbsentStudents = _absentStudents.ToList() + }); } private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot) { - var name = student.FirstName; - if (timeslot.StudentHasOverlaps(student)) - name += "*"; - if (_absentStudents.Contains(student)) - name += " (absent)"; - return name; + // Find the team this student belongs to for formatting context + var team = _teams.FirstOrDefault(t => t.Students.Contains(student)); + if (team == null) + { + // No team context, use StudentNameFormatter directly + return StudentNameFormatter.FormatStudentName( + student, + new StudentNameFormatter.FormatOptions + { + HasOverlap = timeslot.StudentHasOverlaps(student), + IsAbsent = _absentStudents.Contains(student) + }); + } + + return TeamStudentNameFormatter.FormatStudentName( + student, + team, + new TeamStudentNameFormatter.FormatOptions + { + MarkOverlaps = true, + HasOverlaps = timeslot.StudentHasOverlaps, + MarkAbsent = true, + AbsentStudents = _absentStudents.ToList() + }); } private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot) @@ -407,9 +431,12 @@ foreach (var student in timeslot.UnscheduledStudents) { - var studentName = student.FirstName; - if (_absentStudents.Contains(student)) - studentName += " (absent)"; + var studentName = StudentNameFormatter.FormatStudentName( + student, + new StudentNameFormatter.FormatOptions + { + IsAbsent = _absentStudents.Contains(student) + }); var unassignedTeams = _solution.StudentUnassignedTeams(student); var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString())); diff --git a/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor b/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor index 7c033e5..70045ce 100644 --- a/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor +++ b/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor @@ -1,5 +1,6 @@ @using Core.Calculation @using WebApp.Models +@using Core.Utility @TimeSlotName @@ -8,31 +9,54 @@ var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet(); var removed = !scheduledTeamIds.Contains(team.Id); - - - - @team - - @foreach (var student in team.Students) - { - var overlap = StudentHasOverlaps(student); - var isAbsent = AbsentStudents.Contains(student); - var color = overlap ? Color.Warning : Color.Default; - var suffix = GetStudentSuffix(overlap, isAbsent); - - if (student != team.Students.First()) - { - , + + + + + @{ + var teamMembers = TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.None + }); } - - @student.FirstName@suffix - - } - + + OnToggleTeam.InvokeAsync(team))" style="cursor: pointer; display: inline-block;"> + + @team + + + + + + @foreach (var student in team.Students) + { + var overlap = StudentHasOverlaps(student); + var chipColor = overlap ? Color.Warning : Color.Default; + var formattedName = TeamStudentNameFormatter.FormatStudentName( + student, + team, + new TeamStudentNameFormatter.FormatOptions + { + MarkOverlaps = true, + HasOverlaps = StudentHasOverlaps, + MarkAbsent = true, + AbsentStudents = AbsentStudents.ToList() + }); + + + @formattedName + + } + + } @@ -54,11 +78,4 @@ [Parameter] public EventCallback OnToggleTeam { get; set; } - - private string GetStudentSuffix(bool overlap, bool isAbsent) - { - var suffix = overlap ? "*" : ""; - suffix += isAbsent ? " (absent)" : ""; - return suffix; - } } diff --git a/WebApp/Components/Features/MeetingSchedule/UnscheduledStudentsList.razor b/WebApp/Components/Features/MeetingSchedule/UnscheduledStudentsList.razor index 12b90d8..04b6f19 100644 --- a/WebApp/Components/Features/MeetingSchedule/UnscheduledStudentsList.razor +++ b/WebApp/Components/Features/MeetingSchedule/UnscheduledStudentsList.razor @@ -1,4 +1,5 @@ @using Core.Calculation +@using Core.Utility @if (UnscheduledStudents.Any()) { @@ -6,32 +7,51 @@ @foreach (var student in UnscheduledStudents) { - var isAbsent = AbsentStudents.Contains(student); - - @student.FirstName@(isAbsent ? " (absent)" : "") - - @foreach (var unassignedTeam in UnassignedTeams(student)) - { - var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer()); - var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet(); - var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id); - var color = isPossibleAddition ? Color.Success : Color.Default; - - if (unassignedTeam != UnassignedTeams(student).First()) - { - , - } - - - - @unassignedTeam - + @{ + var formattedName = StudentNameFormatter.FormatStudentName( + student, + new StudentNameFormatter.FormatOptions + { + IsAbsent = AbsentStudents.Contains(student) + }); } + + + + @formattedName + + + + @foreach (var unassignedTeam in UnassignedTeams(student)) + { + var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer()); + var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet(); + var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id); + var chipColor = isPossibleAddition ? Color.Success : Color.Default; + var teamMembers = TeamStudentNameFormatter.FormatStudentList( + unassignedTeam, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.None + }); + + + OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;"> + + @if (isScheduled) + { + + } + @unassignedTeam + + + + } + + } diff --git a/WebApp/Components/Features/Students/Registration.razor b/WebApp/Components/Features/Students/Registration.razor index 414823e..af22628 100644 --- a/WebApp/Components/Features/Students/Registration.razor +++ b/WebApp/Components/Features/Students/Registration.razor @@ -4,6 +4,7 @@ @using WebApp.Models @using WebApp.Components.Shared.Components @using Core.Validation +@using Core.Utility @inject AppDbContext Context @inject WebApp.LocalStorageService LocalStorage @inject ValidationService ValidationService @@ -83,7 +84,12 @@ @foreach (var team in teamsToDisplay) { var isCaptain = team.Captain != null && team.Captain.Equals(context.Item.Student); - var teamMembers = string.Join(", ", team.Students.Select(s => s.FirstName)); + var teamMembers = TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.None + }); e.Event.Name)) { - - - @team.ToString() + + + @team.ToString() @if (ShowEventAttributes) { } - + } diff --git a/WebApp/Components/Features/Teams/Goals.razor b/WebApp/Components/Features/Teams/Goals.razor index 22e29d6..5521906 100644 --- a/WebApp/Components/Features/Teams/Goals.razor +++ b/WebApp/Components/Features/Teams/Goals.razor @@ -3,6 +3,7 @@ @using Microsoft.EntityFrameworkCore @using WebApp.Models @using WebApp.Components.Shared.Components +@using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @@ -35,7 +36,12 @@ else @if (team.Event.EventFormat == EventFormat.Team) { - (Team: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName))) + (Team: @TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending + })) } diff --git a/WebApp/Components/Features/Teams/Handout.razor b/WebApp/Components/Features/Teams/Handout.razor index 1e82e16..cd7aa6b 100644 --- a/WebApp/Components/Features/Teams/Handout.razor +++ b/WebApp/Components/Features/Teams/Handout.razor @@ -3,6 +3,7 @@ @using Microsoft.EntityFrameworkCore @using WebApp.Models @using WebApp.Components.Shared.Components +@using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @@ -57,7 +58,12 @@ else { - Team Members: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName)) + Team Members: @TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending + }) } diff --git a/WebApp/Components/Features/Teams/Index.razor b/WebApp/Components/Features/Teams/Index.razor index 094678f..7ab0322 100644 --- a/WebApp/Components/Features/Teams/Index.razor +++ b/WebApp/Components/Features/Teams/Index.razor @@ -128,6 +128,7 @@ = Context.Teams .AsNoTracking() .Include(e => e.Event) + .Include(e => e.Captain) .Include(e => e.Students) .ThenInclude(e => e.EventRankings); diff --git a/WebApp/Components/Features/Teams/Printout.razor b/WebApp/Components/Features/Teams/Printout.razor index f93cdc8..6d3eea2 100644 --- a/WebApp/Components/Features/Teams/Printout.razor +++ b/WebApp/Components/Features/Teams/Printout.razor @@ -3,6 +3,7 @@ @using Microsoft.EntityFrameworkCore @using WebApp.Models @using WebApp.Components.Shared.Components +@using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @@ -56,7 +57,13 @@ else .Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue; - @student.Name @if(context?.Captain == student) { (Cpt)} + @TeamStudentNameFormatter.FormatStudentName( + student, + context, + new TeamStudentNameFormatter.FormatOptions + { + CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain + }) } else @@ -191,7 +198,12 @@ else ❔ } - @string.Join(", ", team.Students.Where(e => e != context).Select(e => e.FirstName)) + @TeamStudentNameFormatter.FormatStudentList( + new Team { Students = team.Students.Where(e => e != context).ToList(), Event = team.Event }, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.None + })