Files
chapter-organizer/Core/Utility/TeamStudentNameFormatter.cs
T
poprhythmandCursor 29101e2ead feat: add optional student nickname for informal display
Keep legal first and last names for formal lists; show DisplayFirstName on teams, calendars, and import matching so two Josiahs can be told apart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 00:06:33 -04:00

262 lines
9.7 KiB
C#

using Core.Entities;
namespace Core.Utility;
/// <summary>
/// Utility class for formatting student names for teams with various formatting options.
/// </summary>
public static class TeamStudentNameFormatter
{
/// <summary>
/// Options for formatting student names.
/// </summary>
public record FormatOptions
{
/// <summary>
/// Style for indicating the team captain. Default is None.
/// </summary>
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
/// <summary>
/// How to order the students. Default is None (preserve original order).
/// </summary>
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
/// <summary>
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
/// </summary>
public bool MarkOverlaps { get; init; } = false;
/// <summary>
/// Whether to add "(absent)" suffix for absent students. Default is false.
/// </summary>
public bool MarkAbsent { get; init; } = false;
/// <summary>
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
/// </summary>
public Func<Student, bool>? HasOverlaps { get; init; }
/// <summary>
/// Collection of absent students. Required if MarkAbsent is true.
/// </summary>
public ICollection<Student>? AbsentStudents { get; init; }
/// <summary>
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
/// </summary>
public bool OnlyTeamEventsForCaptain { get; init; } = true;
}
/// <summary>
/// Style for indicating the team captain.
/// </summary>
public enum CaptainIndicatorStyle
{
/// <summary>
/// No captain indicator.
/// </summary>
None,
/// <summary>
/// Use asterisk (*) for captain.
/// </summary>
Star,
/// <summary>
/// Use "(Cpt)" for captain.
/// </summary>
Captain
}
/// <summary>
/// Style for ordering students.
/// </summary>
public enum OrderingStyle
{
/// <summary>
/// Preserve original order.
/// </summary>
None,
/// <summary>
/// Captain first, then alphabetical by first name.
/// </summary>
CaptainFirst,
/// <summary>
/// Alphabetical by first name.
/// </summary>
Alphabetical,
/// <summary>
/// By grade + TSA year descending (highest first).
/// </summary>
GradeDescending
}
/// <summary>
/// Formats a single student name with the specified options.
/// </summary>
/// <param name="student">The student to format.</param>
/// <param name="team">The team the student belongs to.</param>
/// <param name="options">Formatting options.</param>
/// <returns>Formatted student name.</returns>
public static string FormatStudentName(Student student, Team team, FormatOptions options)
{
if (student == null)
return string.Empty;
var name = student.DisplayFirstName;
// 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.DisplayFirstName.Length);
return name + suffix;
}
/// <summary>
/// Formats all students for a team as a comma-separated string.
/// </summary>
/// <param name="team">The team to format students for.</param>
/// <param name="options">Formatting options.</param>
/// <returns>Comma-separated string of formatted student names.</returns>
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)));
}
/// <summary>
/// Formats all students for a team as a list of strings.
/// </summary>
/// <param name="team">The team to format students for.</param>
/// <param name="options">Formatting options.</param>
/// <returns>List of formatted student names.</returns>
public static List<string> 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();
}
/// <summary>
/// Formats all unique students across multiple teams for an event definition.
/// Returns a list of unique student names (by student ID).
/// </summary>
/// <param name="eventDefinition">The event definition.</param>
/// <param name="teams">The teams for this event.</param>
/// <param name="options">Formatting options.</param>
/// <returns>List of unique formatted student names.</returns>
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> 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.DisplayFirstName)
.Select(x => x.Student),
OrderingStyle.Alphabetical => studentsWithCaptainInfo
.OrderBy(x => x.Student.DisplayFirstName)
.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<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
{
return ordering switch
{
OrderingStyle.CaptainFirst => students
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
.ThenBy(s => s.DisplayFirstName),
OrderingStyle.Alphabetical => students.OrderBy(s => s.DisplayFirstName),
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
_ => students
};
}
}