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")]
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<Student>
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)
{
var match = Match(fullName, @"(.*),\s*(.*)");
@@ -67,10 +82,7 @@ public class Student : IEquatable<Student>
: new Tuple<string, string>(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;
+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)
{
var assumptions = new List<AssignmentRequirement>();
@@ -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())
{
+8 -1
View File
@@ -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)));
}
}
+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)
{
var teams = new List<Team>();
@@ -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
+1 -1
View File
@@ -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)
+6 -6
View File
@@ -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
};
+7 -3
View File
@@ -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)));
}
/// <summary>
@@ -24,6 +24,9 @@ namespace Data.Configurations
.IsRequired()
.HasMaxLength(100);
builder.Property(s => s.Nickname)
.HasMaxLength(100);
builder.Property(s => s.Email)
.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)
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
+8
View File
@@ -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,
+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;
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<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));
}
[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()
{
@@ -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()
{
+26
View File
@@ -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));
}
}
@@ -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()
{
@@ -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" }));
}
[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()
{
@@ -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<Student> 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);
@@ -65,7 +65,7 @@
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@{
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)
{
@@ -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())
{
<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"
Value="@student"
Text="@(ShowFullName ? student.FirstNameLastName : student.FirstName)"
Text="@(ShowFullName ? student.FirstNameLastName : student.DisplayFirstName)"
OnClose="@(() => RemoveStudent(student))" />
}
</MudChipSet>
@@ -91,8 +91,9 @@
return Task.FromResult<IEnumerable<Student>>(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)
@@ -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))
{
<MudToggleItem Value="@student" Style="font-size: .75rem;">
@if (ShowFullName)
@@ -18,7 +18,7 @@
}
else
{
@student.FirstName
@student.DisplayFirstName
}
</MudToggleItem>
}
@@ -31,6 +31,9 @@
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Last Name" @bind-Value="Student.LastName" For="@(() => Student.LastName)" Variant="Variant.Outlined"></MudTextField>
</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">
<MudTextField T="string" Label="Email Address" @bind-Value="Student.Email" For="@(() => Student.Email)" Variant="Variant.Outlined"></MudTextField>
</MudItem>
@@ -80,6 +83,7 @@
try
{
Student.NormalizeNickname();
Context.Students.Add(Student);
await Context.SaveChangesAsync();
@@ -41,6 +41,10 @@
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Last Name</MudText>
<MudText Typo="Typo.body1">@student.LastName</MudText>
</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">
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">Grade</MudText>
<MudText Typo="Typo.body1">@student.Grade</MudText>
@@ -35,6 +35,7 @@
<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="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="Phone Number" @bind-Value="Student.PhoneNumber" For="@(() => Student.PhoneNumber)"></MudTextField>
<MudTextField T="int" Label="Grade" @bind-Value="Student.Grade" For="@(() => Student.Grade)"></MudTextField>
@@ -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
{
@@ -67,6 +67,10 @@
Color="Color.Primary">
@context.Item.LastNameFirstName
</MudLink>
@if (!string.IsNullOrWhiteSpace(context.Item.Nickname))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@context.Item.Nickname</MudText>
}
@if (context.Item.OfficerRole != null)
{
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
@@ -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))
{
<MudToggleItem Value="@student" Text="@student.Name" />
}
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
+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).