diff --git a/Core/Entities/Student.cs b/Core/Entities/Student.cs index 9a014e1..478870d 100644 --- a/Core/Entities/Student.cs +++ b/Core/Entities/Student.cs @@ -17,6 +17,10 @@ public class Student : IEquatable [Display(Name = "Last Name")] public string LastName { get; set; } = null!; + [StringLength(50)] + [Display(Name = "Nickname")] + public string? Nickname { get; set; } + [Range(5,12)] [Display(Name = "Grade")] public int Grade { get; set; } @@ -55,6 +59,17 @@ public class Student : IEquatable public string FirstNameLastName => $"{FirstName} {LastName}"; + /// + /// Nickname when set, otherwise . + /// + public string DisplayFirstName => + string.IsNullOrWhiteSpace(Nickname) ? FirstName : Nickname.Trim(); + + public void NormalizeNickname() + { + Nickname = string.IsNullOrWhiteSpace(Nickname) ? null : Nickname.Trim(); + } + public static Tuple ParseNameParts(string fullName) { var match = Match(fullName, @"(.*),\s*(.*)"); @@ -67,10 +82,7 @@ public class Student : IEquatable : new Tuple(fullName, string.Empty); } - public override string ToString() - { - return FirstName; - } + public override string ToString() => DisplayFirstName; public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident; diff --git a/Core/Parsers/AssignmentRequirementParser.cs b/Core/Parsers/AssignmentRequirementParser.cs index ab9cb98..a94febd 100644 --- a/Core/Parsers/AssignmentRequirementParser.cs +++ b/Core/Parsers/AssignmentRequirementParser.cs @@ -9,6 +9,10 @@ public class AssignmentRequirementParser : CsvParserBase { } + public AssignmentRequirementParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) + { + } + public AssignmentRequirement[] Parse(ICollection events, ICollection students) { var assumptions = new List(); @@ -20,7 +24,11 @@ public class AssignmentRequirementParser : CsvParserBase var studentArray = studentColumns - .Select(c => students.FirstOrDefault(s => s.FirstName == c)).ToArray(); + .Select(c => students.FirstOrDefault(s => + string.Equals(s.DisplayFirstName, c, StringComparison.OrdinalIgnoreCase) + || string.Equals(s.Nickname, c, StringComparison.OrdinalIgnoreCase) + || string.Equals(s.FirstName, c, StringComparison.OrdinalIgnoreCase))) + .ToArray(); while (CsvReader.Read()) { diff --git a/Core/Parsers/FuzzyStudentMatcher.cs b/Core/Parsers/FuzzyStudentMatcher.cs index 43051ea..93d7ad0 100644 --- a/Core/Parsers/FuzzyStudentMatcher.cs +++ b/Core/Parsers/FuzzyStudentMatcher.cs @@ -23,7 +23,14 @@ public static class FuzzyStudentMatcher public static int Score(Student student, string name) { - var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName }; + var candidates = new[] + { + student.Name, + student.FirstNameLastName, + student.LastNameFirstName, + student.DisplayFirstName, + student.Nickname + }.Where(c => !string.IsNullOrWhiteSpace(c)); return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name))); } } diff --git a/Core/Parsers/TeamParser.cs b/Core/Parsers/TeamParser.cs index 86a49c6..468885b 100644 --- a/Core/Parsers/TeamParser.cs +++ b/Core/Parsers/TeamParser.cs @@ -56,6 +56,10 @@ namespace Core.Parsers { } + public TeamParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) + { + } + public Team[] Parse(ICollection events, ICollection students) { var teams = new List(); @@ -118,7 +122,9 @@ namespace Core.Parsers { Fuzz.Ratio(s.Name, studentName), Fuzz.Ratio(s.FirstNameLastName, studentName), - Fuzz.Ratio(s.FirstName, studentName) + Fuzz.Ratio(s.FirstName, studentName), + Fuzz.Ratio(s.DisplayFirstName, studentName), + string.IsNullOrWhiteSpace(s.Nickname) ? 0 : Fuzz.Ratio(s.Nickname, studentName) }.Max() where rat > 90 orderby rat descending diff --git a/Core/Utility/StudentNameFormatter.cs b/Core/Utility/StudentNameFormatter.cs index 5044a5a..bc33ead 100644 --- a/Core/Utility/StudentNameFormatter.cs +++ b/Core/Utility/StudentNameFormatter.cs @@ -34,7 +34,7 @@ public static class StudentNameFormatter if (student == null) return string.Empty; - var name = student.FirstName; + var name = student.DisplayFirstName; // Add overlap marker if (options.HasOverlap) diff --git a/Core/Utility/TeamStudentNameFormatter.cs b/Core/Utility/TeamStudentNameFormatter.cs index d7a838a..a87a387 100644 --- a/Core/Utility/TeamStudentNameFormatter.cs +++ b/Core/Utility/TeamStudentNameFormatter.cs @@ -107,7 +107,7 @@ public static class TeamStudentNameFormatter if (student == null) return string.Empty; - var name = student.FirstName; + 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)) @@ -137,7 +137,7 @@ public static class TeamStudentNameFormatter // Get the suffix from StudentNameFormatter (overlap/absent markers) var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions); - var suffix = baseFormatted.Substring(student.FirstName.Length); + var suffix = baseFormatted.Substring(student.DisplayFirstName.Length); return name + suffix; } @@ -214,10 +214,10 @@ public static class TeamStudentNameFormatter { OrderingStyle.CaptainFirst => studentsWithCaptainInfo .OrderBy(x => !x.IsCaptain) - .ThenBy(x => x.Student.FirstName) + .ThenBy(x => x.Student.DisplayFirstName) .Select(x => x.Student), OrderingStyle.Alphabetical => studentsWithCaptainInfo - .OrderBy(x => x.Student.FirstName) + .OrderBy(x => x.Student.DisplayFirstName) .Select(x => x.Student), OrderingStyle.GradeDescending => studentsWithCaptainInfo .OrderByDescending(x => x.Student.Grade + x.Student.TsaYear) @@ -252,8 +252,8 @@ public static class TeamStudentNameFormatter { OrderingStyle.CaptainFirst => students .OrderBy(s => team.Captain == null || !team.Captain.Equals(s)) - .ThenBy(s => s.FirstName), - OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName), + .ThenBy(s => s.DisplayFirstName), + OrderingStyle.Alphabetical => students.OrderBy(s => s.DisplayFirstName), OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear), _ => students }; diff --git a/Core/YearTransition/YearTransitionPlanner.cs b/Core/YearTransition/YearTransitionPlanner.cs index a7ad58b..6a57d27 100644 --- a/Core/YearTransition/YearTransitionPlanner.cs +++ b/Core/YearTransition/YearTransitionPlanner.cs @@ -242,7 +242,9 @@ public static class YearTransitionPlanner // Exact full-name matches first var fullMatches = students.Where(s => comparer.Equals(s.FirstNameLastName, line) || - comparer.Equals(s.LastNameFirstName, line)).ToList(); + comparer.Equals(s.LastNameFirstName, line) || + comparer.Equals($"{s.DisplayFirstName} {s.LastName}", line) || + comparer.Equals($"{s.LastName}, {s.DisplayFirstName}", line)).ToList(); if (fullMatches.Count > 0) return fullMatches; @@ -251,8 +253,10 @@ public static class YearTransitionPlanner return []; return students.Where(s => - comparer.Equals(s.FirstName.Trim(), first) && - comparer.Equals(s.LastName.Trim(), last)); + comparer.Equals(s.LastName.Trim(), last) && + (comparer.Equals(s.FirstName.Trim(), first) + || comparer.Equals(s.DisplayFirstName, first) + || comparer.Equals(s.Nickname?.Trim(), first))); } /// diff --git a/Data/Configurations/StudentConfiguration.cs b/Data/Configurations/StudentConfiguration.cs index 30421c9..e92b9df 100644 --- a/Data/Configurations/StudentConfiguration.cs +++ b/Data/Configurations/StudentConfiguration.cs @@ -24,6 +24,9 @@ namespace Data.Configurations .IsRequired() .HasMaxLength(100); + builder.Property(s => s.Nickname) + .HasMaxLength(100); + builder.Property(s => s.Email) .HasMaxLength(255); diff --git a/Data/Migrations/20260911190000_AddStudentNickname.cs b/Data/Migrations/20260911190000_AddStudentNickname.cs new file mode 100644 index 0000000..a8bb703 --- /dev/null +++ b/Data/Migrations/20260911190000_AddStudentNickname.cs @@ -0,0 +1,33 @@ +using Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260911190000_AddStudentNickname")] + public partial class AddStudentNickname : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Nickname", + table: "Students", + type: "TEXT", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Nickname", + table: "Students"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index a75c4cc..74364af 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -322,6 +322,10 @@ namespace Data.Migrations .HasMaxLength(100) .HasColumnType("TEXT"); + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + b.Property("NationalId") .HasMaxLength(50) .HasColumnType("TEXT"); diff --git a/Tests/Builders/StudentBuilder.cs b/Tests/Builders/StudentBuilder.cs index 5b06e3e..c9e4234 100644 --- a/Tests/Builders/StudentBuilder.cs +++ b/Tests/Builders/StudentBuilder.cs @@ -12,6 +12,7 @@ public class StudentBuilder private int _id = _idCounter++; private string _firstName = "Test"; private string _lastName = "Student"; + private string? _nickname = null; private int _grade = 9; private string? _email = null; private string? _phoneNumber = null; @@ -41,6 +42,12 @@ public class StudentBuilder return this; } + public StudentBuilder WithNickname(string? nickname) + { + _nickname = nickname; + return this; + } + public StudentBuilder WithGrade(int grade) { _grade = grade; @@ -126,6 +133,7 @@ public class StudentBuilder Id = _id, FirstName = _firstName, LastName = _lastName, + Nickname = _nickname, Grade = _grade, Email = _email, PhoneNumber = _phoneNumber, diff --git a/Tests/Entities/Student_Tests.cs b/Tests/Entities/Student_Tests.cs new file mode 100644 index 0000000..856b3a5 --- /dev/null +++ b/Tests/Entities/Student_Tests.cs @@ -0,0 +1,57 @@ +using Tests.Builders; + +namespace Tests.Entities; + +[TestFixture] +public class Student_Tests +{ + [Test] + public void DisplayFirstName_FallsBackToFirstName() + { + var student = StudentBuilder.Create("Josiah", "Brown").Build(); + + Assert.That(student.DisplayFirstName, Is.EqualTo("Josiah")); + Assert.That(student.ToString(), Is.EqualTo("Josiah")); + } + + [Test] + public void DisplayFirstName_UsesTrimmedNickname() + { + var student = StudentBuilder.Create("Josiah", "Brown").WithNickname(" Jo ").Build(); + + Assert.That(student.DisplayFirstName, Is.EqualTo("Jo")); + Assert.That(student.ToString(), Is.EqualTo("Jo")); + } + + [Test] + public void LegalNameProperties_IgnoreNickname() + { + var student = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + + Assert.That(student.FirstName, Is.EqualTo("Josiah")); + Assert.That(student.LastName, Is.EqualTo("Brown")); + Assert.That(student.Name, Is.EqualTo("Josiah Brown")); + Assert.That(student.FirstNameLastName, Is.EqualTo("Josiah Brown")); + Assert.That(student.LastNameFirstName, Is.EqualTo("Brown, Josiah")); + } + + [Test] + public void DisplayFirstName_TreatsWhitespaceAsUnset() + { + var student = StudentBuilder.Create("Josiah", "Brown").WithNickname(" ").Build(); + + Assert.That(student.DisplayFirstName, Is.EqualTo("Josiah")); + } + + [Test] + public void NormalizeNickname_ClearsBlankAndTrims() + { + var blank = StudentBuilder.Create("Josiah", "Brown").WithNickname(" ").Build(); + blank.NormalizeNickname(); + Assert.That(blank.Nickname, Is.Null); + + var nick = StudentBuilder.Create("Josiah", "Brown").WithNickname(" Jo ").Build(); + nick.NormalizeNickname(); + Assert.That(nick.Nickname, Is.EqualTo("Jo")); + } +} diff --git a/Tests/Parsers/AssignmentRequirement_Tests.cs b/Tests/Parsers/AssignmentRequirement_Tests.cs index 7810689..879a60a 100644 --- a/Tests/Parsers/AssignmentRequirement_Tests.cs +++ b/Tests/Parsers/AssignmentRequirement_Tests.cs @@ -1,3 +1,9 @@ +using System.Text; +using Core.Entities; +using Core.Models; +using Core.Parsers; +using Tests.Builders; + namespace Tests.Parsers; public class AssignmentRequirement_Tests @@ -16,4 +22,53 @@ public class AssignmentRequirement_Tests Assert.Pass(); } + + [Test] + public void Parse_MatchesNicknameColumnsForTwoJosiahs() + { + StudentBuilder.ResetIdCounter(); + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build(); + var coding = EventDefinitionBuilder.Individual("Coding").WithShortName("Coding").Build(); + + const string csv = """ + ,Jo,josiah b + Coding,i,x + """; + + var requirements = ParseCsv(csv, [coding], [jo, josiahB]); + + Assert.That(requirements, Has.Length.EqualTo(2)); + Assert.That(requirements.Single(r => r.Student.Id == jo.Id).Requirement, Is.EqualTo(Requirement.Include)); + Assert.That(requirements.Single(r => r.Student.Id == josiahB.Id).Requirement, Is.EqualTo(Requirement.Exclude)); + } + + [Test] + public void Parse_SharedLegalFirstName_TakesFirstStudent() + { + StudentBuilder.ResetIdCounter(); + var first = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var second = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build(); + var coding = EventDefinitionBuilder.Individual("Coding").WithShortName("Coding").Build(); + + const string csv = """ + ,Josiah + Coding,i + """; + + var requirements = ParseCsv(csv, [coding], [first, second]); + + Assert.That(requirements, Has.Length.EqualTo(1)); + Assert.That(requirements[0].Student.Id, Is.EqualTo(first.Id)); + } + + private static AssignmentRequirement[] ParseCsv( + string csv, + ICollection events, + ICollection students) + { + using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv))); + using var parser = new AssignmentRequirementParser(reader); + return parser.Parse(events, students); + } } \ No newline at end of file diff --git a/Tests/Parsers/FuzzyStudentMatcher_Tests.cs b/Tests/Parsers/FuzzyStudentMatcher_Tests.cs new file mode 100644 index 0000000..bb357f2 --- /dev/null +++ b/Tests/Parsers/FuzzyStudentMatcher_Tests.cs @@ -0,0 +1,41 @@ +using Core.Parsers; +using Tests.Builders; + +namespace Tests.Parsers; + +[TestFixture] +public class FuzzyStudentMatcher_Tests +{ + [SetUp] + public void SetUp() => StudentBuilder.ResetIdCounter(); + + [Test] + public void Find_MatchesNicknameAmongTwoJosiahs() + { + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build(); + + var match = FuzzyStudentMatcher.Find([jo, josiahB], "Jo"); + + Assert.That(match, Is.Not.Null); + Assert.That(match!.Value.Student.Id, Is.EqualTo(jo.Id)); + } + + [Test] + public void Score_LegalNameStillMatchesWhenNicknameSet() + { + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + + Assert.That(FuzzyStudentMatcher.Score(jo, "Josiah Brown"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold)); + Assert.That(FuzzyStudentMatcher.Score(jo, "Brown, Josiah"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold)); + } + + [Test] + public void Score_NullNicknameDoesNotThrow() + { + var student = StudentBuilder.Create("Aria", "Cole").Build(); + + Assert.That(FuzzyStudentMatcher.Score(student, "Aria Cole"), Is.GreaterThanOrEqualTo(FuzzyStudentMatcher.MatchThreshold)); + Assert.That(FuzzyStudentMatcher.Find([student], "Nobody"), Is.Null); + } +} diff --git a/Tests/Parsers/StudentEventRankingParser_Tests.cs b/Tests/Parsers/StudentEventRankingParser_Tests.cs index e988db1..8236c47 100644 --- a/Tests/Parsers/StudentEventRankingParser_Tests.cs +++ b/Tests/Parsers/StudentEventRankingParser_Tests.cs @@ -68,6 +68,21 @@ public class StudentEventRankingParser_Tests Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria)); } + [Test] + public void Parse_NicknamePlusLastName_MatchesStudent() + { + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + + var result = ParseCsv(""" + Student Name,1 + Jo Brown,Coding + """, [jo], _events); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(jo)); + } + [Test] public void Parse_ShortName_MatchesEvent() { diff --git a/Tests/Parsers/StudentNotesFieldParser_Tests.cs b/Tests/Parsers/StudentNotesFieldParser_Tests.cs index 5a38330..4d1b04b 100644 --- a/Tests/Parsers/StudentNotesFieldParser_Tests.cs +++ b/Tests/Parsers/StudentNotesFieldParser_Tests.cs @@ -34,6 +34,22 @@ public class StudentNotesFieldParser_Tests Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here")); } + [Test] + public void Parse_MatchesNicknamePlusLastName() + { + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + const string csv = """ + Student Name,Interview Time + Jo Brown,3:20-3:35 + """; + + var result = Parse(csv, [jo]); + + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Matches, Has.Count.EqualTo(1)); + Assert.That(result.Matches[0].Student, Is.SameAs(jo)); + } + [Test] public void Parse_SameExistingNote_HasNoChanges() { diff --git a/Tests/Parsers/TeamParser_Tests.cs b/Tests/Parsers/TeamParser_Tests.cs index cdda555..8ac7fa8 100644 --- a/Tests/Parsers/TeamParser_Tests.cs +++ b/Tests/Parsers/TeamParser_Tests.cs @@ -1,3 +1,7 @@ +using System.Text; +using Core.Parsers; +using Tests.Builders; + namespace Tests.Parsers; public class TeamParser_Tests @@ -37,4 +41,26 @@ public class TeamParser_Tests } } + [Test] + public void Parse_MatchesNicknameAndLegalStudentColumns() + { + StudentBuilder.ResetIdCounter(); + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build(); + var coding = EventDefinitionBuilder.Team("Coding", 2, 2).Build(); + + const string csv = """ + Team Name,Event Name,Regional Time Slot,Student 1,Student 2 + A,Coding,,Jo,Josiah Green + """; + + using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv))); + using var parser = new TeamParser(reader); + var teams = parser.Parse([coding], [jo, josiahB]); + + Assert.That(teams, Has.Length.EqualTo(1)); + Assert.That(teams[0].Students.Select(s => s.Id), Is.EquivalentTo(new[] { jo.Id, josiahB.Id })); + Assert.That(teams[0].Captain!.Id, Is.EqualTo(jo.Id)); + } + } \ No newline at end of file diff --git a/Tests/Printing/NoteTemplateMerger_Tests.cs b/Tests/Printing/NoteTemplateMerger_Tests.cs index a0d2a27..5f46330 100644 --- a/Tests/Printing/NoteTemplateMerger_Tests.cs +++ b/Tests/Printing/NoteTemplateMerger_Tests.cs @@ -17,6 +17,22 @@ public class NoteTemplateMerger_Tests Assert.That(result, Is.EqualTo("Hello Aria Cole")); } + [Test] + public void Merge_FirstNameStaysLegalWhenDisplayFirstNameIsNickname() + { + var tokens = PrintTokenMap.Create(); + tokens["FirstName"] = "Josiah"; + tokens["Nickname"] = "Jo"; + tokens["DisplayFirstName"] = "Jo"; + tokens["LastName"] = "Brown"; + + var result = NoteTemplateMerger.Merge( + "{{FirstName}} {{LastName}} / {{DisplayFirstName}} ({{Nickname}})", + tokens); + + Assert.That(result, Is.EqualTo("Josiah Brown / Jo (Jo)")); + } + [Test] public void Merge_SupportsDottedAndSpacedNames() { diff --git a/Tests/Utility/StudentNameFormatter_Tests.cs b/Tests/Utility/StudentNameFormatter_Tests.cs new file mode 100644 index 0000000..1ca0b84 --- /dev/null +++ b/Tests/Utility/StudentNameFormatter_Tests.cs @@ -0,0 +1,88 @@ +using Core.Utility; +using Tests.Builders; + +namespace Tests.Utility; + +[TestFixture] +public class StudentNameFormatter_Tests +{ + [Test] + public void FormatStudentName_UsesDisplayFirstNameAndSuffixes() + { + var student = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + + var formatted = StudentNameFormatter.FormatStudentName(student, new StudentNameFormatter.FormatOptions + { + HasOverlap = true, + IsAbsent = true + }); + + Assert.That(formatted, Is.EqualTo("Jo* (absent)")); + } +} + +[TestFixture] +public class TeamStudentNameFormatter_Tests +{ + [Test] + public void FormatStudentName_KeepsCaptainAndOverlapMarkersOnNickname() + { + var captain = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var teammate = StudentBuilder.Create("Aria", "Cole").Build(); + var evt = EventDefinitionBuilder.Team("Flight Endurance", 2, 2).Build(); + var team = TeamBuilder.Create(evt) + .WithStudent(captain, isCaptain: true) + .WithStudent(teammate) + .Build(); + + var formatted = TeamStudentNameFormatter.FormatStudentName( + captain, + team, + new TeamStudentNameFormatter.FormatOptions + { + CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain, + MarkOverlaps = true, + HasOverlaps = _ => true + }); + + Assert.That(formatted, Is.EqualTo("Jo(Cpt)*")); + } + + [Test] + public void FormatStudentList_AlphabeticalUsesDisplayFirstName() + { + var jo = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").Build(); + var josiahB = StudentBuilder.Create("Josiah", "Green").WithNickname("Josiah B").Build(); + var aria = StudentBuilder.Create("Aria", "Cole").Build(); + var evt = EventDefinitionBuilder.Team("Flight Endurance", 3, 3).Build(); + var team = TeamBuilder.Create(evt) + .WithStudent(josiahB) + .WithStudent(aria) + .WithStudent(jo, isCaptain: true) + .Build(); + + var list = TeamStudentNameFormatter.FormatStudentList( + team, + new TeamStudentNameFormatter.FormatOptions + { + Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical + }); + + Assert.That(list, Is.EqualTo("Aria, Jo, Josiah B")); + } + + [Test] + public void FormatStudentName_FallsBackToFirstName() + { + var student = StudentBuilder.Create("Josiah", "Green").Build(); + var evt = EventDefinitionBuilder.Team("Flight Endurance", 2, 2).Build(); + var team = TeamBuilder.Create(evt).WithStudent(student).Build(); + + var formatted = TeamStudentNameFormatter.FormatStudentName( + student, + team, + new TeamStudentNameFormatter.FormatOptions()); + + Assert.That(formatted, Is.EqualTo("Josiah")); + } +} diff --git a/Tests/YearTransition/YearTransitionPlanner_Tests.cs b/Tests/YearTransition/YearTransitionPlanner_Tests.cs index 6e40132..45f9067 100644 --- a/Tests/YearTransition/YearTransitionPlanner_Tests.cs +++ b/Tests/YearTransition/YearTransitionPlanner_Tests.cs @@ -196,6 +196,32 @@ public class YearTransitionPlanner_Tests Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" })); } + [Test] + public void MatchPastedNames_MatchesNicknamePlusLastName() + { + var josiah = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").WithGrade(7).Build(); + + var result = YearTransitionPlanner.MatchPastedNames( + [josiah], + ["Jo Brown", "Brown, Jo"]); + + Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { josiah.Id })); + Assert.That(result.UnmatchedNames, Is.Empty); + } + + [Test] + public void MatchPastedNames_LegalNameStillMatchesWhenNicknameSet() + { + var josiah = StudentBuilder.Create("Josiah", "Brown").WithNickname("Jo").WithGrade(7).Build(); + + var result = YearTransitionPlanner.MatchPastedNames( + [josiah], + ["Josiah Brown", "Brown, Josiah"]); + + Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { josiah.Id })); + Assert.That(result.UnmatchedNames, Is.Empty); + } + [Test] public void MatchPastedNames_AmbiguousWhenDuplicateNames() { diff --git a/WebApp/Components/Features/Calendar/StateScheduleHandout.razor b/WebApp/Components/Features/Calendar/StateScheduleHandout.razor index 77c8e56..23d01a9 100644 --- a/WebApp/Components/Features/Calendar/StateScheduleHandout.razor +++ b/WebApp/Components/Features/Calendar/StateScheduleHandout.razor @@ -222,7 +222,7 @@ else .ThenInclude(t => t!.Captain) .Include(s => s.Teams) .ThenInclude(t => t!.Students) - .OrderBy(s => s.FirstName) + .OrderBy(s => s.Nickname ?? s.FirstName) .ThenBy(s => s.LastName) .ToArrayAsync(); } @@ -391,15 +391,15 @@ else if (isIndividual) { - var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase); + var ordered = students.OrderBy(s => s.DisplayFirstName, 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); + ? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!) + : students.OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase); return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual))); } @@ -410,12 +410,12 @@ else { var sid = student.StateId?.Trim(); return !string.IsNullOrEmpty(sid) - ? $"{student.FirstName} ({sid})" - : student.FirstName; + ? $"{student.DisplayFirstName} ({sid})" + : student.DisplayFirstName; } var isCpt = team.Captain?.Id == student.Id; - return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName; + return isCpt ? $"{student.DisplayFirstName} (Cpt.)" : student.DisplayFirstName; } private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity); diff --git a/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor index 840dfd8..60f67b8 100644 --- a/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor +++ b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor @@ -65,7 +65,7 @@ @{ var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet(); - var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName); + var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.DisplayFirstName); } @foreach (var student in allStudents) { diff --git a/WebApp/Components/Features/Students/Components/StudentTextBoxSelector.razor b/WebApp/Components/Features/Students/Components/StudentTextBoxSelector.razor index a0c32ac..d835be7 100644 --- a/WebApp/Components/Features/Students/Components/StudentTextBoxSelector.razor +++ b/WebApp/Components/Features/Students/Components/StudentTextBoxSelector.razor @@ -7,7 +7,7 @@ Label="@Label" @bind-Value="_currentStudent" SearchFunc="@SearchStudents" - ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.FirstName)" + ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.DisplayFirstName)" Immediate="true" ResetValueOnEmptyText="true" CoerceText="false" @@ -21,7 +21,7 @@ } else { - @student.FirstName + @student.DisplayFirstName } @if (ShowGrade) { @@ -33,11 +33,11 @@ @if (SelectedStudents.Any()) { - @foreach (var student in SelectedStudents.OrderBy(s => s.FirstName)) + @foreach (var student in SelectedStudents.OrderBy(s => s.DisplayFirstName)) { } @@ -91,8 +91,9 @@ return Task.FromResult>(Students .Where(s => !SelectedStudents.Contains(s)) .Where(s => s.FirstName.ToLower().Contains(search) || - s.LastName.ToLower().Contains(search)) - .OrderBy(s => s.FirstName)); + s.LastName.ToLower().Contains(search) || + (s.Nickname != null && s.Nickname.ToLower().Contains(search))) + .OrderBy(s => s.DisplayFirstName)); } private void RemoveStudent(Student student) diff --git a/WebApp/Components/Features/Students/Components/StudentToggleSelector.razor b/WebApp/Components/Features/Students/Components/StudentToggleSelector.razor index 979ca71..5f3ad66 100644 --- a/WebApp/Components/Features/Students/Components/StudentToggleSelector.razor +++ b/WebApp/Components/Features/Students/Components/StudentToggleSelector.razor @@ -9,7 +9,7 @@ ValuesChanged="@OnSelectedStudentsChanged" Vertical="true" CheckMark> - @foreach (var student in Students.OrderBy(e => e.FirstName)) + @foreach (var student in Students.OrderBy(e => e.DisplayFirstName)) { @if (ShowFullName) @@ -18,7 +18,7 @@ } else { - @student.FirstName + @student.DisplayFirstName } } diff --git a/WebApp/Components/Features/Students/Create.razor b/WebApp/Components/Features/Students/Create.razor index b426cce..6c26c84 100644 --- a/WebApp/Components/Features/Students/Create.razor +++ b/WebApp/Components/Features/Students/Create.razor @@ -31,6 +31,9 @@ + + + @@ -80,6 +83,7 @@ try { + Student.NormalizeNickname(); Context.Students.Add(Student); await Context.SaveChangesAsync(); diff --git a/WebApp/Components/Features/Students/Details.razor b/WebApp/Components/Features/Students/Details.razor index f7bd62c..ff93f56 100644 --- a/WebApp/Components/Features/Students/Details.razor +++ b/WebApp/Components/Features/Students/Details.razor @@ -41,6 +41,10 @@ Last Name @student.LastName + + Nickname + @(string.IsNullOrWhiteSpace(student.Nickname) ? "—" : student.Nickname) + Grade @student.Grade diff --git a/WebApp/Components/Features/Students/Edit.razor b/WebApp/Components/Features/Students/Edit.razor index 8478505..59e45b8 100644 --- a/WebApp/Components/Features/Students/Edit.razor +++ b/WebApp/Components/Features/Students/Edit.razor @@ -35,6 +35,7 @@ + @@ -121,7 +122,8 @@ if (Student?.OfficerRole == 0) Student.OfficerRole = null; - Context.Attach(Student!).State = EntityState.Modified; + Student!.NormalizeNickname(); + Context.Attach(Student).State = EntityState.Modified; try { diff --git a/WebApp/Components/Features/Students/Index.razor b/WebApp/Components/Features/Students/Index.razor index 0bf59d5..2247aa1 100644 --- a/WebApp/Components/Features/Students/Index.razor +++ b/WebApp/Components/Features/Students/Index.razor @@ -67,6 +67,10 @@ Color="Color.Primary"> @context.Item.LastNameFirstName + @if (!string.IsNullOrWhiteSpace(context.Item.Nickname)) + { + @context.Item.Nickname + } @if (context.Item.OfficerRole != null) { @context.Item.OfficerRole diff --git a/WebApp/Components/Features/Teams/Components/TeamCaptainSelector.razor b/WebApp/Components/Features/Teams/Components/TeamCaptainSelector.razor index eb435e2..beac814 100644 --- a/WebApp/Components/Features/Teams/Components/TeamCaptainSelector.razor +++ b/WebApp/Components/Features/Teams/Components/TeamCaptainSelector.razor @@ -8,7 +8,7 @@ Value="@SelectedCaptain" ValueChanged="@OnSelectedCaptainChanged" CheckMark> - @foreach (var student in Students.OrderBy(e => e.FirstName)) + @foreach (var student in Students.OrderBy(e => e.DisplayFirstName)) { } diff --git a/WebApp/Components/Features/Teams/Create.razor b/WebApp/Components/Features/Teams/Create.razor index 46a1bb5..6ae486e 100644 --- a/WebApp/Components/Features/Teams/Create.razor +++ b/WebApp/Components/Features/Teams/Create.razor @@ -164,7 +164,7 @@ { // For individual events, use student's first name as identifier var student = _selectedStudents.First(); - Team.Identifier = student.FirstName; + Team.Identifier = student.DisplayFirstName; Team.Captain = student; } else if (existingTeamCount == 1) diff --git a/WebApp/Components/Features/Teams/Edit.razor b/WebApp/Components/Features/Teams/Edit.razor index 6a093ca..b70cab8 100644 --- a/WebApp/Components/Features/Teams/Edit.razor +++ b/WebApp/Components/Features/Teams/Edit.razor @@ -88,7 +88,7 @@ { case EventFormat.Individual when Team.Students.Count == 1: Team.Captain ??= Team.Students[0]; - Team.Identifier ??= Team.Captain.FirstName; + Team.Identifier ??= Team.Captain.DisplayFirstName; break; case EventFormat.Team: break; @@ -134,7 +134,7 @@ if (Team is { Event.EventFormat: EventFormat.Individual, Students.Count: 1 }) { Team.Captain ??= Team.Students[0]; - Team.Identifier = Team.Captain.FirstName; + Team.Identifier = Team.Captain.DisplayFirstName; } try diff --git a/WebApp/Components/Features/Teams/Goals.razor b/WebApp/Components/Features/Teams/Goals.razor index 5521906..ab016b3 100644 --- a/WebApp/Components/Features/Teams/Goals.razor +++ b/WebApp/Components/Features/Teams/Goals.razor @@ -72,7 +72,7 @@ else .ThenInclude(e => e.Event) .Include(e => e.Teams) .ThenInclude(e => e.Students) - .OrderBy(e => e.FirstName) + .OrderBy(e => e.Nickname ?? e.FirstName) .ToArrayAsync(); } } diff --git a/WebApp/Components/Features/Teams/Handout.razor b/WebApp/Components/Features/Teams/Handout.razor index a08d595..ac0080d 100644 --- a/WebApp/Components/Features/Teams/Handout.razor +++ b/WebApp/Components/Features/Teams/Handout.razor @@ -124,6 +124,6 @@ else .ThenInclude(e => e.Captain) .Include(e => e.EventRankings) .ThenInclude(e => e.EventDefinition) - .OrderBy(e => e.FirstName).ToArrayAsync(); + .OrderBy(e => e.Nickname ?? e.FirstName).ToArrayAsync(); } } diff --git a/WebApp/Services/MeetingScheduleDataService.cs b/WebApp/Services/MeetingScheduleDataService.cs index f632a65..85d1062 100644 --- a/WebApp/Services/MeetingScheduleDataService.cs +++ b/WebApp/Services/MeetingScheduleDataService.cs @@ -37,7 +37,7 @@ public class MeetingScheduleDataService : IMeetingScheduleDataService .ThenInclude(t => t.Captain) .Include(e => e.EventRankings) .ThenInclude(e => e.EventDefinition) - .OrderBy(e => e.FirstName) + .OrderBy(e => e.Nickname ?? e.FirstName) .ToArrayAsync(); } } diff --git a/docs/plans/student-nickname.md b/docs/plans/student-nickname.md new file mode 100644 index 0000000..77b9b56 --- /dev/null +++ b/docs/plans/student-nickname.md @@ -0,0 +1,35 @@ +# Student nicknames + +**Created:** 2026-09-11 +**Last updated:** 2026-09-11 +**Description:** Optional nickname used as the display first name on informal surfaces. + +## Behavior + +- **First Name** and **Last Name** stay the official name (`Name`, `FirstNameLastName`, `LastNameFirstName`). +- Optional **Nickname** on `Student`. Blank or whitespace is stored as null. +- `DisplayFirstName` is the trimmed nickname when set, otherwise first name. +- Informal UI (teams, event rankings, calendars, meeting lists, rank badges, team member print tokens) uses `DisplayFirstName`. +- `{{FirstName}}` stays legal. New tokens: `{{Nickname}}` (empty when unset) and `{{DisplayFirstName}}`. + +## Data + +Migration `AddStudentNickname` adds nullable `Students.Nickname` (max 100). Applied on next app start via `MigrateAsync`. + +## Testing + +Unit tests stay in the Core `Tests` project (no Blazor/bUnit). They lock the two-Josiahs contract: + +| Layer | What we assert | +| --- | --- | +| Entity | `DisplayFirstName` / `ToString` / `NormalizeNickname`; legal `Name` / `FirstNameLastName` / `LastNameFirstName` ignore nickname | +| Display | Formatters and alphabetical team lists use nickname; suffix markers still attach; no nickname falls back to first name | +| Matching | Fuzzy matcher, year-rollover paste, assignment CSV columns, team import, notes import, and ranking import distinguish `Jo` vs `Josiah B` | +| Collision | Assignment header `Josiah` still binds the first Josiah (requirement files must use display names) | +| Print | Catalog includes `Nickname` and `DisplayFirstName`; merge keeps `{{FirstName}}` legal | + +Not covered by unit tests: Blazor create/edit/index captions, `NotePrintService` token map (same keys as the catalog/merger contract), and rewriting stored team identifiers. + +## Follow-up + +After setting nicknames on students who share a first name, rename any team identifiers that still collide (identifiers are stored, not recomputed).