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>
This commit is contained in:
2026-09-12 00:06:33 -04:00
co-authored by Cursor
parent c03ffc0833
commit 29101e2ead
35 changed files with 516 additions and 41 deletions
+16 -4
View File
@@ -17,6 +17,10 @@ public class Student : IEquatable<Student>
[Display(Name = "Last Name")] [Display(Name = "Last Name")]
public string LastName { get; set; } = null!; public string LastName { get; set; } = null!;
[StringLength(50)]
[Display(Name = "Nickname")]
public string? Nickname { get; set; }
[Range(5,12)] [Range(5,12)]
[Display(Name = "Grade")] [Display(Name = "Grade")]
public int Grade { get; set; } public int Grade { get; set; }
@@ -55,6 +59,17 @@ public class Student : IEquatable<Student>
public string FirstNameLastName => $"{FirstName} {LastName}"; public string FirstNameLastName => $"{FirstName} {LastName}";
/// <summary>
/// Nickname when set, otherwise <see cref="FirstName"/>.
/// </summary>
public string DisplayFirstName =>
string.IsNullOrWhiteSpace(Nickname) ? FirstName : Nickname.Trim();
public void NormalizeNickname()
{
Nickname = string.IsNullOrWhiteSpace(Nickname) ? null : Nickname.Trim();
}
public static Tuple<string, string> ParseNameParts(string fullName) public static Tuple<string, string> ParseNameParts(string fullName)
{ {
var match = Match(fullName, @"(.*),\s*(.*)"); var match = Match(fullName, @"(.*),\s*(.*)");
@@ -67,10 +82,7 @@ public class Student : IEquatable<Student>
: new Tuple<string, string>(fullName, string.Empty); : new Tuple<string, string>(fullName, string.Empty);
} }
public override string ToString() public override string ToString() => DisplayFirstName;
{
return FirstName;
}
public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident; public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident;
+9 -1
View File
@@ -9,6 +9,10 @@ public class AssignmentRequirementParser : CsvParserBase
{ {
} }
public AssignmentRequirementParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public AssignmentRequirement[] Parse(ICollection<EventDefinition> events, ICollection<Student> students) public AssignmentRequirement[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
{ {
var assumptions = new List<AssignmentRequirement>(); var assumptions = new List<AssignmentRequirement>();
@@ -20,7 +24,11 @@ public class AssignmentRequirementParser : CsvParserBase
var studentArray = var studentArray =
studentColumns 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()) while (CsvReader.Read())
{ {
+8 -1
View File
@@ -23,7 +23,14 @@ public static class FuzzyStudentMatcher
public static int Score(Student student, string name) 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))); return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
} }
} }
+7 -1
View File
@@ -56,6 +56,10 @@ namespace Core.Parsers
{ {
} }
public TeamParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public Team[] Parse(ICollection<EventDefinition> events, ICollection<Student> students) public Team[] Parse(ICollection<EventDefinition> events, ICollection<Student> students)
{ {
var teams = new List<Team>(); var teams = new List<Team>();
@@ -118,7 +122,9 @@ namespace Core.Parsers
{ {
Fuzz.Ratio(s.Name, studentName), Fuzz.Ratio(s.Name, studentName),
Fuzz.Ratio(s.FirstNameLastName, 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() }.Max()
where rat > 90 where rat > 90
orderby rat descending orderby rat descending
+1 -1
View File
@@ -34,7 +34,7 @@ public static class StudentNameFormatter
if (student == null) if (student == null)
return string.Empty; return string.Empty;
var name = student.FirstName; var name = student.DisplayFirstName;
// Add overlap marker // Add overlap marker
if (options.HasOverlap) if (options.HasOverlap)
+6 -6
View File
@@ -107,7 +107,7 @@ public static class TeamStudentNameFormatter
if (student == null) if (student == null)
return string.Empty; return string.Empty;
var name = student.FirstName; var name = student.DisplayFirstName;
// Add captain indicator (before overlap/absent markers) // Add captain indicator (before overlap/absent markers)
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student)) 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) // Get the suffix from StudentNameFormatter (overlap/absent markers)
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions); var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
var suffix = baseFormatted.Substring(student.FirstName.Length); var suffix = baseFormatted.Substring(student.DisplayFirstName.Length);
return name + suffix; return name + suffix;
} }
@@ -214,10 +214,10 @@ public static class TeamStudentNameFormatter
{ {
OrderingStyle.CaptainFirst => studentsWithCaptainInfo OrderingStyle.CaptainFirst => studentsWithCaptainInfo
.OrderBy(x => !x.IsCaptain) .OrderBy(x => !x.IsCaptain)
.ThenBy(x => x.Student.FirstName) .ThenBy(x => x.Student.DisplayFirstName)
.Select(x => x.Student), .Select(x => x.Student),
OrderingStyle.Alphabetical => studentsWithCaptainInfo OrderingStyle.Alphabetical => studentsWithCaptainInfo
.OrderBy(x => x.Student.FirstName) .OrderBy(x => x.Student.DisplayFirstName)
.Select(x => x.Student), .Select(x => x.Student),
OrderingStyle.GradeDescending => studentsWithCaptainInfo OrderingStyle.GradeDescending => studentsWithCaptainInfo
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear) .OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
@@ -252,8 +252,8 @@ public static class TeamStudentNameFormatter
{ {
OrderingStyle.CaptainFirst => students OrderingStyle.CaptainFirst => students
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s)) .OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
.ThenBy(s => s.FirstName), .ThenBy(s => s.DisplayFirstName),
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName), OrderingStyle.Alphabetical => students.OrderBy(s => s.DisplayFirstName),
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear), OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
_ => students _ => students
}; };
+7 -3
View File
@@ -242,7 +242,9 @@ public static class YearTransitionPlanner
// Exact full-name matches first // Exact full-name matches first
var fullMatches = students.Where(s => var fullMatches = students.Where(s =>
comparer.Equals(s.FirstNameLastName, line) || 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) if (fullMatches.Count > 0)
return fullMatches; return fullMatches;
@@ -251,8 +253,10 @@ public static class YearTransitionPlanner
return []; return [];
return students.Where(s => 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)));
} }
/// <summary> /// <summary>
@@ -24,6 +24,9 @@ namespace Data.Configurations
.IsRequired() .IsRequired()
.HasMaxLength(100); .HasMaxLength(100);
builder.Property(s => s.Nickname)
.HasMaxLength(100);
builder.Property(s => s.Email) builder.Property(s => s.Email)
.HasMaxLength(255); .HasMaxLength(255);
@@ -0,0 +1,33 @@
using Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260911190000_AddStudentNickname")]
public partial class AddStudentNickname : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Nickname",
table: "Students",
type: "TEXT",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Nickname",
table: "Students");
}
}
}
@@ -322,6 +322,10 @@ namespace Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId") b.Property<string>("NationalId")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+8
View File
@@ -12,6 +12,7 @@ public class StudentBuilder
private int _id = _idCounter++; private int _id = _idCounter++;
private string _firstName = "Test"; private string _firstName = "Test";
private string _lastName = "Student"; private string _lastName = "Student";
private string? _nickname = null;
private int _grade = 9; private int _grade = 9;
private string? _email = null; private string? _email = null;
private string? _phoneNumber = null; private string? _phoneNumber = null;
@@ -41,6 +42,12 @@ public class StudentBuilder
return this; return this;
} }
public StudentBuilder WithNickname(string? nickname)
{
_nickname = nickname;
return this;
}
public StudentBuilder WithGrade(int grade) public StudentBuilder WithGrade(int grade)
{ {
_grade = grade; _grade = grade;
@@ -126,6 +133,7 @@ public class StudentBuilder
Id = _id, Id = _id,
FirstName = _firstName, FirstName = _firstName,
LastName = _lastName, LastName = _lastName,
Nickname = _nickname,
Grade = _grade, Grade = _grade,
Email = _email, Email = _email,
PhoneNumber = _phoneNumber, PhoneNumber = _phoneNumber,
+57
View File
@@ -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"));
}
}
@@ -1,3 +1,9 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers; namespace Tests.Parsers;
public class AssignmentRequirement_Tests public class AssignmentRequirement_Tests
@@ -16,4 +22,53 @@ public class AssignmentRequirement_Tests
Assert.Pass(); 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<EventDefinition> events,
ICollection<Student> students)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new AssignmentRequirementParser(reader);
return parser.Parse(events, students);
}
} }
@@ -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);
}
}
@@ -68,6 +68,21 @@ public class StudentEventRankingParser_Tests
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria)); 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] [Test]
public void Parse_ShortName_MatchesEvent() public void Parse_ShortName_MatchesEvent()
{ {
@@ -34,6 +34,22 @@ public class StudentNotesFieldParser_Tests
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here")); 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] [Test]
public void Parse_SameExistingNote_HasNoChanges() public void Parse_SameExistingNote_HasNoChanges()
{ {
+26
View File
@@ -1,3 +1,7 @@
using System.Text;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers; namespace Tests.Parsers;
public class TeamParser_Tests 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));
}
} }
@@ -17,6 +17,22 @@ public class NoteTemplateMerger_Tests
Assert.That(result, Is.EqualTo("Hello Aria Cole")); 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] [Test]
public void Merge_SupportsDottedAndSpacedNames() public void Merge_SupportsDottedAndSpacedNames()
{ {
@@ -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"));
}
}
@@ -196,6 +196,32 @@ public class YearTransitionPlanner_Tests
Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" })); 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] [Test]
public void MatchPastedNames_AmbiguousWhenDuplicateNames() public void MatchPastedNames_AmbiguousWhenDuplicateNames()
{ {
@@ -222,7 +222,7 @@ else
.ThenInclude(t => t!.Captain) .ThenInclude(t => t!.Captain)
.Include(s => s.Teams) .Include(s => s.Teams)
.ThenInclude(t => t!.Students) .ThenInclude(t => t!.Students)
.OrderBy(s => s.FirstName) .OrderBy(s => s.Nickname ?? s.FirstName)
.ThenBy(s => s.LastName) .ThenBy(s => s.LastName)
.ToArrayAsync(); .ToArrayAsync();
} }
@@ -391,15 +391,15 @@ else
if (isIndividual) 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))); return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
} }
var cap = team.Captain; var cap = team.Captain;
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id); var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
IEnumerable<Student> orderedTeam = capInRoster IEnumerable<Student> orderedTeam = capInRoster
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!) ? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase); : students.OrderBy(s => s.DisplayFirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual))); return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
} }
@@ -410,12 +410,12 @@ else
{ {
var sid = student.StateId?.Trim(); var sid = student.StateId?.Trim();
return !string.IsNullOrEmpty(sid) return !string.IsNullOrEmpty(sid)
? $"{student.FirstName} ({sid})" ? $"{student.DisplayFirstName} ({sid})"
: student.FirstName; : student.DisplayFirstName;
} }
var isCpt = team.Captain?.Id == student.Id; 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); private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
@@ -65,7 +65,7 @@
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> <MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@{ @{
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet(); 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) @foreach (var student in allStudents)
{ {
@@ -7,7 +7,7 @@
Label="@Label" Label="@Label"
@bind-Value="_currentStudent" @bind-Value="_currentStudent"
SearchFunc="@SearchStudents" SearchFunc="@SearchStudents"
ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.FirstName)" ToStringFunc="@(s => ShowFullName ? s?.FirstNameLastName : s?.DisplayFirstName)"
Immediate="true" Immediate="true"
ResetValueOnEmptyText="true" ResetValueOnEmptyText="true"
CoerceText="false" CoerceText="false"
@@ -21,7 +21,7 @@
} }
else else
{ {
@student.FirstName @student.DisplayFirstName
} }
@if (ShowGrade) @if (ShowGrade)
{ {
@@ -33,11 +33,11 @@
@if (SelectedStudents.Any()) @if (SelectedStudents.Any())
{ {
<MudChipSet T="Student" AllClosable="true" Class="mt-2"> <MudChipSet T="Student" AllClosable="true" Class="mt-2">
@foreach (var student in SelectedStudents.OrderBy(s => s.FirstName)) @foreach (var student in SelectedStudents.OrderBy(s => s.DisplayFirstName))
{ {
<MudChip T="Student" <MudChip T="Student"
Value="@student" Value="@student"
Text="@(ShowFullName ? student.FirstNameLastName : student.FirstName)" Text="@(ShowFullName ? student.FirstNameLastName : student.DisplayFirstName)"
OnClose="@(() => RemoveStudent(student))" /> OnClose="@(() => RemoveStudent(student))" />
} }
</MudChipSet> </MudChipSet>
@@ -91,8 +91,9 @@
return Task.FromResult<IEnumerable<Student>>(Students return Task.FromResult<IEnumerable<Student>>(Students
.Where(s => !SelectedStudents.Contains(s)) .Where(s => !SelectedStudents.Contains(s))
.Where(s => s.FirstName.ToLower().Contains(search) || .Where(s => s.FirstName.ToLower().Contains(search) ||
s.LastName.ToLower().Contains(search)) s.LastName.ToLower().Contains(search) ||
.OrderBy(s => s.FirstName)); (s.Nickname != null && s.Nickname.ToLower().Contains(search)))
.OrderBy(s => s.DisplayFirstName));
} }
private void RemoveStudent(Student student) private void RemoveStudent(Student student)
@@ -9,7 +9,7 @@
ValuesChanged="@OnSelectedStudentsChanged" ValuesChanged="@OnSelectedStudentsChanged"
Vertical="true" Vertical="true"
CheckMark> CheckMark>
@foreach (var student in Students.OrderBy(e => e.FirstName)) @foreach (var student in Students.OrderBy(e => e.DisplayFirstName))
{ {
<MudToggleItem Value="@student" Style="font-size: .75rem;"> <MudToggleItem Value="@student" Style="font-size: .75rem;">
@if (ShowFullName) @if (ShowFullName)
@@ -18,7 +18,7 @@
} }
else else
{ {
@student.FirstName @student.DisplayFirstName
} }
</MudToggleItem> </MudToggleItem>
} }
@@ -31,6 +31,9 @@
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField> <MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField>
</MudItem> </MudItem>
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Nickname" @bind-Value="Student.Nickname" For="@(() => Student.Nickname)" Variant="Variant.Outlined" HelperText="Shown instead of first name on teams, rankings, and printouts"></MudTextField>
</MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField> <MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField>
</MudItem> </MudItem>
@@ -80,6 +83,7 @@
try try
{ {
Student.NormalizeNickname();
Context.Students.Add(Student); Context.Students.Add(Student);
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();
@@ -41,6 +41,10 @@
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Last Name</MudText> <MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Last Name</MudText>
<MudText Typo="Typo.body1">@student.LastName</MudText> <MudText Typo="Typo.body1">@student.LastName</MudText>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Nickname</MudText>
<MudText Typo="Typo.body1">@(string.IsNullOrWhiteSpace(student.Nickname) ? "—" : student.Nickname)</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4"> <MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Grade</MudText> <MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Grade</MudText>
<MudText Typo="Typo.body1">@student.Grade</MudText> <MudText Typo="Typo.body1">@student.Grade</MudText>
@@ -35,6 +35,7 @@
<MudPaper Elevation="2" Class="pa-3 pa-md-6"> <MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudTextField T="string" Label="First Name" @bind-Value="Student.FirstName" For="@(() => Student.FirstName)"></MudTextField> <MudTextField T="string" Label="First Name" @bind-Value="Student.FirstName" For="@(() => Student.FirstName)"></MudTextField>
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)"></MudTextField> <MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)"></MudTextField>
<MudTextField T="string" Label="Nickname" @bind-Value="Student.Nickname" For="@(() => Student.Nickname)" HelperText="Shown instead of first name on teams, rankings, and printouts"></MudTextField>
<MudTextField T="string" Label="Email Adress" @bind-Value="Student.Email" For="@(() => Student.Email)"></MudTextField> <MudTextField T="string" Label="Email Adress" @bind-Value="Student.Email" For="@(() => Student.Email)"></MudTextField>
<MudTextField T="string" Label="Phone Number" @bind-Value="Student.PhoneNumber" For="@(() => Student.PhoneNumber)"></MudTextField> <MudTextField T="string" Label="Phone Number" @bind-Value="Student.PhoneNumber" For="@(() => Student.PhoneNumber)"></MudTextField>
<MudTextField T="int" Label="Grade" @bind-Value="Student.Grade" For="@(() => Student.Grade)"></MudTextField> <MudTextField T="int" Label="Grade" @bind-Value="Student.Grade" For="@(() => Student.Grade)"></MudTextField>
@@ -121,7 +122,8 @@
if (Student?.OfficerRole == 0) if (Student?.OfficerRole == 0)
Student.OfficerRole = null; Student.OfficerRole = null;
Context.Attach(Student!).State = EntityState.Modified; Student!.NormalizeNickname();
Context.Attach(Student).State = EntityState.Modified;
try try
{ {
@@ -67,6 +67,10 @@
Color="Color.Primary"> Color="Color.Primary">
@context.Item.LastNameFirstName @context.Item.LastNameFirstName
</MudLink> </MudLink>
@if (!string.IsNullOrWhiteSpace(context.Item.Nickname))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@context.Item.Nickname</MudText>
}
@if (context.Item.OfficerRole != null) @if (context.Item.OfficerRole != null)
{ {
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip> <MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
@@ -8,7 +8,7 @@
Value="@SelectedCaptain" Value="@SelectedCaptain"
ValueChanged="@OnSelectedCaptainChanged" ValueChanged="@OnSelectedCaptainChanged"
CheckMark> CheckMark>
@foreach (var student in Students.OrderBy(e => e.FirstName)) @foreach (var student in Students.OrderBy(e => e.DisplayFirstName))
{ {
<MudToggleItem Value="@student" Text="@student.Name" /> <MudToggleItem Value="@student" Text="@student.Name" />
} }
@@ -164,7 +164,7 @@
{ {
// For individual events, use student's first name as identifier // For individual events, use student's first name as identifier
var student = _selectedStudents.First(); var student = _selectedStudents.First();
Team.Identifier = student.FirstName; Team.Identifier = student.DisplayFirstName;
Team.Captain = student; Team.Captain = student;
} }
else if (existingTeamCount == 1) else if (existingTeamCount == 1)
+2 -2
View File
@@ -88,7 +88,7 @@
{ {
case EventFormat.Individual when Team.Students.Count == 1: case EventFormat.Individual when Team.Students.Count == 1:
Team.Captain ??= Team.Students[0]; Team.Captain ??= Team.Students[0];
Team.Identifier ??= Team.Captain.FirstName; Team.Identifier ??= Team.Captain.DisplayFirstName;
break; break;
case EventFormat.Team: case EventFormat.Team:
break; break;
@@ -134,7 +134,7 @@
if (Team is { Event.EventFormat: EventFormat.Individual, Students.Count: 1 }) if (Team is { Event.EventFormat: EventFormat.Individual, Students.Count: 1 })
{ {
Team.Captain ??= Team.Students[0]; Team.Captain ??= Team.Students[0];
Team.Identifier = Team.Captain.FirstName; Team.Identifier = Team.Captain.DisplayFirstName;
} }
try try
+1 -1
View File
@@ -72,7 +72,7 @@ else
.ThenInclude(e => e.Event) .ThenInclude(e => e.Event)
.Include(e => e.Teams) .Include(e => e.Teams)
.ThenInclude(e => e.Students) .ThenInclude(e => e.Students)
.OrderBy(e => e.FirstName) .OrderBy(e => e.Nickname ?? e.FirstName)
.ToArrayAsync(); .ToArrayAsync();
} }
} }
@@ -124,6 +124,6 @@ else
.ThenInclude(e => e.Captain) .ThenInclude(e => e.Captain)
.Include(e => e.EventRankings) .Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition) .ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName).ToArrayAsync(); .OrderBy(e => e.Nickname ?? e.FirstName).ToArrayAsync();
} }
} }
@@ -37,7 +37,7 @@ public class MeetingScheduleDataService : IMeetingScheduleDataService
.ThenInclude(t => t.Captain) .ThenInclude(t => t.Captain)
.Include(e => e.EventRankings) .Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition) .ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName) .OrderBy(e => e.Nickname ?? e.FirstName)
.ToArrayAsync(); .ToArrayAsync();
} }
} }
+35
View File
@@ -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).