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:
@@ -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,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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user