feat: add locked new-year rollover wizard for season transitions

Promote returning students, assign officers, and clear last season's data after an automatic SQLite backup, with Docker volume path docs fixed for /app/Data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 14:18:18 -04:00
co-authored by Cursor
parent c5c0f95f60
commit afdabd179a
16 changed files with 1677 additions and 50 deletions
@@ -0,0 +1,32 @@
using Core.Models;
namespace Core.YearTransition;
/// <summary>
/// Resolves the graduating grade from chapter school level configuration.
/// </summary>
public static class GraduatingGradeResolver
{
/// <summary>
/// Middle school students graduate after grade 8; high school after grade 12.
/// Returns null when school level is unset (both / unspecified).
/// </summary>
public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch
{
SchoolLevel.MiddleSchool => 8,
SchoolLevel.HighSchool => 12,
_ => null
};
/// <summary>
/// Human-readable label for wizard display.
/// </summary>
public static string Describe(SchoolLevel schoolLevel, int graduatingGrade) => schoolLevel switch
{
SchoolLevel.MiddleSchool =>
$"Middle school chapter — students graduate after grade {graduatingGrade}",
SchoolLevel.HighSchool =>
$"High school chapter — students graduate after grade {graduatingGrade}",
_ => $"Students graduate after grade {graduatingGrade}"
};
}
@@ -0,0 +1,294 @@
using Core.Entities;
namespace Core.YearTransition;
/// <summary>
/// Inputs for building a year-transition plan.
/// </summary>
public sealed class YearTransitionRequest
{
public required IReadOnlyList<Student> Students { get; init; }
public required IReadOnlySet<int> ReturningStudentIds { get; init; }
public IReadOnlyDictionary<OfficerRole, int?> OfficerAssignments { get; init; }
= new Dictionary<OfficerRole, int?>();
public required int GraduatingGrade { get; init; }
public required string TargetCompetitionYear { get; init; }
public IReadOnlyList<string> PastedNames { get; init; } = [];
}
/// <summary>
/// Preview of a year transition before it is applied.
/// </summary>
public sealed class YearTransitionPlan
{
public required string TargetCompetitionYear { get; init; }
public required int GraduatingGrade { get; init; }
public required IReadOnlyList<StudentPromotion> Promotions { get; init; }
public required IReadOnlyList<Student> StudentsToRemove { get; init; }
public required IReadOnlyList<OfficerAssignmentChange> OfficerChanges { get; init; }
public required IReadOnlyList<string> UnmatchedPastedNames { get; init; }
public required IReadOnlyList<string> AmbiguousPastedNames { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
public int ReturningCount => Promotions.Count;
public int RemovalCount => StudentsToRemove.Count;
}
public sealed class StudentPromotion
{
public required Student Student { get; init; }
public required int PreviousGrade { get; init; }
public required int NewGrade { get; init; }
public required int PreviousTsaYear { get; init; }
public required int NewTsaYear { get; init; }
public OfficerRole? PreviousOfficerRole { get; init; }
public OfficerRole? NewOfficerRole { get; init; }
}
public sealed class OfficerAssignmentChange
{
public required OfficerRole Role { get; init; }
public Student? NewOfficer { get; init; }
public Student? PreviousOfficer { get; init; }
}
/// <summary>
/// Pure planner for year-end student promotion, graduation, and officer assignment.
/// </summary>
public static class YearTransitionPlanner
{
private const int AbsoluteMaxGrade = 12;
/// <summary>
/// Students at or above the graduating grade are suggested as non-returning.
/// </summary>
public static bool SuggestReturning(Student student, int graduatingGrade) =>
student.Grade < graduatingGrade;
/// <summary>
/// Parses pasted name lines and matches them to students.
/// </summary>
public static NameMatchResult MatchPastedNames(
IReadOnlyList<Student> students,
IEnumerable<string> pastedLines)
{
var matchedIds = new HashSet<int>();
var unmatched = new List<string>();
var ambiguous = new List<string>();
foreach (var rawLine in pastedLines)
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line))
continue;
var matches = FindNameMatches(students, line).ToList();
if (matches.Count == 0)
{
unmatched.Add(line);
}
else if (matches.Count > 1)
{
ambiguous.Add(line);
foreach (var match in matches)
matchedIds.Add(match.Id);
}
else
{
matchedIds.Add(matches[0].Id);
}
}
return new NameMatchResult(matchedIds, unmatched, ambiguous);
}
public static YearTransitionPlan Build(YearTransitionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade)
throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12.");
var studentsById = request.Students.ToDictionary(s => s.Id);
var warnings = new List<string>();
var promotions = new List<StudentPromotion>();
var toRemove = new List<Student>();
var nameMatch = MatchPastedNames(request.Students, request.PastedNames);
warnings.AddRange(nameMatch.AmbiguousNames.Select(n =>
$"Pasted name '{n}' matched more than one student."));
// Officer role -> student id from request (null = vacant)
var newOfficerByRole = Enum.GetValues<OfficerRole>()
.ToDictionary(
role => role,
role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null);
// Detect same student assigned to multiple offices
var assignedStudentIds = newOfficerByRole.Values
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var duplicateOfficerStudents = assignedStudentIds
.GroupBy(id => id)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToHashSet();
foreach (var studentId in duplicateOfficerStudents)
{
if (studentsById.TryGetValue(studentId, out var student))
{
warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role.");
}
}
// Build new officer role lookup for returning students
var newRoleByStudentId = new Dictionary<int, OfficerRole>();
foreach (var (role, studentId) in newOfficerByRole)
{
if (!studentId.HasValue)
continue;
if (!request.ReturningStudentIds.Contains(studentId.Value))
{
var name = studentsById.TryGetValue(studentId.Value, out var s)
? s.LastNameFirstName
: $"Id {studentId.Value}";
warnings.Add($"{role} is assigned to {name}, who is not marked returning.");
continue;
}
if (duplicateOfficerStudents.Contains(studentId.Value))
continue;
newRoleByStudentId[studentId.Value] = role;
}
foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
if (request.ReturningStudentIds.Contains(student.Id))
{
var newGrade = Math.Min(AbsoluteMaxGrade,
Math.Min(request.GraduatingGrade, student.Grade + 1));
var newTsaYear = student.TsaYear + 1;
if (student.Grade >= request.GraduatingGrade)
{
warnings.Add(
$"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}.");
}
OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role)
? role
: null;
promotions.Add(new StudentPromotion
{
Student = student,
PreviousGrade = student.Grade,
NewGrade = newGrade,
PreviousTsaYear = student.TsaYear,
NewTsaYear = newTsaYear,
PreviousOfficerRole = student.OfficerRole,
NewOfficerRole = assignedRole
});
}
else
{
toRemove.Add(student);
}
}
var officerChanges = Enum.GetValues<OfficerRole>()
.Select(role =>
{
var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role);
Student? next = null;
if (newOfficerByRole.TryGetValue(role, out var nextId) &&
nextId.HasValue &&
studentsById.TryGetValue(nextId.Value, out var nextStudent) &&
request.ReturningStudentIds.Contains(nextId.Value) &&
!duplicateOfficerStudents.Contains(nextId.Value))
{
next = nextStudent;
}
return new OfficerAssignmentChange
{
Role = role,
PreviousOfficer = previous,
NewOfficer = next
};
})
.ToList();
return new YearTransitionPlan
{
TargetCompetitionYear = request.TargetCompetitionYear,
GraduatingGrade = request.GraduatingGrade,
Promotions = promotions,
StudentsToRemove = toRemove,
OfficerChanges = officerChanges,
UnmatchedPastedNames = nameMatch.UnmatchedNames,
AmbiguousPastedNames = nameMatch.AmbiguousNames,
Warnings = warnings
};
}
private static IEnumerable<Student> FindNameMatches(IReadOnlyList<Student> students, string line)
{
var comparer = StringComparer.OrdinalIgnoreCase;
// Exact full-name matches first
var fullMatches = students.Where(s =>
comparer.Equals(s.FirstNameLastName, line) ||
comparer.Equals(s.LastNameFirstName, line)).ToList();
if (fullMatches.Count > 0)
return fullMatches;
var (first, last) = ParseName(line);
if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last))
return [];
return students.Where(s =>
comparer.Equals(s.FirstName.Trim(), first) &&
comparer.Equals(s.LastName.Trim(), last));
}
/// <summary>
/// Parses a name line into (first, last), using <see cref="Student.ParseNameParts"/> for
/// "Last, First" and a last-space split for "First Last".
/// </summary>
private static (string First, string Last) ParseName(string fullName)
{
var trimmed = fullName.Trim();
if (trimmed.Contains(','))
{
var parts = Student.ParseNameParts(trimmed);
return (parts.Item1.Trim(), parts.Item2.Trim());
}
var lastSpace = trimmed.LastIndexOf(' ');
if (lastSpace <= 0)
return (trimmed, string.Empty);
return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim());
}
}
public sealed class NameMatchResult
{
public NameMatchResult(
IReadOnlySet<int> matchedStudentIds,
IReadOnlyList<string> unmatchedNames,
IReadOnlyList<string> ambiguousNames)
{
MatchedStudentIds = matchedStudentIds;
UnmatchedNames = unmatchedNames;
AmbiguousNames = ambiguousNames;
}
public IReadOnlySet<int> MatchedStudentIds { get; }
public IReadOnlyList<string> UnmatchedNames { get; }
public IReadOnlyList<string> AmbiguousNames { get; }
}