Compare commits

...
Author SHA1 Message Date
poprhythmandCursor 4c91db37c2 feat: add CSV import for student event rankings
Let advisors load preference ranks from a converted CSV with fuzzy matching, known aliases, and a parse-preview-save page instead of relying on the ranking editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:41:13 -04:00
poprhythmandCursor 2acd83a841 fix: replace event ranking drag-and-drop with add/reorder buttons
SortableJS duplicated and mis-ordered events; buttons keep rank in C# and drop the unused BlazorSortableList package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:27:46 -04:00
poprhythmandCursor 74fc542630 fix: make yearly theme editable and stop printing events twice
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:00:37 -04:00
poprhythmandCursor 980a7213a4 fix: prevent login JS crash when returnUrl is empty
Replace eval-built form submit with tsaLogin.submitForm so a missing returnUrl no longer produces invalid JavaScript.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 15:19:11 -04:00
poprhythmandCursor afdabd179a 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>
2026-08-14 14:18:18 -04:00
poprhythm c5c0f95f60 Add 2026 TN TSA State Competition Event Times 2026-08-14 13:33:03 -04:00
poprhythm 432caa0fe8 Refactor StateScheduleHandout component to improve event occurrence filtering and display 2026-04-08 22:11:31 -04:00
poprhythm 8d7c6b103c Enhance EventOccurrenceDetailsDialog and StateScheduleHandout components for improved event display and styling 2026-04-08 15:10:45 -04:00
poprhythm 5d2d019e87 Refactor Printout component to enhance student ordering and include captain information 2026-04-08 14:22:47 -04:00
poprhythm ea1bb70740 Update Printout component to improve student ordering logic 2026-04-07 14:01:51 -04:00
poprhythm 4401e4a3ec Refactor StateScheduleHandout component for improved print layout and styling 2026-04-07 08:39:14 -04:00
poprhythm a9036d5d04 Add state schedule handout feature and configuration options
This commit introduces a new StateScheduleHandout component for generating printable schedules for students, including a combined master list of events. It adds configuration options in appsettings.json for state abbreviations and special event filters, enhancing the scheduling functionality. The Program.cs file is updated to register the new StateScheduleHandoutOptions, and the Calendar and Teams components are modified to include links to the new handout feature. Additionally, utility methods for filtering event occurrences are implemented to support the new functionality, improving the overall user experience in managing state schedules.
2026-04-06 23:33:57 -04:00
48 changed files with 3947 additions and 320 deletions
@@ -0,0 +1,107 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Result of parsing a student event ranking CSV.
/// </summary>
public class StudentEventRankingParseResult
{
/// <summary>
/// Accepted ranking matches, including the raw CSV text and fuzzy scores.
/// </summary>
public List<StudentEventRankingMatch> Matches { get; set; } = [];
/// <summary>
/// Unmatched students, unmatched or ambiguous events, and other row-level issues.
/// </summary>
public List<StudentEventRankingIssue> Issues { get; set; } = [];
/// <summary>
/// Critical errors that prevented parsing (for example a missing header).
/// </summary>
public List<string> Errors { get; set; } = [];
/// <summary>
/// Non-critical warnings about the file as a whole.
/// </summary>
public List<string> Warnings { get; set; } = [];
/// <summary>
/// Accepted rankings without match metadata.
/// </summary>
public IReadOnlyList<StudentEventRanking> Rankings => [.. Matches.Select(m => m.Ranking)];
/// <summary>
/// Number of accepted ranking rows.
/// </summary>
public int TotalParsed => Matches.Count;
/// <summary>
/// Distinct students who have at least one accepted rank.
/// Uses Id when assigned, otherwise first and last name, so unsaved parsed students stay distinct.
/// </summary>
public IReadOnlyList<Student> StudentsWithAcceptedRanks =>
[.. Matches
.Select(m => m.Ranking.Student)
.GroupBy(s => s.Id != 0 ? $"id:{s.Id}" : $"name:{s.FirstName}|{s.LastName}")
.Select(g => g.First())];
/// <summary>
/// True when no critical parse errors were recorded.
/// </summary>
public bool IsSuccess => Errors.Count == 0;
}
/// <summary>
/// A successfully matched ranking cell from the CSV.
/// </summary>
public class StudentEventRankingMatch
{
public required StudentEventRanking Ranking { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string RawEventName { get; set; } = string.Empty;
public int StudentScore { get; set; }
public int EventScore { get; set; }
public int RowNumber { get; set; }
}
/// <summary>
/// A row-level problem encountered while parsing rankings.
/// </summary>
public class StudentEventRankingIssue
{
public int RowNumber { get; set; }
public int Rank { get; set; }
public string RawStudentName { get; set; } = string.Empty;
public string RawEventName { get; set; } = string.Empty;
public StudentEventRankingIssueType IssueType { get; set; }
public string Message { get; set; } = string.Empty;
public string? SuggestedEventName { get; set; }
public int? Score { get; set; }
}
/// <summary>
/// Types of issues reported while parsing student event rankings.
/// </summary>
public enum StudentEventRankingIssueType
{
UnmatchedStudent,
UnmatchedEvent,
AmbiguousEvent,
DuplicateEvent,
DuplicateRank,
InvalidFormat
}
+216 -52
View File
@@ -1,77 +1,241 @@
using Core.Entities; using Core.Entities;
using Core.Models;
using FuzzySharp; using FuzzySharp;
namespace Core.Parsers; namespace Core.Parsers;
public class StudentEventRankingParser : CsvParserBase public class StudentEventRankingParser : CsvParserBase
{ {
public const int StudentMatchThreshold = 90;
public const int EventMatchThreshold = 70;
public const int EventAmbiguityGap = 8;
/// <summary>
/// Informal names that share too little text with the catalog for fuzzy matching.
/// Keyed by official <see cref="EventDefinition.Name"/>.
/// </summary>
private static readonly Dictionary<string, string[]> EventAliases = new(StringComparer.OrdinalIgnoreCase)
{
["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"],
["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"],
["Digital Photography"] = ["Digital Photo"],
["Forensic Technology"] = ["Forensics", "Forensic"],
["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"],
["Medical Technology"] = ["Med Tech"],
["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"],
["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"],
["Structural Engineering"] = ["Structural Eng"],
["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"],
["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"],
["TSA Robotics"] = ["Robotics"],
["Audio Podcasting"] = ["Audio Podcast", "Podcasting"]
};
public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
{ {
} }
public StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events) public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{ {
var rankings = new List<StudentEventRanking>(); }
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> events)
{
var result = new StudentEventRankingParseResult();
CsvReader.Read(); CsvReader.Read();
CsvReader.ReadHeader(); CsvReader.ReadHeader();
while (CsvReader.Read()) if (CsvReader.HeaderRecord is null ||
{ !CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
var name = CsvReader.GetField("Student Name"); {
result.Errors.Add("CSV must include a 'Student Name' column.");
return result;
}
while (CsvReader.Read())
{
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
var name = CsvReader.GetField("Student Name")?.Trim();
if (string.IsNullOrEmpty(name)) if (string.IsNullOrEmpty(name))
continue; continue;
var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90); var studentMatch = FindStudent(students, name);
if (student == null) if (studentMatch is null)
continue;
var competitiveEvents = new List<EventDefinition>();
for (var i = 1; i <= 6; i++)
{ {
var eventName = CsvReader.GetField(i.ToString()); result.Issues.Add(new StudentEventRankingIssue
if (string.IsNullOrEmpty(eventName) || eventName == "") continue;
eventName = eventName.Trim();
if (eventName == "I&I")
eventName = "Inventions & Innovations";
if (eventName == "Med Tech")
eventName = "Medical Technology";
if (eventName.StartsWith("Challenging Tech"))
eventName = "Challenging Technology Issues";
var matches =
(from e in events
let rat = Fuzz.Ratio(e.Name, eventName)
where rat > 90
orderby rat descending
select e).ToList();
if (!matches.Any())
{ {
matches = RowNumber = rowNumber,
(from e in events RawStudentName = name,
where e.Name.StartsWith(eventName) IssueType = StudentEventRankingIssueType.UnmatchedStudent,
select e).ToList(); Message = $"No student matched '{name}'."
} });
continue;
var competitiveEvent = matches.FirstOrDefault();
if (competitiveEvent == null)
{
//todo: throw new ArgumentException($"Event named '{eventName}' not found");
continue;
}
rankings.Add(new StudentEventRanking{
Student = student, EventDefinition = competitiveEvent,
Rank = i});
} }
}
return rankings.ToArray(); var (student, studentScore) = studentMatch.Value;
var acceptedEventsForStudent = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var acceptedRanksForStudent = new HashSet<int>();
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
{
var eventName = CsvReader.GetField(rank.ToString())?.Trim();
if (string.IsNullOrEmpty(eventName))
continue;
var eventResolution = ResolveEvent(events, eventName);
if (eventResolution.Status == EventMatchStatus.Unmatched)
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.UnmatchedEvent,
Message = $"No event matched '{eventName}' for {student.FirstNameLastName} (rank {rank}).",
SuggestedEventName = eventResolution.BestEvent?.Name,
Score = eventResolution.BestScore
});
continue;
}
if (eventResolution.Status == EventMatchStatus.Ambiguous)
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.AmbiguousEvent,
Message = $"'{eventName}' is ambiguous between '{eventResolution.BestEvent?.Name}' and '{eventResolution.RunnerUpEvent?.Name}' for {student.FirstNameLastName} (rank {rank}).",
SuggestedEventName = eventResolution.BestEvent?.Name,
Score = eventResolution.BestScore
});
continue;
}
var matchedEvent = eventResolution.BestEvent!;
if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}"))
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.DuplicateEvent,
Message = $"{student.FirstNameLastName} already has '{matchedEvent.Name}' in this file.",
SuggestedEventName = matchedEvent.Name,
Score = eventResolution.BestScore
});
continue;
}
if (!acceptedRanksForStudent.Add(rank))
{
result.Issues.Add(new StudentEventRankingIssue
{
RowNumber = rowNumber,
Rank = rank,
RawStudentName = name,
RawEventName = eventName,
IssueType = StudentEventRankingIssueType.DuplicateRank,
Message = $"{student.FirstNameLastName} already has a rank {rank} assignment in this file.",
SuggestedEventName = matchedEvent.Name,
Score = eventResolution.BestScore
});
continue;
}
result.Matches.Add(new StudentEventRankingMatch
{
Ranking = new StudentEventRanking
{
Student = student,
EventDefinition = matchedEvent,
Rank = rank
},
RawStudentName = name,
RawEventName = eventName,
StudentScore = studentScore,
EventScore = eventResolution.BestScore,
RowNumber = rowNumber
});
}
}
if (result.Matches.Count == 0 && result.Errors.Count == 0)
result.Warnings.Add("No rankings were accepted from the CSV.");
return result;
} }
private static (Student Student, int Score)? FindStudent(ICollection<Student> students, string name)
{
var ranked = students
.Select(s => (Student: s, Score: ScoreStudent(s, name)))
.Where(x => x.Score >= StudentMatchThreshold)
.OrderByDescending(x => x.Score)
.ToList();
return ranked.Count == 0 ? null : ranked[0];
}
private static int ScoreStudent(Student student, string name)
{
var candidates = new[] { student.Name, student.FirstNameLastName, student.LastNameFirstName };
return candidates.Max(candidate => Math.Max(Fuzz.Ratio(candidate, name), Fuzz.TokenSetRatio(candidate, name)));
}
private static EventResolution ResolveEvent(ICollection<EventDefinition> events, string eventName)
{
var scored = events
.Select(e => (Event: e, Score: ScoreEvent(e, eventName)))
.OrderByDescending(x => x.Score)
.ToList();
if (scored.Count == 0)
return new EventResolution(EventMatchStatus.Unmatched, null, 0, null, 0);
var best = scored[0];
var runnerUp = scored.Count > 1 ? scored[1] : default;
if (best.Score < EventMatchThreshold)
return new EventResolution(EventMatchStatus.Unmatched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
if (runnerUp.Event is not null && best.Score - runnerUp.Score < EventAmbiguityGap)
return new EventResolution(EventMatchStatus.Ambiguous, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
return new EventResolution(EventMatchStatus.Matched, best.Event, best.Score, runnerUp.Event, runnerUp.Score);
}
private static int ScoreEvent(EventDefinition evt, string eventName)
{
List<string> names = [evt.Name];
if (!string.IsNullOrWhiteSpace(evt.ShortName))
names.Add(evt.ShortName);
if (EventAliases.TryGetValue(evt.Name, out var aliases))
names.AddRange(aliases);
return names
.Select(n => new[] { Fuzz.Ratio(n, eventName), Fuzz.TokenSetRatio(n, eventName), Fuzz.PartialRatio(n, eventName) }.Max())
.DefaultIfEmpty(0)
.Max();
}
private enum EventMatchStatus
{
Matched,
Ambiguous,
Unmatched
}
private readonly record struct EventResolution(
EventMatchStatus Status,
EventDefinition? BestEvent,
int BestScore,
EventDefinition? RunnerUpEvent,
int RunnerUpScore);
} }
@@ -0,0 +1,15 @@
using Core.Entities;
using Core.Models;
namespace Core.Services;
/// <summary>
/// Parses student event ranking CSV data against existing students and events.
/// </summary>
public interface IStudentEventRankingImportService
{
/// <summary>
/// Parses ranking CSV from a stream. The stream is not disposed.
/// </summary>
StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events);
}
@@ -0,0 +1,19 @@
using Core.Entities;
using Core.Models;
using Core.Parsers;
namespace Core.Services;
/// <summary>
/// Wraps <see cref="StudentEventRankingParser"/> for stream-based import.
/// </summary>
public class StudentEventRankingImportService : IStudentEventRankingImportService
{
/// <inheritdoc />
public StudentEventRankingParseResult Parse(Stream stream, ICollection<Student> students, ICollection<EventDefinition> events)
{
var reader = new StreamReader(stream, leaveOpen: true);
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
@@ -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; }
}
+28 -2
View File
@@ -172,6 +172,31 @@ docker-compose logs -f webapp
--- ---
## Data Persistence (SQLite + Backups)
The app stores its SQLite database and runtime config under **`Data/`** (capital D) relative to the content root:
| Path in container | Purpose |
|-------------------|---------|
| `/app/Data/app.db` | Live SQLite database |
| `/app/Data/appsettings.json` | Chapter settings overrides (e.g. CompetitionYear) |
| `/app/Data/backups/pre-rollover-*.db` | Automatic backups created by New Year Rollover |
**Docker volume mount must use capital `Data`:**
```yaml
volumes:
- ./data:/app/Data
```
On Linux, `./data:/app/data` is a **different** path and will not persist the database or rollover backups. After a correct mount, host files appear under `./data/` (for example `./data/app.db` and `./data/backups/`).
Ensure the container user can write to the mounted directory (the image runs as a non-root `APP_UID`). If directory creation for `backups/` fails, year rollover aborts before changing data.
For the rollover workflow itself, see `docs/instructions/year-rollover.md`.
---
## Managing Users ## Managing Users
### Adding a New User ### Adding a New User
@@ -310,10 +335,11 @@ curl http://localhost:8080
- [ ] Set file permissions to 600 - [ ] Set file permissions to 600
- [ ] Configured HTTPS/SSL certificates - [ ] Configured HTTPS/SSL certificates
- [ ] Updated `ASPNETCORE_URLS` for production domain - [ ] Updated `ASPNETCORE_URLS` for production domain
- [ ] Configured volume for database persistence - [ ] Configured volume for database persistence as `./data:/app/Data` (capital D)
- [ ] Verified `app.db` appears on the host under `./data/` after first run
- [ ] Removed development endpoints (already done in code) - [ ] Removed development endpoints (already done in code)
- [ ] Set up log monitoring - [ ] Set up log monitoring
- [ ] Configured automatic backups - [ ] Confirmed year-rollover backups write to `./data/backups/` (see `docs/instructions/year-rollover.md`)
- [ ] Tested login with all user roles - [ ] Tested login with all user roles
- [ ] Tested rate limiting (5 failed attempts) - [ ] Tested rate limiting (5 failed attempts)
- [ ] Documented admin password securely - [ ] Documented admin password securely
@@ -0,0 +1,260 @@
using System.Text;
using Core.Entities;
using Core.Models;
using Core.Parsers;
using Tests.Builders;
namespace Tests.Parsers;
[TestFixture]
public class StudentEventRankingParser_Tests
{
private Student _aria = null!;
private EventDefinition _videoGame = null!;
private EventDefinition _coding = null!;
private EventDefinition _jss = null!;
private EventDefinition _digitalPhoto = null!;
private EventDefinition _biotech = null!;
private EventDefinition _inventions = null!;
private EventDefinition _techBowl = null!;
private EventDefinition _techDesign = null!;
private List<Student> _students = null!;
private List<EventDefinition> _events = null!;
[SetUp]
public void SetUp()
{
BuilderExtensions.ResetAllBuilders();
_aria = StudentBuilder.Create("Aria", "Chittenden").Build();
_videoGame = EventDefinitionBuilder.Team("Video Game Design", 2, 6).WithShortName("Video Game").Build();
_coding = EventDefinitionBuilder.Team("Coding", 2, 2).WithShortName("Coding").Build();
_jss = EventDefinitionBuilder.Team("Junior Solar Sprint", 2, 4).WithShortName("JSS").Build();
_digitalPhoto = EventDefinitionBuilder.Individual("Digital Photography").WithShortName("Digital Photo").Build();
_biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).WithShortName("Biotech").Build();
_inventions = EventDefinitionBuilder.Team("Inventions & Innovations", 3, 6).WithShortName("I&I").Build();
_techBowl = EventDefinitionBuilder.Team("Tech Bowl", 3, 3).WithShortName("Tech Bowl").Build();
_techDesign = EventDefinitionBuilder.Team("Technical Design", 2, 2).WithShortName("Tech Design").Build();
_students = [_aria];
_events = [_videoGame, _coding, _jss, _digitalPhoto, _biotech, _inventions, _techBowl, _techDesign];
}
[Test]
public void Parse_LastCommaFirst_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
"Chittenden, Aria",Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_coding));
Assert.That(result.Matches[0].Ranking.Rank, Is.EqualTo(1));
}
[Test]
public void Parse_FirstLast_MatchesStudent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.Student, Is.SameAs(_aria));
}
[Test]
public void Parse_ShortName_MatchesEvent()
{
var result = ParseCsv("""
Student Name,1,2,3
Aria Chittenden,Video Game,JSS,I&I
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(3));
Assert.That(result.Matches.Select(m => m.Ranking.EventDefinition), Is.EqualTo(new[] { _videoGame, _jss, _inventions }));
}
[Test]
public void Parse_NearMissFullName_MatchesEvent()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Digital Photo
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_digitalPhoto));
Assert.That(result.Matches[0].EventScore, Is.GreaterThanOrEqualTo(StudentEventRankingParser.EventMatchThreshold));
}
[Test]
public void Parse_BiotechShortName_MatchesBiotechnology()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Biotech
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_biotech));
}
[Test]
public void Parse_UnmatchedStudent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Nobody Here,Coding
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedStudent));
Assert.That(result.Issues[0].RawStudentName, Is.EqualTo("Nobody Here"));
}
[Test]
public void Parse_SolarRacer_MatchesJuniorSolarSprint()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Solar Racer
""", _students, _events);
Assert.That(result.Issues, Is.Empty);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition, Is.SameAs(_jss));
}
[TestCase("Solar Racer", "Junior Solar Sprint")]
[TestCase("Solar Race", "Junior Solar Sprint")]
[TestCase("Challenging Tech", "Challenging Technology Issues")]
[TestCase("Digital Photo", "Digital Photography")]
[TestCase("Forensics", "Forensic Technology")]
[TestCase("Micro Controller", "Microcontroller Design")]
[TestCase("Med Tech", "Medical Technology")]
[TestCase("Innovations & Inventions", "Inventions & Innovations")]
[TestCase("Inventions and Innovations", "Inventions & Innovations")]
[TestCase("Systems Control Tech", "System Control Technology")]
[TestCase("Systems Control Technology", "System Control Technology")]
[TestCase("Structural Eng", "Structural Engineering")]
[TestCase("Drone Challenge", "Drone Challenge (UAV)")]
[TestCase("Robotics", "TSA Robotics")]
[TestCase("Audio Podcast", "Audio Podcasting")]
public void Parse_KnownAlias_MatchesOfficialEvent(string alias, string officialName)
{
var events = TestEntityHandler.GetEvents().ToList();
events.AddRange(
[
EventDefinitionBuilder.Team("Drone Challenge (UAV)", 2, 6).WithShortName("Drone").Build(),
EventDefinitionBuilder.Team("TSA Robotics", 2, 6).WithShortName("Robotics").Build(),
EventDefinitionBuilder.Team("Audio Podcasting", 2, 6).WithShortName("Podcasting").Build()
]);
var result = ParseCsv($"""
Student Name,1
Aria Chittenden,{alias}
""", _students, events);
Assert.That(result.Issues, Is.Empty, $"Expected '{alias}' to match '{officialName}'");
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Matches[0].Ranking.EventDefinition.Name, Is.EqualTo(officialName));
}
[Test]
public void Parse_UnmatchedEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Underwater Basket Weaving
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.UnmatchedEvent));
Assert.That(result.Issues[0].RawEventName, Is.EqualTo("Underwater Basket Weaving"));
}
[Test]
public void Parse_AmbiguousEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1
Aria Chittenden,Tech
""", _students, _events);
Assert.That(result.Matches, Is.Empty);
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.AmbiguousEvent));
}
[Test]
public void Parse_RankColumnsBeyondSix_AreRead()
{
var result = ParseCsv("""
Student Name,1,2,3,4,5,6,7
Aria Chittenden,Coding,,,,,,JSS
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(2));
Assert.That(result.Matches.Select(m => m.Ranking.Rank), Is.EqualTo(new[] { 1, 7 }));
Assert.That(result.Matches[1].Ranking.EventDefinition, Is.SameAs(_jss));
}
[Test]
public void Parse_DuplicateEvent_IsReported()
{
var result = ParseCsv("""
Student Name,1,2
Aria Chittenden,Coding,Coding
""", _students, _events);
Assert.That(result.Matches, Has.Count.EqualTo(1));
Assert.That(result.Issues, Has.Count.EqualTo(1));
Assert.That(result.Issues[0].IssueType, Is.EqualTo(StudentEventRankingIssueType.DuplicateEvent));
}
[Test]
public void Parse_MissingStudentNameHeader_IsError()
{
var result = ParseCsv("""
Name,1
Aria Chittenden,Coding
""", _students, _events);
Assert.That(result.IsSuccess, Is.False);
Assert.That(result.Errors, Is.Not.Empty);
}
[Test]
public void Parse_2024RankingsFile_MatchesKnownStudentsAndEvents()
{
var events = TestEntityHandler.GetEvents();
var students = TestEntityHandler.GetStudents(events);
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
Assert.That(students, Has.Length.EqualTo(29));
Assert.That(rankings, Is.Not.Empty);
Assert.That(rankings.Select(r => r.Student.FirstNameLastName).Distinct().Count(), Is.EqualTo(29));
Assert.That(rankings.All(r => r.EventDefinition is not null), Is.True);
Assert.That(rankings.All(r => r.Rank is >= 1 and <= 10), Is.True);
Assert.That(rankings.Count(r => r.Student.FirstName == "First26"), Is.EqualTo(7));
}
private static StudentEventRankingParseResult ParseCsv(
string csv,
ICollection<Student> students,
ICollection<EventDefinition> events)
{
using var reader = new StreamReader(new MemoryStream(Encoding.UTF8.GetBytes(csv)));
using var parser = new StudentEventRankingParser(reader);
return parser.Parse(students, events);
}
}
+3 -3
View File
@@ -69,9 +69,9 @@ public static class TestEntityHandler
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events) public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
{ {
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv"); var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv");
var rankingParser = new StudentEventRankingParser(fileInfo); using var rankingParser = new StudentEventRankingParser(fileInfo);
return rankingParser.Parse(students, events); return [.. rankingParser.Parse(students, events).Rankings];
} }
} }
@@ -0,0 +1,212 @@
General Session
Registration April 9 4:00 p.m. - 6:00 p.m. Banquet Room E
TECHSPO April 9 4:00 p.m. - 6:00 p.m. Main Hallway
Tennessee TSA Store April 9 4:00 p.m. - 8:00 p.m. Meeting Room 1
Curfew April 9 11:00 p.m. All Conference Locations
Static Event Turn-In April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Help Desk April 10 10:00 a.m. - 5:00 p.m. CCC Lobby
Mandatory Advisor Meeting April 10 10:00 a.m. - 11:00 a.m. Banquet Room E
TECHSPO April 10 10:00 a.m. - 4:00 p.m. Main Hallway
Tennessee TSA Store April 10 10:00 a.m. - 11:30 a.m. Meeting Room 1
Branching Out Workshop April 10 11:30 a.m. - 12:30 p.m. Banquet Room E
Finding Your Roots Workshop April 10 12:30 p.m. - 1:30 p.m. Banquet Room E
Tennessee TSA Store April 10 12:30 p.m. - 4:00 p.m. Meeting Room 1
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
Chapter Officer Meeting April 10 2:30 p.m. - 3:00 p.m. Banquet Room E
Board Game Night April 10 8:00 p.m. - 10:00 p.m. Meeting Room 14
Dance April 10 8:00 p.m. - 10:00 p.m. Exhibit Hall D
Senior Social April 10 8:00 p.m. - 8:30 p.m. Banquet Room E
Curfew April 10 11:00 p.m. All Conference Locations
Voting Delegate Meeting April 11 8:00 a.m. - 9:00 a.m. Banquet Room E
Help Desk April 11 9:00 a.m. - 5:00 p.m. CCC Lobby
Tennessee TSA Store April 11 9:00 a.m. - 11:30 a.m. Meeting Room 1
Tennessee TSA Store April 11 12:30 p.m. - 4:00 p.m. Meeting Room 1
Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
Creating Community through Communication Workshop April 11 11:00 p.m. - 12:00 p.m. Banquet Room E
Curfew April 11 11:00 p.m. All Conference Locations
Awards Ceremony April 12 8:30 a.m. - 12:00 p.m. Exhibit Hall A
Audio Podcasting – MS
Prompt Pick-Up April 9 6:00 p.m. Online
Semifinalist Submissions Due April 11 9:00 a.m. Online
Biotechnology – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 9:30 a.m. - 11:30 a.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
CAD Foundations – MS
Setup, Event, & Interviews April 10 10:30 a.m. - 2:00 p.m. Meeting Room 6
Career Prep – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 1:00 p.m. - 3:00 p.m. Meeting Room 10
Challenging Technology Issues – MS
*NOTE: Preliminary Round Time Sign-ups listed as April 8 in Yapp — verify with event coordinator
Preliminary Round Time Sign-ups April 8 6:00 p.m. - 7:00 p.m. Online
Prelims Presentation – Holding Room April 10 10:30 a.m. - 1:00 p.m. Meeting Room 7
Prelims Presentation – Presentation April 10 10:30 a.m. - 1:00 p.m. Meeting Room 8
Semifinalist Round Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentation – Holding Room April 11 9:30 a.m. - 11:00 a.m. Meeting Room 9
Semifinalist Presentation – Presentation April 11 9:30 a.m. - 11:00 a.m. Meeting Room 10
Children's Stories – MS
Submit Entry April 9 6:00 p.m. - 7:00 p.m. Banquet Hall G
Judging April 10 9:00 a.m. - 5:00 p.m. Meeting Room 13
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Reading/Interviews April 11 1:00 p.m. - 4:00 p.m. Meeting Room 18
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Coding – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
On-Site Event April 11 12:30 p.m. - 3:00 p.m. Meeting Room 19
Community Service Video – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 10 3:00 p.m. - 4:00 p.m. Meeting Room 17
Construction Challenge – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 10:30 a.m. - 12:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Cybersecurity – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
Data Science and Analytics – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Preparation April 11 3:00 p.m. - 4:30 p.m. Meeting Room 4
Semifinalist Presentations April 11 3:00 p.m. - 4:30 p.m. Meeting Room 5
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Digital Photography – MS
Semifinalist Setup, Onsite Problem April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
Semifinalist Time Sign-Up April 10 10:00 a.m. - 1:00 p.m. Meeting Room 19
Semifinalist Interviews April 10 4:00 p.m. - 5:00 p.m. Meeting Room 16
Dragster – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
Time Trials April 10 10:00 a.m. - 11:00 a.m. Exhibit Hall B
Semifinalist Interview Sign-ups April 10 12:00 p.m. - 12:15 p.m. Exhibit Hall B
Semifinalist Interviews April 10 12:30 p.m. - 1:30 p.m. Exhibit Hall B
Semifinalist Races April 10 2:30 p.m. - 3:00 p.m. Exhibit Hall B
Project Pick-up April 10 5:00 p.m. - 5:30 p.m. Exhibit Hall B
Flight – MS
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Preliminary Round Testing April 10 10:00 a.m. - 11:30 a.m. Exhibit Hall C
Semifinalist Construction and Flights April 10 1:00 p.m. - 3:30 p.m. Exhibit Hall C
All Event Materials Picked Up April 10 4:00 p.m. - 4:30 p.m. Exhibit Hall C
Forensic Technology – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
Inventions and Innovations – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 11:30 a.m. - 1:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Leadership Strategies – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Presentation – Delivery April 10 1:30 p.m. - 4:00 p.m. Meeting Room 8
Preliminary Presentation – Holding Room April 10 1:30 p.m. - 4:00 p.m. Meeting Room 7
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinals Presentation – Delivery April 11 11:30 a.m. - 1:00 p.m. Meeting Room 10
Semifinals Presentation – Holding Room April 11 11:30 a.m. - 1:00 p.m. Meeting Room 9
Mass Production – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 12:30 p.m. - 2:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Mechanical Engineering – MS
Submit Entry & Sign Up April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Design Trial April 10 3:00 p.m. - 4:00 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Medical Technology – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 1:30 p.m. - 3:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Microcontroller Design – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Presentation Time Sign-ups April 10 6:00 p.m. - 7:00 p.m. Online
Presentations/Interviews April 11 2:00 p.m. - 4:30 p.m. Exhibit Hall C
Off the Grid – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall C
Judging April 10 9:00 a.m. - 5:00 p.m. Exhibit Hall C
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations/Interviews April 11 2:30 p.m. - 4:30 p.m. Exhibit Hall C
Project Pick-up April 11 5:00 p.m. - 5:30 p.m. Exhibit Hall C
Prepared Speech – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Presentations April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 1:30 p.m. - 3:00 p.m. Meeting Room 10
Problem Solving – MS
Peer Kit Check April 11 12:00 p.m. - 12:30 p.m. Exhibit Hall C
Onsite Problem April 11 12:30 p.m. - 3:00 p.m. Exhibit Hall C
Promotional Marketing – MS
Semifinalist Setup, Onsite Problem April 11 9:30 a.m. - 11:00 a.m. Meeting Room 6
TSA Robotics – MS
Preliminary Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Preliminary Round Review April 10 2:00 p.m. - 3:30 p.m. Exhibit Hall B
Semifinalist Time Sign-Ups April 10 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 11 10:30 a.m. - 11:30 a.m. Exhibit Hall B
STEM Animation – MS
Semifinalist Sign-ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Presentations April 11 9:30 a.m. - 11:00 a.m. Meeting Room 16
Structural Engineering – MS
Submit Entry April 10 8:00 a.m. - 9:00 a.m. Exhibit Hall B
Semifinalist Build April 11 9:00 a.m. - 12:00 p.m. Exhibit Hall B
Semifinalist Testing April 11 3:30 p.m. - 4:00 p.m. Exhibit Hall B
Project Pick-up April 11 4:30 p.m. - 5:00 p.m. Exhibit Hall B
System Control Technology – MS
Set-up, Performance, and Judging April 10 10:00 a.m. - 2:00 p.m. Meeting Room 3
Tech Bowl – MS
On-Site Preliminary Exam Testing Window April 9 6:00 p.m. - 9:00 p.m. Banquet Hall I
Bracket Released April 10 6:00 p.m. Online
Semifinalist Competition April 11 9:00 a.m. - 1:00 p.m. Banquet Hall G
Semifinalist Holding April 11 9:00 a.m. - 1:00 p.m. Banquet Hall H
Technical Design – MS
Prompt Release April 10 10:00 a.m. Online
Solution Submit April 11 9:00 a.m. - 10:00 a.m. Online
Judging April 11 10:00 a.m. - 2:00 p.m. CRC
Video Game Design – MS
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
Website Design – MS
Semifinalist Time Sign-Ups April 9 6:00 p.m. - 7:00 p.m. Online
Semifinalist Interviews April 10 10:00 a.m. - 12:00 p.m. Meeting Room 16
@@ -0,0 +1,230 @@
using Core.Entities;
using Core.Models;
using Core.YearTransition;
using NUnit.Framework;
using Tests.Builders;
namespace Tests.YearTransition;
[TestFixture]
public class GraduatingGradeResolver_Tests
{
[Test]
public void FromSchoolLevel_MiddleSchool_Returns8()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.MiddleSchool), Is.EqualTo(8));
}
[Test]
public void FromSchoolLevel_HighSchool_Returns12()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.HighSchool), Is.EqualTo(12));
}
[Test]
public void FromSchoolLevel_Null_ReturnsNull()
{
Assert.That(GraduatingGradeResolver.FromSchoolLevel(null), Is.Null);
}
}
[TestFixture]
public class YearTransitionPlanner_Tests
{
[SetUp]
public void SetUp()
{
StudentBuilder.ResetIdCounter();
}
[Test]
public void SuggestReturning_BelowGraduatingGrade_IsTrue()
{
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.True);
}
[Test]
public void SuggestReturning_AtGraduatingGrade_IsFalse()
{
var student = StudentBuilder.Create("Ann", "Lee").WithGrade(8).Build();
Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.False);
}
[Test]
public void Build_PromotesReturningStudents_MiddleSchool()
{
var returning = StudentBuilder.Create("Ann", "Lee").WithGrade(6).WithTsaYear(1).Build();
var graduating = StudentBuilder.Create("Bob", "Smith").WithGrade(8).WithTsaYear(3).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [returning, graduating],
ReturningStudentIds = new HashSet<int> { returning.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027"
});
Assert.That(plan.ReturningCount, Is.EqualTo(1));
Assert.That(plan.RemovalCount, Is.EqualTo(1));
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(7));
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(2));
Assert.That(plan.StudentsToRemove[0].Id, Is.EqualTo(graduating.Id));
}
[Test]
public void Build_CapsGradeAtGraduatingGrade_HighSchool()
{
var senior = StudentBuilder.Create("Chris", "Young").WithGrade(12).WithTsaYear(4).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [senior],
ReturningStudentIds = new HashSet<int> { senior.Id },
GraduatingGrade = 12,
TargetCompetitionYear = "2027"
});
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(12));
Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(5));
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
}
[Test]
public void Build_CapsGradeAtEight_WhenMiddleSchoolReturnerAtGraduatingGrade()
{
var eighth = StudentBuilder.Create("Dana", "Nguyen").WithGrade(8).WithTsaYear(2).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [eighth],
ReturningStudentIds = new HashSet<int> { eighth.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027"
});
Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(8));
Assert.That(plan.Warnings, Has.Some.Contain("graduating grade"));
}
[Test]
public void Build_AssignsOfficersAndClearsOthers()
{
var president = StudentBuilder.Create("Eve", "Adams").WithGrade(7).AsPresident().Build();
var vp = StudentBuilder.Create("Frank", "Baker").WithGrade(6).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [president, vp],
ReturningStudentIds = new HashSet<int> { president.Id, vp.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = vp.Id,
[OfficerRole.VicePresident] = president.Id
}
});
var eve = plan.Promotions.Single(p => p.Student.Id == president.Id);
var frank = plan.Promotions.Single(p => p.Student.Id == vp.Id);
Assert.That(eve.PreviousOfficerRole, Is.EqualTo(OfficerRole.President));
Assert.That(eve.NewOfficerRole, Is.EqualTo(OfficerRole.VicePresident));
Assert.That(frank.NewOfficerRole, Is.EqualTo(OfficerRole.President));
var presidentChange = plan.OfficerChanges.Single(c => c.Role == OfficerRole.President);
Assert.That(presidentChange.PreviousOfficer!.Id, Is.EqualTo(president.Id));
Assert.That(presidentChange.NewOfficer!.Id, Is.EqualTo(vp.Id));
}
[Test]
public void Build_WarnsWhenOfficerAssignedToNonReturningStudent()
{
var returning = StudentBuilder.Create("Gina", "Cole").WithGrade(6).Build();
var leaving = StudentBuilder.Create("Hank", "Diaz").WithGrade(8).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [returning, leaving],
ReturningStudentIds = new HashSet<int> { returning.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = leaving.Id
}
});
Assert.That(plan.Warnings, Has.Some.Contain("not marked returning"));
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
}
[Test]
public void Build_WarnsWhenSameStudentHasTwoOffices()
{
var student = StudentBuilder.Create("Ivy", "Evans").WithGrade(7).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [student],
ReturningStudentIds = new HashSet<int> { student.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[OfficerRole.President] = student.Id,
[OfficerRole.Treasurer] = student.Id
}
});
Assert.That(plan.Warnings, Has.Some.Contain("more than one officer role"));
Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null);
}
[Test]
public void MatchPastedNames_MatchesLastCommaFirstAndFirstLast()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Bob", "Smith").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames(
[a, b],
["Lee, Ann", "Bob Smith", "Nobody Here"]);
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" }));
}
[Test]
public void MatchPastedNames_AmbiguousWhenDuplicateNames()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
var result = YearTransitionPlanner.MatchPastedNames([a, b], ["Ann Lee"]);
Assert.That(result.AmbiguousNames, Is.EquivalentTo(new[] { "Ann Lee" }));
Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id }));
}
[Test]
public void Build_IncludesUnmatchedAndAmbiguousFromPaste()
{
var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build();
var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build();
var plan = YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = [a, b],
ReturningStudentIds = new HashSet<int> { a.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
PastedNames = ["Ann Lee", "Zed Zulu"]
});
Assert.That(plan.AmbiguousPastedNames, Has.Member("Ann Lee"));
Assert.That(plan.UnmatchedPastedNames, Has.Member("Zed Zulu"));
Assert.That(plan.Warnings, Has.Some.Contain("matched more than one"));
}
}
+1 -1
View File
@@ -22,10 +22,10 @@
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script> <script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script> <script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script> <script src="js/markdownTablePaste.js"></script>
<script src="js/login.js"></script>
</body> </body>
</html> </html>
@@ -134,7 +134,7 @@
if (e.Key == "Enter") if (e.Key == "Enter")
{ {
// Blur the active element to ensure MudTextField bindings update // Blur the active element to ensure MudTextField bindings update
await JS.InvokeVoidAsync("eval", "document.activeElement.blur()"); await JS.InvokeVoidAsync("tsaLogin.blurActiveElement");
// Small delay to allow bindings to process // Small delay to allow bindings to process
await Task.Delay(50); await Task.Delay(50);
@@ -146,14 +146,11 @@
private async Task HandleFormSubmit() private async Task HandleFormSubmit()
{ {
// Update hidden inputs with current model values, then submit the form await JS.InvokeVoidAsync(
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl); "tsaLogin.submitForm",
await JS.InvokeVoidAsync("eval", $@" _loginModel.Email ?? string.Empty,
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)}; _loginModel.Password ?? string.Empty,
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)}; _loginModel.RememberMe,
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}'; _returnUrl ?? string.Empty);
document.getElementById('returnUrlInput').value = {returnUrlValue};
document.getElementById('loginForm').submit();
");
} }
} }
@@ -1 +1,72 @@
@namespace WebApp.Components.Features.Calendar
@using Core.Entities
<MudDialog>
<DialogContent>
@if (EventOccurrence == null)
{
<MudAlert Severity="Severity.Warning">
Event details are unavailable.
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.h6">
@(EventDefinition?.Name ?? EventOccurrence.Name)
</MudText>
<MudDivider />
<MudText Typo="Typo.body1">
<strong>Occurrence:</strong> @EventOccurrence.Name
</MudText>
<MudText Typo="Typo.body1">
<strong>Start:</strong> @EventOccurrence.StartTime.ToString("f")
</MudText>
@if (EventOccurrence.EndTime != null)
{
<MudText Typo="Typo.body1">
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
</MudText>
}
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
{
<MudText Typo="Typo.body1">
<strong>Location:</strong> @EventOccurrence.Location
</MudText>
}
@if (StudentFirstNames.Any())
{
<MudText Typo="Typo.body1">
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
public IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventOccurrence? EventOccurrence { get; set; }
[Parameter]
public EventDefinition? EventDefinition { get; set; }
[Parameter]
public List<string> StudentFirstNames { get; set; } = [];
private void Close()
{
MudDialog.Close();
}
}
@@ -14,6 +14,9 @@
<MudTooltip Text="Import"> <MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton> <MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator"> <AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Admin"> <MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton> <MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
@@ -0,0 +1,422 @@
@page "/calendar/state-schedule-handout"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Options
@using System.Globalization
@using WebApp.Models
@using WebApp.Utility
@using WebApp.Services
@inject AppDbContext Context
@inject IConfiguration Configuration
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
@inject IEventOccurrenceService EventOccurrenceService
<div class="no-print">
<PageHeader
Title="State schedule handout"
Description="Print per-student schedules and the combined master list."
Icon="@Icons.Material.Filled.Print"
ShowBackButton="true"
BackButtonUrl="/calendar" />
</div>
@if (_students == null || _allOccurrences == null)
{
<p><em>Loading...</em></p>
}
else
{
var opts = HandoutOptionsMonitor.CurrentValue;
<MudContainer Class="state-schedule-handout">
@foreach (var student in _students)
{
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5">
@if (string.IsNullOrWhiteSpace(student.StateId))
{
@student.Name
}
else
{
@($"{student.Name} - {student.StateId}")
}
</MudText>
<MudText Typo="Typo.h6" Class="mb-3">
TSA @_competitionYear @_stateAbbrev State Schedule
</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
<thead>
<tr>
<th>State ID</th>
<th>Event</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
@foreach (var eventRow in GetEventSummaryRows(student))
{
<tr>
<td>@eventRow.StateRegistrationId</td>
<td>@eventRow.EventName</td>
<td>@eventRow.Activity</td>
</tr>
}
</tbody>
</MudSimpleTable>
@{
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
}
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
@if (scheduleRows.Count == 0)
{
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
}
else
{
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
{
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatEventColumn(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudContainer>
}
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
@{
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
}
@if (combinedOccurrences.Count == 0)
{
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
}
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var tlGroup in dateGroup
.OrderBy(o => o.StartTime)
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
.Select(g => g.ToList()))
{
if (tlGroup.Count == 1)
{
var occ = tlGroup[0];
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatCombinedScheduleEventCell(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
else
{
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
var specificOccs = tlGroup
.Where(o => o.EventDefinitionId.HasValue)
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
.ToList();
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
var representative = genericOcc ?? specificOccs[0];
if (genericOcc != null)
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs)
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
else
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs.Skip(1))
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
}
}
</tbody>
</MudSimpleTable>
}
</MudContainer>
</MudContainer>
}
@code {
private Student[]? _students;
private List<EventOccurrence>? _allOccurrences;
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
private string _competitionYear = "";
private string _stateAbbrev = "";
private string? _chapterStateId;
protected override async Task OnInitializedAsync()
{
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
_chapterStateId = Configuration["ChapterSettings:StateId"];
_allOccurrences = await Context.EventOccurrences
.AsNoTracking()
.Include(eo => eo.EventDefinition)
.OrderBy(eo => eo.StartTime)
.ToListAsync();
var eventDefIds = _allOccurrences
.Where(o => o.EventDefinitionId.HasValue)
.Select(o => o.EventDefinitionId!.Value)
.Distinct()
.ToList();
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
// Tracking required: Include Teams->Students creates a graph cycle (Student–Team–Student) that EF disallows with AsNoTracking().
_students = await Context.Students
.Include(s => s.Teams)
.ThenInclude(t => t!.Event)
.Include(s => s.Teams)
.ThenInclude(t => t!.Captain)
.Include(s => s.Teams)
.ThenInclude(t => t!.Students)
.OrderBy(s => s.FirstName)
.ThenBy(s => s.LastName)
.ToArrayAsync();
}
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
{
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
var competition = _allOccurrences!
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
var special = _allOccurrences!
.Where(o => o.EventDefinitionId == null)
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
return competition
.Concat(special)
.OrderBy(o => o.StartTime)
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
}
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
{
return _allOccurrences!
.Where(o =>
{
// Keep chapter-wide/special schedule rows.
if (!o.EventDefinitionId.HasValue)
return true;
// Keep only competition events where this chapter has registered teams.
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
})
.OrderBy(o => o.StartTime);
}
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
{
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
{
yield return new EventSummaryRow(
StateRegistrationId: FormatStateRegistrationId(team, student),
EventName: team.Event.Name,
Activity: FormatActivitySummary(team, student));
}
}
/// <summary>
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
/// Individual events: competitor's <see cref="Student.StateId"/>.
/// </summary>
private string FormatStateRegistrationId(Team team, Student student)
{
if (team.Event.EventFormat == EventFormat.Individual)
{
return string.IsNullOrWhiteSpace(student.StateId)
? "—"
: student.StateId.Trim();
}
var chap = _chapterStateId?.Trim();
var ident = team.Identifier?.Trim();
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
return "—";
// Already a full registration id (e.g. "12227-1" or state id stored on team)
if (!string.IsNullOrEmpty(ident))
{
if (ident.Contains('-', StringComparison.Ordinal))
return ident;
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
return ident;
}
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
return $"{chap}-{ident}";
return !string.IsNullOrEmpty(chap) ? chap : ident!;
}
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
private static string FormatActivitySummary(Team team, Student student)
{
var parts = new List<string>();
if (team.Captain?.Id == student.Id)
parts.Add("(Cpt.)");
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
parts.Add(team.Event.SemifinalistActivity!);
return string.Join(" ", parts).Trim();
}
private static string FormatDateHeading(DateTime date) =>
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
private static string FormatTimeDisplay(EventOccurrence o)
{
if (!string.IsNullOrWhiteSpace(o.Time))
return o.Time.Trim();
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
}
private static string FormatEventColumn(EventOccurrence o)
{
if (o.EventDefinition != null)
{
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
? o.EventDefinition.ShortName
: o.EventDefinition.Name;
if (string.IsNullOrWhiteSpace(o.Name))
return ev;
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
return o.Name.Trim();
return $"{ev} {o.Name}".Trim();
}
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
}
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
{
var baseText = FormatEventColumn(occ);
if (!occ.EventDefinitionId.HasValue)
return baseText;
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
return baseText;
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
var orderedTeams = teams
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
{
var cmp = CombinedScheduleTeamSortOrder(a, b);
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
}))
.ToList();
var rosterStrings = orderedTeams
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (rosterStrings.Count == 0)
return baseText;
var suffix = rosterStrings.Count == 1
? rosterStrings[0]
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
return $"{baseText} — {suffix}";
}
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
{
var ka = a.Identifier?.Trim() ?? "";
var kb = b.Identifier?.Trim() ?? "";
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
return na.CompareTo(nb);
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
}
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
{
var students = team.Students?.ToList() ?? [];
if (students.Count == 0)
return "";
if (isIndividual)
{
var ordered = students.OrderBy(s => s.FirstName, 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);
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
{
if (isIndividual)
{
var sid = student.StateId?.Trim();
return !string.IsNullOrEmpty(sid)
? $"{student.FirstName} ({sid})"
: student.FirstName;
}
var isCpt = team.Captain?.Id == student.Id;
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
}
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
}
@@ -7,7 +7,7 @@
<PageHeader <PageHeader
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")" Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="Yearly theme: Unity Through Community" /> Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
@if (_events == null) @if (_events == null)
{ {
@@ -66,84 +66,7 @@ else
{ {
<MudItem xs="3"> <MudItem xs="3">
<MudText Class="d-flex py-1"> <MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i> <i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
</MudText>
</MudItem>
<MudItem xs="8">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Theme</MudText>
</MudItem>
}
@if (!string.IsNullOrEmpty(evt.Documentation))
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Materials:</i>
</MudText>
</MudItem>
<MudItem xs="8">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Documentation</MudText>
</MudItem>
}
</MudGrid>
</MudContainer>
<MudDivider />
}
</MudContainer>
<MudContainer>
@foreach (var evt in _events)
{
<MudContainer Class="mt-3 mb-1 nobrk">
<MudGrid>
<MudItem xs="4">
<MudStack>
<MudItem>
<MudText Class="d-flex py-1" Typo="Typo.h5">@evt.Name</MudText>
</MudItem>
@if (evt.RegionalEvent)
{
<MudItem>
<MudText Class="d-flex" Typo="Typo.caption"><i>Regional Event</i></MudText>
</MudItem>
}
</MudStack>
</MudItem>
<MudItem xs="2">
<MudText>
@if (evt.EventFormat is EventFormat.Team)
{
<strong>@evt.EventFormat</strong>
<br />
<p>Size: <strong>@evt.TeamSize</strong></p>
}
else
{
<strong>@evt.EventFormat</strong>
}
</MudText>
</MudItem>
<MudItem xs="3">
Eligibility: @evt.Eligibility
</MudItem>
<MudItem xs="1">
<strong> Effort</strong>: @evt.LevelOfEffort
</MudItem>
<MudItem xs="2">
<strong>Activity</strong>: @evt.SemifinalistActivity
</MudItem>
<MudItem xs="12">
<MudText Class="d-flex py-1 pre-wrap-text">@evt.Description</MudText>
</MudItem>
@if (!string.IsNullOrEmpty(evt.Theme))
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i>
</MudText> </MudText>
</MudItem> </MudItem>
<MudItem xs="8"> <MudItem xs="8">
@@ -11,6 +11,14 @@
Title="Student Event Ranks" Title="Student Event Ranks"
Icon="@AppIcons.EventRank"> Icon="@AppIcons.EventRank">
<ActionButtons> <ActionButtons>
<MudTooltip Text="Import rankings from CSV">
<MudButton StartIcon="@Icons.Material.Filled.UploadFile"
Href="students/event-ranking/import"
Variant="Variant.Filled"
Color="Color.Primary">
Import
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Event Ranking" /> <PageNoteButton PageIdentifier="Event Ranking" />
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@@ -1,9 +1,7 @@
@page "/students/event-ranking-edit/{StudentId:int}" @page "/students/event-ranking-edit/{StudentId:int}"
@attribute [Authorize] @attribute [Authorize]
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using BlazorSortableList
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components
@inject AppDbContext Context @inject AppDbContext Context
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -33,7 +31,6 @@ else
</ActionButtons> </ActionButtons>
</PageHeader> </PageHeader>
@* https://github.com/AlexNek/BlazorSortableList *@
<MudGrid Class="mt-2"> <MudGrid Class="mt-2">
<MudItem xs="12" md="6" xl="4"> <MudItem xs="12" md="6" xl="4">
<MudPaper Class="pa-3 mb-3" Elevation="2"> <MudPaper Class="pa-3 mb-3" Elevation="2">
@@ -41,29 +38,52 @@ else
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" Class="mr-2" Color="Color.Primary" /> <MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" Class="mr-2" Color="Color.Primary" />
<MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText> <MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText>
</div> </div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events here in order of preference</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">
Use the arrows to set rank. Use the X to remove an event. Up to @StudentEventRanking.MaxRank events.
</MudText>
</MudPaper> </MudPaper>
<SortableList @if (_rankedEvents.Count == 0)
Group="GroupId" Id="ListId1" Context="item" {
Items="_rankedEvents" OnRemove="RankedEventsRemove" OnUpdate="Update"> <MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
<SortableItemTemplate> No ranked events yet. Add events from the list on the right.
<MudCard Outlined="true" Class="mb-2"> </MudAlert>
<MudCardContent Class="pa-2"> }
<div class="d-flex align-center"> @for (var i = 0; i < _rankedEvents.Count; i++)
<MudBadge Content="@(_rankedEvents.IndexOf(item) + 1)" Color="Color.Primary" Overlap="true" Class="mr-3"> {
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" /> var index = i;
</MudBadge> var item = _rankedEvents[index];
<div class="flex-grow-1"> var rank = index + 1;
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText> <MudCard @key="item.Id" Outlined="true" Class="mb-2">
<MudText Typo="Typo.caption"> <MudCardContent Class="pa-2">
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item) <div class="d-flex align-center">
</MudText> <MudBadge Content="@rank" Color="Color.Primary" Overlap="true" Class="mr-3">
</div> <MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" />
</div> </MudBadge>
</MudCardContent> <div class="flex-grow-1">
</MudCard> <MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
</SortableItemTemplate> <MudText Typo="Typo.caption">
</SortableList> @AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
</MudText>
</div>
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp"
Size="Size.Small"
Disabled="@(index == 0)"
aria-label="Move up"
OnClick="@(() => MoveRanked(item, -1))" />
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown"
Size="Size.Small"
Disabled="@(index == _rankedEvents.Count - 1)"
aria-label="Move down"
OnClick="@(() => MoveRanked(item, 1))" />
<MudIconButton Icon="@Icons.Material.Filled.Close"
Size="Size.Small"
Color="Color.Default"
aria-label="Remove from ranked events"
OnClick="@(() => RemoveRanked(item))" />
</div>
</MudCardContent>
</MudCard>
}
</MudItem> </MudItem>
<MudItem xs="12" md="6" xl="4"> <MudItem xs="12" md="6" xl="4">
<MudPaper Class="pa-3 mb-3" Elevation="2"> <MudPaper Class="pa-3 mb-3" Elevation="2">
@@ -71,99 +91,117 @@ else
<MudIcon Icon="@AppIcons.Events" Class="mr-2" /> <MudIcon Icon="@AppIcons.Events" Class="mr-2" />
<MudText Typo="Typo.h6">Available Events</MudText> <MudText Typo="Typo.h6">Available Events</MudText>
</div> </div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events to rank them</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">
@if (AtMaxRank)
{
<text>Maximum of @StudentEventRanking.MaxRank ranked events reached. Remove one to add another.</text>
}
else
{
<text>Use Add to place an event at the end of the ranked list.</text>
}
</MudText>
</MudPaper> </MudPaper>
<SortableList @if (_availableEvents.Count == 0)
Group="GroupId" Id="ListId2" Context="item" {
Items="_availableEvents" OnRemove="AvailableEventsRemove" Sort="false"> <MudAlert Severity="Severity.Info" Dense="true">
<SortableItemTemplate> @(_rankedEvents.Count == 0 ? "No events are available to rank." : "All events are already ranked.")
<MudCard Outlined="true" Class="mb-2"> </MudAlert>
<MudCardContent Class="pa-2"> }
@foreach (var item in _availableEvents)
{
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
<MudCardContent Class="pa-2">
<div class="d-flex align-center">
<div class="flex-grow-1">
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText> <MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
<MudText Typo="Typo.caption"> <MudText Typo="Typo.caption">
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item) @AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
</MudText> </MudText>
</MudCardContent> </div>
</MudCard> <MudIconButton Icon="@Icons.Material.Filled.Add"
</SortableItemTemplate> Color="Color.Primary"
</SortableList> Disabled="@AtMaxRank"
aria-label="@($"Add {item.Name} to ranked events")"
OnClick="@(() => AddRanked(item))" />
</div>
</MudCardContent>
</MudCard>
}
</MudItem> </MudItem>
</MudGrid> </MudGrid>
} }
@code { @code {
private const string ListId1 = "SharedListId1";
private const string ListId2 = "SharedListId2";
private const string GroupId = "CommonGroup";
[Parameter] public int? StudentId { get; set; } [Parameter] public int? StudentId { get; set; }
private Student? _student; private Student? _student;
private List<EventDefinition>? _events;
public List<EventDefinition> _rankedEvents = []; private List<EventDefinition> _rankedEvents = [];
public List<EventDefinition> _availableEvents = []; private List<EventDefinition> _availableEvents = [];
private void RankedEventsRemove((int oldIndex, int newIndex) indices) private bool AtMaxRank => _rankedEvents.Count >= StudentEventRanking.MaxRank;
{
// get the item at the old index in list 1
var item = _rankedEvents[indices.oldIndex];
// add it to the new index in list 2
_availableEvents.Insert(indices.newIndex, item);
// remove the item from the old index in list 1
_rankedEvents.Remove(_rankedEvents[indices.oldIndex]);
}
private void AvailableEventsRemove((int oldIndex, int newIndex) indices)
{
// get the item at the old index in list 2
var item = _availableEvents[indices.oldIndex];
// add it to the new index in list 1
_rankedEvents.Insert(indices.newIndex, item);
// remove the item from the old index in list 2
_availableEvents.Remove(_availableEvents[indices.oldIndex]);
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_student = _student =
await Context.Students await Context.Students
.Include(e => e.EventRankings) .Include(e => e.EventRankings)
.Where(e => e.Id == StudentId).FirstAsync(); .ThenInclude(r => r.EventDefinition)
_events = .Where(e => e.Id == StudentId)
.FirstAsync();
var events =
await Context.Events await Context.Events
.OrderBy(e => e.Name) .OrderBy(e => e.Name)
.ToListAsync(); .ToListAsync();
_rankedEvents = _student.EventRankings.OrderBy(e => e.Rank).Select(e => e.EventDefinition).ToList(); _rankedEvents = _student.EventRankings
_availableEvents = _events.Where(e => !_rankedEvents.Contains(e)).ToList(); .Where(e => e.EventDefinition != null)
.OrderBy(e => e.Rank)
.Select(e => e.EventDefinition)
.DistinctBy(e => e.Id)
.Take(StudentEventRanking.MaxRank)
.ToList();
var rankedIds = _rankedEvents.Select(e => e.Id).ToHashSet();
_availableEvents = events.Where(e => !rankedIds.Contains(e.Id)).ToList();
} }
private void Update((int oldIndex, int newIndex) indices) private void AddRanked(EventDefinition item)
{ {
var (oldIndex, newIndex) = indices; if (AtMaxRank || _rankedEvents.Any(e => e.Id == item.Id))
var items = _rankedEvents;
var itemToMove = items[oldIndex];
items.RemoveAt(oldIndex);
if (newIndex < items.Count)
{ {
items.Insert(newIndex, itemToMove); return;
}
else
{
items.Add(itemToMove);
} }
StateHasChanged(); _availableEvents.RemoveAll(e => e.Id == item.Id);
_rankedEvents.Add(item);
} }
private void RemoveRanked(EventDefinition item)
{
_rankedEvents.RemoveAll(e => e.Id == item.Id);
if (_availableEvents.All(e => e.Id != item.Id))
{
_availableEvents.Add(item);
_availableEvents.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
}
}
private void MoveRanked(EventDefinition item, int offset)
{
var index = _rankedEvents.FindIndex(e => e.Id == item.Id);
var newIndex = index + offset;
if (index < 0 || newIndex < 0 || newIndex >= _rankedEvents.Count)
{
return;
}
_rankedEvents.RemoveAt(index);
_rankedEvents.Insert(newIndex, item);
}
async Task Save() async Task Save()
{ {
@@ -172,10 +210,12 @@ else
try try
{ {
var uniqueRanked = _rankedEvents.DistinctBy(e => e.Id).Take(StudentEventRanking.MaxRank).ToList();
_student.EventRankings.Clear(); _student.EventRankings.Clear();
for (var index = 0; index < _rankedEvents.Count; index++) for (var index = 0; index < uniqueRanked.Count; index++)
{ {
var evt = _rankedEvents[index]; var evt = uniqueRanked[index];
_student.EventRankings.Add(new StudentEventRanking _student.EventRankings.Add(new StudentEventRanking
{ {
EventDefinition = evt, EventDefinition = evt,
@@ -0,0 +1,345 @@
@page "/students/event-ranking/import"
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Models
@using Core.Services
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@inject IStudentEventRankingImportService ImportService
@inject IStudentEventRankingSaveService SaveService
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject ILogger<EventRankingImport> Logger
@rendermode InteractiveServer
<PageHeader
Title="Import Event Rankings"
Description="Upload a CSV of student event preferences, preview matches, then save."
Icon="@AppIcons.EventRank"
ShowBackButton="true"
BackButtonUrl="/students/event-ranking" />
<MudGrid>
<MudItem xs="12" md="5">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
<MudStack Spacing="3">
<MudText Typo="Typo.body2">
Required column: <code>Student Name</code>. Rank columns are <code>1</code> through <code>10</code>.
Names can be <code>Last, First</code> or <code>First Last</code>. Event cells can be a full name, short name, or a close match.
</MudText>
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
@if (!string.IsNullOrEmpty(_fileName))
{
<MudText Typo="Typo.caption">@_fileName</MudText>
}
<MudStack Row="true" Spacing="2">
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Article"
OnClick="HandleParse"
Disabled="@(_isParsing || _fileBytes is null)">
Parse
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClear"
Disabled="@_isParsing">
Clear
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
@if (_isParsing)
{
<MudProgressLinear Indeterminate="true" Class="mb-4" />
<MudText>Parsing...</MudText>
}
else if (_parseResult == null)
{
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
}
else
{
<MudStack Spacing="3">
@foreach (var error in _parseResult.Errors)
{
<MudAlert Severity="Severity.Error" Dense="true">@error</MudAlert>
}
@foreach (var warning in _parseResult.Warnings)
{
<MudAlert Severity="Severity.Warning" Dense="true">@warning</MudAlert>
}
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{
<MudAlert Severity="Severity.Success" Dense="true">
Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s)
</MudAlert>
}
@if (_parseResult.Issues.Count > 0)
{
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@($"Issues ({_parseResult.Issues.Count})")"
Icon="@Icons.Material.Filled.Warning">
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Row</MudTh>
<MudTh>Rank</MudTh>
<MudTh>Type</MudTh>
<MudTh>Student</MudTh>
<MudTh>Event text</MudTh>
<MudTh>Message</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Row">@context.RowNumber</MudTd>
<MudTd DataLabel="Rank">@(context.Rank > 0 ? context.Rank.ToString() : "-")</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
@context.IssueType
</MudChip>
</MudTd>
<MudTd DataLabel="Student">@context.RawStudentName</MudTd>
<MudTd DataLabel="Event text">@context.RawEventName</MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd>
</RowTemplate>
</MudTable>
</MudExpansionPanel>
</MudExpansionPanels>
}
@if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0)
{
<MudText Typo="Typo.h6">Matched rankings</MudText>
<MudTable Items="@_parseResult.Matches" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Student</MudTh>
<MudTh>Rank</MudTh>
<MudTh>CSV text</MudTh>
<MudTh>Matched event</MudTh>
<MudTh>Score</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Student">@context.Ranking.Student.FirstNameLastName</MudTd>
<MudTd DataLabel="Rank">@context.Ranking.Rank</MudTd>
<MudTd DataLabel="CSV text">@context.RawEventName</MudTd>
<MudTd DataLabel="Matched event">@context.Ranking.EventDefinition.Name</MudTd>
<MudTd DataLabel="Score">@context.EventScore</MudTd>
</RowTemplate>
</MudTable>
<MudStack Row="true" Spacing="2" Class="mt-2">
<MudButton
Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleSave"
Disabled="@_isSaving">
Save to Database
</MudButton>
<MudButton
Variant="Variant.Text"
OnClick="HandleClearResults">
Clear Results
</MudButton>
</MudStack>
}
</MudStack>
}
</MudPaper>
</MudItem>
</MudGrid>
@code {
private byte[]? _fileBytes;
private string? _fileName;
private StudentEventRankingParseResult? _parseResult;
private bool _isParsing;
private bool _isSaving;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task HandleFileChanged(InputFileChangeEventArgs args)
{
if (_isDisposed)
return;
try
{
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
await using var memory = new MemoryStream();
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
_fileBytes = memory.ToArray();
_fileName = args.File.Name;
_parseResult = null;
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error reading ranking CSV");
if (!_isDisposed)
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
}
}
private async Task HandleParse()
{
if (_fileBytes is null)
{
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
return;
}
_isParsing = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var students = await Context.Students
.AsNoTracking()
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(token);
var events = await Context.Events
.AsNoTracking()
.OrderBy(e => e.Name)
.ToListAsync(token);
await using var stream = new MemoryStream(_fileBytes, writable: false);
_parseResult = ImportService.Parse(stream, students, events);
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error parsing ranking CSV");
if (!_isDisposed)
{
Snackbar.Add($"Error parsing CSV: {ex.Message}", Severity.Error);
_parseResult = new StudentEventRankingParseResult
{
Errors = { $"Error: {ex.Message}" }
};
}
}
finally
{
_isParsing = false;
}
}
private void HandleClear()
{
_fileBytes = null;
_fileName = null;
_parseResult = null;
}
private void HandleClearResults()
{
_parseResult = null;
}
private async Task HandleSave()
{
if (_parseResult is null || !_parseResult.IsSuccess || _parseResult.TotalParsed == 0)
{
Snackbar.Add("No valid rankings to save", Severity.Warning);
return;
}
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
var existing = await SaveService.GetStudentsWithExistingRankingsAsync(_parseResult, token);
if (existing.Count > 0)
{
var preview = string.Join(", ", existing.Take(8));
var remaining = existing.Count > 8 ? $" and {existing.Count - 8} more" : string.Empty;
var confirmed = await DialogService.ShowMessageBox(
"Replace existing rankings?",
$"This will replace current rankings for {existing.Count} student(s): {preview}{remaining}. Continue?",
yesText: "Replace rankings",
cancelText: "Cancel");
if (confirmed != true || _isDisposed)
return;
}
_isSaving = true;
var saveResult = await SaveService.SaveAsync(_parseResult, token);
if (_isDisposed)
return;
Snackbar.Add(
$"Saved {saveResult.RankingsSaved} ranking(s) for {saveResult.StudentsUpdated} student(s)",
Severity.Success);
NavigationManager.NavigateTo("/students/event-ranking");
}
catch (TaskCanceledException)
{
}
catch (JSDisconnectedException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error saving imported rankings");
if (!_isDisposed)
Snackbar.Add($"Error saving rankings: {ex.Message}", Severity.Error);
}
finally
{
_isSaving = false;
}
}
private static Color GetIssueTypeColor(StudentEventRankingIssueType issueType) =>
issueType switch
{
StudentEventRankingIssueType.UnmatchedStudent => Color.Error,
StudentEventRankingIssueType.UnmatchedEvent => Color.Warning,
StudentEventRankingIssueType.AmbiguousEvent => Color.Warning,
StudentEventRankingIssueType.DuplicateEvent => Color.Info,
StudentEventRankingIssueType.DuplicateRank => Color.Info,
StudentEventRankingIssueType.InvalidFormat => Color.Error,
_ => Color.Default
};
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -9,7 +9,7 @@
<PageHeader <PageHeader
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")" Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="Yearly theme: Unity Through Community" /> Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
@if (_teams == null || _students == null) @if (_teams == null || _students == null)
{ {
@@ -74,7 +74,7 @@ else
{ {
<MudItem xs="3"> <MudItem xs="3">
<MudText Class="d-flex py-1"> <MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i> <i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
</MudText> </MudText>
</MudItem> </MudItem>
<MudItem xs="8"> <MudItem xs="8">
@@ -19,6 +19,9 @@
<MudTooltip Text="Handout"> <MudTooltip Text="Handout">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton> <MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</MudTooltip> </MudTooltip>
<MudTooltip Text="State schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
</MudTooltip>
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")"> <MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt" <MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)" Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
@@ -9,7 +9,7 @@
<PageHeader <PageHeader
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")" Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="Yearly theme: Unity Through Community" /> Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
<Legend></Legend> <Legend></Legend>
@if (_teams == null) @if (_teams == null)
@@ -41,9 +41,8 @@ else
@{ @{
var students var students
= context.Students = context.Students
.OrderByDescending(s => s == context.Captain) .OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue) .ThenByDescending(e => e.Grade + e.TsaYear)
.ThenByDescending(e => e.Grade)
.ThenBy(e => e.FirstName) .ThenBy(e => e.FirstName)
.ToArray(); .ToArray();
} }
@@ -233,6 +232,7 @@ else
.AsNoTracking() .AsNoTracking()
.Include(e => e.Event) .Include(e => e.Event)
.Include(e => e.Students) .Include(e => e.Students)
.Include(e => e.Captain)
.OrderByEventFormatFirst() .OrderByEventFormatFirst()
.ThenBy(e => e.Event.Name) .ThenBy(e => e.Event.Name)
.ThenBy(e => e.Identifier ?? "") .ThenBy(e => e.Identifier ?? "")
+9 -46
View File
@@ -3,10 +3,10 @@
@using WebApp.Authentication @using WebApp.Authentication
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using System.Text.Json @using WebApp.Services
@using Core.Models @using Core.Models
@inject IWebHostEnvironment Environment
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter
@rendermode InteractiveServer @rendermode InteractiveServer
@@ -44,6 +44,12 @@
MaxLength="4" MaxLength="4"
Required="true" /> Required="true" />
</MudItem> </MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_settings.YearlyTheme"
Label="Yearly Theme"
Variant="Variant.Outlined"
HelperText="National TSA yearly theme (e.g., Unity Through Community)" />
</MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel" <MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel"
Label="School Level" Label="School Level"
@@ -125,19 +131,10 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
// Load from IConfiguration
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>() _settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings(); ?? new Models.ChapterSettings();
} }
private string GetAppSettingsPath()
{
return Path.Combine(
Environment.ContentRootPath,
"Data",
"appsettings.json");
}
private async Task SaveSettings() private async Task SaveSettings()
{ {
if (_settings == null) return; if (_settings == null) return;
@@ -147,41 +144,7 @@
try try
{ {
var appSettingsPath = GetAppSettingsPath(); await ChapterSettingsWriter.WriteAsync(_settings);
// Ensure Data directory exists
var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
// Read existing appsettings or create new
JsonDocument? existingDoc = null;
Dictionary<string, object?> settings;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath);
existingDoc = JsonDocument.Parse(existingJson);
settings = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? new Dictionary<string, object?>();
}
else
{
settings = new Dictionary<string, object?>();
}
// Update ChapterSettings section
settings["ChapterSettings"] = _settings;
// Write back to file
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(settings, options);
await File.WriteAllTextAsync(appSettingsPath, json);
existingDoc?.Dispose();
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; _statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_statusSeverity = Severity.Success; _statusSeverity = Severity.Success;
} }
+651
View File
@@ -0,0 +1,651 @@
@page "/settings/new-year"
@attribute [Authorize(Roles = AuthRoles.Administrator)]
@implements IAsyncDisposable
@using Core.Entities
@using Core.Models
@using Core.YearTransition
@using Data
@using Microsoft.EntityFrameworkCore
@using WebApp.Authentication
@using WebApp.Components.Shared.Components
@using WebApp.Services
@inject AppDbContext Context
@inject IConfiguration Configuration
@inject IYearRolloverService YearRolloverService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<YearRollover> Logger
@rendermode InteractiveServer
<PageHeader
Title="New Year Rollover"
Description="Promote returning students, set officers, and clear last season's data after an automatic database backup."
ShowBackButton="true"
BackButtonUrl="/settings/chapter" />
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 mb-8">
@if (_result != null)
{
<MudAlert Severity="Severity.Success" Class="mb-4" Variant="Variant.Filled">
Rollover to @_result.CompetitionYear completed successfully.
</MudAlert>
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Summary</MudText>
<MudText>Backup: <code>@_result.BackupPath</code></MudText>
<MudText>Students promoted: @_result.StudentsPromoted</MudText>
<MudText>Students removed: @_result.StudentsRemoved</MudText>
<MudText>Teams deleted: @_result.TeamsDeleted</MudText>
<MudText>Event rankings deleted: @_result.RankingsDeleted</MudText>
<MudText>Meeting histories deleted: @_result.MeetingHistoriesDeleted</MudText>
<MudText>Event occurrences deleted: @_result.EventOccurrencesDeleted</MudText>
<MudText Typo="Typo.subtitle1" Class="mt-3 mb-1">Officers</MudText>
<MudList T="string" Dense="true">
@foreach (var line in _result.OfficerSummary)
{
<MudListItem T="string" Icon="@Icons.Material.Filled.Badge">@line</MudListItem>
}
</MudList>
<MudAlert Severity="Severity.Info" Class="mt-4" Dense="true">
Restart the application so printouts and the home page show the new competition year.
Then add new students, import the new state schedule, and clear Meeting Schedule browser state with Reset.
</MudAlert>
</MudPaper>
}
else if (_students == null)
{
<MudProgressCircular Indeterminate="true" />
}
else if (!_wizardUnlocked)
{
<MudPaper Class="pa-6 mb-4">
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4" Icon="@Icons.Material.Filled.Lock">
This wizard permanently changes production chapter data. It is locked until you intentionally unlock it.
</MudAlert>
<MudText Typo="Typo.h5" Class="mb-3">Before you continue</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
Unlocking lets you plan a rollover. Applying it will still require a second typed confirmation.
An automatic database backup is created immediately before apply and is the only undo.
</MudText>
<MudList T="string" Dense="true" Class="mb-4">
<MudListItem T="string" Icon="@Icons.Material.Filled.DeleteForever" IconColor="Color.Error">
Non-returning students are permanently deleted
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Groups" IconColor="Color.Error">
All teams, event rankings, and meeting history are cleared
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Event" IconColor="Color.Warning">
Event occurrences are cleared by default (state schedule)
</MudListItem>
</MudList>
<MudCheckBox @bind-Value="_ackDestructive" Color="Color.Error" Class="mb-2"
Label="I understand this permanently deletes students and season data" />
<MudCheckBox @bind-Value="_ackBackupOnlyUndo" Color="Color.Error" Class="mb-4"
Label="I understand the automatic backup is the only undo" />
<MudTextField @bind-Value="_unlockPhrase"
Label="@($"Type {UnlockPhrase} to unlock")"
Variant="Variant.Outlined"
HelperText="@($"Confirmation is case-insensitive. Type exactly: {UnlockPhrase}")"
Class="mb-4"
Style="max-width: 320px;"
Immediate="true" />
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.LockOpen"
Disabled="!CanUnlockWizard"
OnClick="UnlockWizard">
Unlock wizard
</MudButton>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.LockOpen">
Wizard unlocked for this session. Close or refresh this page to lock it again.
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Default" Class="ml-2" OnClick="LockWizard">
Lock again
</MudButton>
</MudAlert>
<MudStepper @bind-ActiveIndex="Step" Class="mb-4">
<MudStep Title="Year">Year &amp; grades</MudStep>
<MudStep Title="Roster">Returning roster</MudStep>
<MudStep Title="Officers">Officers</MudStep>
<MudStep Title="Reset">Season reset</MudStep>
<MudStep Title="Apply">Preview &amp; apply</MudStep>
</MudStepper>
@if (Step == 0)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Competition year</MudText>
<MudTextField @bind-Value="_targetYear"
Label="Target competition year"
Variant="Variant.Outlined"
HelperText="Defaults to current year + 1"
Class="mb-4"
Style="max-width: 200px;" />
<MudText Typo="Typo.h5" Class="mb-2">Chapter type</MudText>
@if (_configuredSchoolLevel is { } configured)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
@GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value)
<MudLink Href="/settings/chapter" Class="ml-2">Change in Chapter Settings</MudLink>
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
School level is not set in Chapter Settings (Both MS and HS). Choose the chapter type for this rollover,
then set it permanently on the <MudLink Href="/settings/chapter">Chapter Settings</MudLink> page.
</MudAlert>
<MudSelect T="SchoolLevel?" Value="_overrideSchoolLevel" Label="Chapter type for this rollover"
Variant="Variant.Outlined" Class="mb-3" Style="max-width: 320px;"
ValueChanged="OnOverrideSchoolLevelChanged">
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.MiddleSchool">Middle School (graduate after grade 8)</MudSelectItem>
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.HighSchool">High School (graduate after grade 12)</MudSelectItem>
</MudSelect>
}
<MudAlert Severity="Severity.Normal" Dense="true" Class="mt-2">
Applying the rollover will create an automatic backup at
<code>Data/backups/pre-rollover-*.db</code> before making any changes.
That backup is the only undo.
</MudAlert>
</MudPaper>
}
else if (Step == 1)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Returning students</MudText>
<MudText Typo="Typo.body2" Class="mb-3 mud-text-secondary">
Students at or above graduating grade @_graduatingGrade are unchecked by default.
Paste a list of names (one per line) to check matches.
</MudText>
<MudTextField @bind-Value="_pasteBox"
Label="Paste returning names (optional)"
Variant="Variant.Outlined"
Lines="4"
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Size="Size.Small" Class="mb-4" OnClick="ApplyPastedNames"
StartIcon="@Icons.Material.Filled.ContentPaste">
Apply pasted names
</MudButton>
@if (_pasteUnmatched.Count > 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
Unmatched: @string.Join("; ", _pasteUnmatched)
</MudAlert>
}
@if (_pasteAmbiguous.Count > 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous)
</MudAlert>
}
<MudTable Items="_students" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Returning</MudTh>
<MudTh>Name</MudTh>
<MudTh>Grade</MudTh>
<MudTh>TSA Year</MudTh>
<MudTh>Officer</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudCheckBox T="bool" Value="_returningIds.Contains(context.Id)"
ValueChanged="(bool v) => SetReturning(context.Id, v)"
Dense="true" Color="Color.Primary" />
</MudTd>
<MudTd>@context.LastNameFirstName</MudTd>
<MudTd>@context.Grade</MudTd>
<MudTd>@context.TsaYear</MudTd>
<MudTd>@(context.OfficerRole?.ToString() ?? "—")</MudTd>
</RowTemplate>
</MudTable>
<MudText Typo="Typo.caption" Class="mt-2">
@_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed
</MudText>
</MudPaper>
}
else if (Step == 2)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">New officer slate</MudText>
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
Leave a role blank to leave that office vacant. Only returning students are listed.
New students who will be officers can be assigned later on the student edit page.
</MudText>
<MudGrid>
@foreach (var role in _officerRoles)
{
<MudItem xs="12" md="6">
<MudSelect T="int?" Value="GetOfficerSelection(role)"
ValueChanged="(int? id) => SetOfficerSelection(role, id)"
Label="@role.ToString()"
Variant="Variant.Outlined"
Clearable="true">
@foreach (var student in ReturningStudents)
{
<MudSelectItem T="int?" Value="@student.Id">@student.LastNameFirstName</MudSelectItem>
}
</MudSelect>
</MudItem>
}
</MudGrid>
</MudPaper>
}
else if (Step == 3)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Season reset</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
The following are always cleared: all teams, all event rankings, and all meeting history records.
Written notes on the Notes page are not affected.
</MudAlert>
<MudCheckBox @bind-Value="_clearEventOccurrences" Color="Color.Primary" Label="Clear all event occurrences (state schedule)" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward.
</MudText>
</MudPaper>
}
else if (Step == 4)
{
var plan = BuildPlan();
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Preview</MudText>
<MudText>Competition year → <strong>@plan.TargetCompetitionYear</strong></MudText>
<MudText>Promote <strong>@plan.ReturningCount</strong> students · Remove <strong>@plan.RemovalCount</strong> students</MudText>
<MudText>Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" )</MudText>
@if (plan.Warnings.Count > 0)
{
<MudAlert Severity="Severity.Warning" Class="mt-3 mb-2">
<MudText Typo="Typo.subtitle2">Warnings</MudText>
<ul class="mb-0">
@foreach (var warning in plan.Warnings)
{
<li>@warning</li>
}
</ul>
</MudAlert>
}
<MudExpansionPanels Class="mt-3 mb-3">
<MudExpansionPanel Text="@($"Promotions ({plan.ReturningCount})")">
<MudTable Items="plan.Promotions" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Grade</MudTh>
<MudTh>TSA Year</MudTh>
<MudTh>Officer</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Student.LastNameFirstName</MudTd>
<MudTd>@context.PreviousGrade → @context.NewGrade</MudTd>
<MudTd>@context.PreviousTsaYear → @context.NewTsaYear</MudTd>
<MudTd>@(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—")</MudTd>
</RowTemplate>
</MudTable>
</MudExpansionPanel>
<MudExpansionPanel Text="@($"Removals ({plan.RemovalCount})")">
<MudList T="string" Dense="true">
@foreach (var student in plan.StudentsToRemove)
{
<MudListItem T="string">@student.LastNameFirstName (grade @student.Grade)</MudListItem>
}
</MudList>
</MudExpansionPanel>
<MudExpansionPanel Text="Officers">
<MudList T="string" Dense="true">
@foreach (var change in plan.OfficerChanges)
{
<MudListItem T="string">
@change.Role:
@(change.NewOfficer?.LastNameFirstName ?? "(vacant)")
@if (change.PreviousOfficer != null)
{
<span class="mud-text-secondary"> (was @change.PreviousOfficer.LastNameFirstName)</span>
}
</MudListItem>
}
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
<MudTextField @bind-Value="_applyConfirmYear"
Label="@($"Type {_targetYear.Trim()} to enable Apply")"
Variant="Variant.Outlined"
HelperText="Must exactly match the target competition year above"
Class="mb-4"
Style="max-width: 280px;"
Immediate="true" />
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Warning"
Disabled="_isApplying || !CanApply"
OnClick="ConfirmAndApply">
@if (_isApplying)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Applying...</span>
}
else
{
<span>Apply rollover</span>
}
</MudButton>
</MudPaper>
}
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mt-2">
<MudButton Variant="Variant.Text"
Disabled="Step == 0 || _isApplying"
OnClick="() => Step--">
Back
</MudButton>
@if (Step < 4)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Disabled="!CanGoNext"
OnClick="GoNext">
Next
</MudButton>
}
</MudStack>
}
</MudContainer>
@code {
private const string UnlockPhrase = "ROLLOVER";
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed;
private bool _wizardUnlocked;
private bool _ackDestructive;
private bool _ackBackupOnlyUndo;
private string _unlockPhrase = "";
private string _applyConfirmYear = "";
private int _step;
private int Step
{
get => _step;
set
{
_step = value;
if (_step >= 1)
EnsureReturningDefaults();
if (_step >= 2)
PruneOfficerSelections();
}
}
private List<Student>? _students;
private HashSet<int> _returningIds = [];
private Dictionary<OfficerRole, int?> _officerSelections = [];
private readonly OfficerRole[] _officerRoles = Enum.GetValues<OfficerRole>();
private string _targetYear = "2027";
private SchoolLevel? _configuredSchoolLevel;
private SchoolLevel? _overrideSchoolLevel;
private int? _graduatingGrade;
private string _pasteBox = "";
private List<string> _pasteUnmatched = [];
private List<string> _pasteAmbiguous = [];
private bool _clearEventOccurrences = true;
private bool _isApplying;
private YearRolloverResult? _result;
private bool _returningInitialized;
private IEnumerable<Student> ReturningStudents =>
_students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName)
?? Enumerable.Empty<Student>();
private bool CanUnlockWizard =>
_ackDestructive &&
_ackBackupOnlyUndo &&
string.Equals(_unlockPhrase.Trim(), UnlockPhrase, StringComparison.OrdinalIgnoreCase);
private bool CanApply =>
_wizardUnlocked &&
_graduatingGrade.HasValue &&
!string.IsNullOrWhiteSpace(_targetYear) &&
string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal);
private bool CanGoNext => Step switch
{
0 => _graduatingGrade.HasValue && !string.IsNullOrWhiteSpace(_targetYear),
_ => true
};
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
var currentYear = Configuration["ChapterSettings:CompetitionYear"] ?? "2026";
if (int.TryParse(currentYear, out var year))
_targetYear = (year + 1).ToString();
else
_targetYear = currentYear;
_configuredSchoolLevel = Configuration.GetSection("ChapterSettings").Get<WebApp.Models.ChapterSettings>()?.SchoolLevel
?? ParseSchoolLevel(Configuration["ChapterSettings:SchoolLevel"]);
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(_configuredSchoolLevel);
}
protected override async Task OnInitializedAsync()
{
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
_students = await Context.Students
.AsNoTracking()
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(token);
foreach (var role in _officerRoles)
_officerSelections[role] = null;
}
catch (TaskCanceledException)
{
// disposed
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load students for year rollover");
if (!_isDisposed)
Snackbar.Add($"Failed to load students: {ex.Message}", Severity.Error);
}
}
private static SchoolLevel? ParseSchoolLevel(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return Enum.TryParse<SchoolLevel>(value, ignoreCase: true, out var parsed) ? parsed : null;
}
private void UnlockWizard()
{
if (!CanUnlockWizard)
return;
_wizardUnlocked = true;
Step = 0;
Snackbar.Add("Year rollover wizard unlocked for this session", Severity.Warning);
}
private void LockWizard()
{
_wizardUnlocked = false;
_ackDestructive = false;
_ackBackupOnlyUndo = false;
_unlockPhrase = "";
_applyConfirmYear = "";
Step = 0;
}
private void OnOverrideSchoolLevelChanged(SchoolLevel? value)
{
_overrideSchoolLevel = value;
_graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(value);
_returningInitialized = false;
}
private void EnsureReturningDefaults()
{
if (_returningInitialized || _students == null || !_graduatingGrade.HasValue)
return;
_returningIds = _students
.Where(s => YearTransitionPlanner.SuggestReturning(s, _graduatingGrade.Value))
.Select(s => s.Id)
.ToHashSet();
_returningInitialized = true;
}
private void GoNext()
{
Step++;
}
private void SetReturning(int studentId, bool returning)
{
if (returning)
_returningIds.Add(studentId);
else
{
_returningIds.Remove(studentId);
PruneOfficerSelections();
}
}
private void ApplyPastedNames()
{
if (_students == null)
return;
var lines = _pasteBox.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = YearTransitionPlanner.MatchPastedNames(_students, lines);
foreach (var id in result.MatchedStudentIds)
_returningIds.Add(id);
_pasteUnmatched = result.UnmatchedNames.ToList();
_pasteAmbiguous = result.AmbiguousNames.ToList();
}
private int? GetOfficerSelection(OfficerRole role) =>
_officerSelections.TryGetValue(role, out var id) ? id : null;
private void SetOfficerSelection(OfficerRole role, int? studentId)
{
_officerSelections[role] = studentId;
}
private void PruneOfficerSelections()
{
foreach (var role in _officerRoles)
{
if (_officerSelections.TryGetValue(role, out var id) &&
id.HasValue &&
!_returningIds.Contains(id.Value))
{
_officerSelections[role] = null;
}
}
}
private YearTransitionPlan BuildPlan()
{
return YearTransitionPlanner.Build(new YearTransitionRequest
{
Students = _students ?? [],
ReturningStudentIds = _returningIds,
OfficerAssignments = _officerSelections,
GraduatingGrade = _graduatingGrade ?? 8,
TargetCompetitionYear = _targetYear.Trim(),
PastedNames = []
});
}
private async Task ConfirmAndApply()
{
if (_isDisposed || !CanApply || !_wizardUnlocked)
return;
if (!string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal))
{
Snackbar.Add("Type the target competition year exactly to confirm.", Severity.Warning);
return;
}
var plan = BuildPlan();
var message =
$"This will permanently delete {plan.RemovalCount} student(s), all teams, all event rankings, " +
$"all meeting history{(_clearEventOccurrences ? ", and all event occurrences" : "")}. " +
$"An automatic database backup will be created first and is the only undo. Continue?";
var confirmed = await DialogService.ShowMessageBox(
"Confirm year rollover",
message,
yesText: "Yes, apply rollover",
cancelText: "Cancel");
if (confirmed != true || _isDisposed || !_wizardUnlocked)
return;
_isApplying = true;
try
{
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
_result = await YearRolloverService.ApplyAsync(new YearRolloverOptions
{
Plan = plan,
ClearEventOccurrences = _clearEventOccurrences
}, token);
if (!_isDisposed)
Snackbar.Add($"Rollover to {_result.CompetitionYear} complete", Severity.Success);
}
catch (TaskCanceledException)
{
// disposed
}
catch (JSDisconnectedException)
{
// connection lost
}
catch (Exception ex)
{
Logger.LogError(ex, "Year rollover apply failed");
if (!_isDisposed)
Snackbar.Add($"Rollover failed: {ex.Message}", Severity.Error);
}
finally
{
_isApplying = false;
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -35,6 +35,7 @@
<AuthorizeView Roles="Administrator"> <AuthorizeView Roles="Administrator">
<MudDivider Class="my-2"/> <MudDivider Class="my-2"/>
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink> <MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
<MudNavLink Href="/settings/new-year" Icon="@Icons.Material.Filled.EventRepeat">New Year Rollover (locked)</MudNavLink>
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink> <MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
</AuthorizeView> </AuthorizeView>
</MudNavMenu> </MudNavMenu>
+10
View File
@@ -37,6 +37,16 @@ public class ChapterSettings
/// </summary> /// </summary>
public string CompetitionYear { get; set; } = "2026"; public string CompetitionYear { get; set; } = "2026";
/// <summary>
/// National TSA yearly theme (e.g., "Unity Through Community")
/// </summary>
public string YearlyTheme { get; set; } = "Unity Through Community";
/// <summary>
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
/// </summary>
public string StateAbbrev { get; set; } = "ST";
/// <summary> /// <summary>
/// School level for the chapter (null = import both MS and HS events) /// School level for the chapter (null = import both MS and HS events)
/// </summary> /// </summary>
-16
View File
@@ -1,16 +0,0 @@
using BlazorSortableList;
using Core.Entities;
namespace WebApp.Models;
/// <summary>
/// Class SharedSortableListGroup.
/// Used for BlazorSortableList
/// </summary>
internal class SharedSortableListGroup : MultiSortableListGroup<EventDefinition>
{
public SharedSortableListGroup(Action refreshComponent)
: base(refreshComponent)
{
}
}
@@ -0,0 +1,33 @@
namespace WebApp.Models;
/// <summary>
/// Per-student handout filters; section <see cref="SectionName"/>. Edit in Data/appsettings.json, save, refresh the page (no redeploy).
/// </summary>
public class StateScheduleHandoutOptions
{
public const string SectionName = "ChapterSettings:StateScheduleHandout";
/// <summary>
/// <see cref="Core.Entities.EventOccurrence.SpecialEventType"/> values allowed on student pages.
/// Gated in code (omit here): VotingDelegateMeeting, MeetTheCandidates, ChapterOfficerMeeting.
/// </summary>
public string[] StudentSpecialEventTypes { get; set; } =
[
"GeneralSchedule",
"SocialGathering"
];
/// <summary>
/// Occurrence <see cref="Core.Entities.EventOccurrence.Name"/> substrings that exclude a row from student pages (case-insensitive).
/// Master schedule still lists all occurrences.
/// </summary>
public string[] StudentExcludeOccurrenceNameSubstrings { get; set; } =
[
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
];
}
+17 -9
View File
@@ -11,6 +11,7 @@ using WebApp;
using WebApp.Authentication; using WebApp.Authentication;
using WebApp.Components; using WebApp.Components;
using WebApp.Logging; using WebApp.Logging;
using WebApp.Models;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger)); .WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
}); });
// Configure authentication secrets for production (Docker, etc.) // Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(authSecretsPath))
{
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
}
if (builder.Environment.IsProduction()) if (builder.Environment.IsProduction())
{ {
// Option 1: Load from volume-mounted secrets file in Data directory
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(secretsPath))
{
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
}
// Option 2: Environment variables with prefix
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_"); builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
} }
@@ -201,6 +201,14 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.AddScoped<WebApp.Services.IChapterSettingsWriter, WebApp.Services.ChapterSettingsWriter>();
builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Services.DatabaseBackupService>();
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
// State container for maintaining state per user connection (Blazor Server) // State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>(); builder.Services.AddScoped<StateContainer>();
+67
View File
@@ -0,0 +1,67 @@
using System.Text.Json;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Persists chapter settings to <c>Data/appsettings.json</c>.
/// </summary>
public class ChapterSettingsWriter : IChapterSettingsWriter
{
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly ILogger<ChapterSettingsWriter> _logger;
public ChapterSettingsWriter(
IWebHostEnvironment environment,
IConfiguration configuration,
ILogger<ChapterSettingsWriter> logger)
{
_environment = environment;
_configuration = configuration;
_logger = logger;
}
public async Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
var appSettingsPath = GetAppSettingsPath();
var dataDir = Path.GetDirectoryName(appSettingsPath);
if (dataDir != null && !Directory.Exists(dataDir))
{
Directory.CreateDirectory(dataDir);
}
Dictionary<string, object?> root;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? [];
}
else
{
root = [];
}
root["ChapterSettings"] = settings;
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(root, options);
await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken);
_logger.LogInformation("Chapter settings written to {Path}", appSettingsPath);
}
public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default)
{
var settings = _configuration.GetSection("ChapterSettings").Get<ChapterSettings>()
?? new ChapterSettings();
settings.CompetitionYear = competitionYear;
await WriteAsync(settings, cancellationToken);
}
private string GetAppSettingsPath() =>
Path.Combine(_environment.ContentRootPath, "Data", "appsettings.json");
}
+48
View File
@@ -0,0 +1,48 @@
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Creates SQLite database backups via VACUUM INTO.
/// </summary>
public class DatabaseBackupService : IDatabaseBackupService
{
private readonly AppDbContext _context;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<DatabaseBackupService> _logger;
public DatabaseBackupService(
AppDbContext context,
IWebHostEnvironment environment,
ILogger<DatabaseBackupService> logger)
{
_context = context;
_environment = environment;
_logger = logger;
}
public async Task<string> CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default)
{
var backupsDir = Path.Combine(_environment.ContentRootPath, "Data", "backups");
Directory.CreateDirectory(backupsDir);
var fileName = $"pre-rollover-{DateTime.Now:yyyyMMdd-HHmmss}.db";
var backupPath = Path.Combine(backupsDir, fileName);
// Path is server-generated (never user input); escape single quotes for SQLite string literal.
var escapedPath = backupPath.Replace("'", "''", StringComparison.Ordinal);
#pragma warning disable EF1002 // Path is fully server-controlled; VACUUM INTO cannot use parameters.
await _context.Database.ExecuteSqlRawAsync($"VACUUM INTO '{escapedPath}'", cancellationToken);
#pragma warning restore EF1002
if (!File.Exists(backupPath))
{
throw new InvalidOperationException(
$"Database backup was requested but the file was not created at '{backupPath}'.");
}
_logger.LogInformation("Created pre-rollover database backup at {BackupPath}", backupPath);
return backupPath;
}
}
+3 -2
View File
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
} }
var teams = await _context.Teams var teams = await _context.Teams
.Include(t => t.Event)
.Include(t => t.Students) .Include(t => t.Students)
.Include(t => t.Captain) .Include(t => t.Captain)
.Where(t => ids.Contains(t.Event.Id)) .Where(t => t.Event != null && ids.Contains(t.Event.Id))
.ToListAsync(); .ToListAsync();
return teams return teams
.GroupBy(t => t.Event.Id) .GroupBy(t => t.Event!.Id)
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
} }
} }
+19
View File
@@ -0,0 +1,19 @@
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Persists chapter settings to <c>Data/appsettings.json</c>.
/// </summary>
public interface IChapterSettingsWriter
{
/// <summary>
/// Writes the given chapter settings, preserving other top-level sections in the file.
/// </summary>
Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default);
/// <summary>
/// Updates only the competition year while preserving other chapter settings from configuration.
/// </summary>
Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default);
}
+13
View File
@@ -0,0 +1,13 @@
namespace WebApp.Services;
/// <summary>
/// Creates SQLite database backups.
/// </summary>
public interface IDatabaseBackupService
{
/// <summary>
/// Creates a pre-rollover backup of the application database using SQLite VACUUM INTO.
/// </summary>
/// <returns>The absolute path of the backup file.</returns>
Task<string> CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,34 @@
using Core.Models;
namespace WebApp.Services;
/// <summary>
/// Persists parsed student event rankings.
/// </summary>
public interface IStudentEventRankingSaveService
{
/// <summary>
/// Students in the parse result who already have rankings stored.
/// </summary>
Task<IReadOnlyList<string>> GetStudentsWithExistingRankingsAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default);
/// <summary>
/// Replaces rankings for students who have at least one accepted rank in the parse result.
/// Other students are left unchanged.
/// </summary>
Task<StudentEventRankingSaveResult> SaveAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Outcome of saving imported event rankings.
/// </summary>
public class StudentEventRankingSaveResult
{
public int StudentsUpdated { get; set; }
public int RankingsSaved { get; set; }
}
+36
View File
@@ -0,0 +1,36 @@
using Core.YearTransition;
namespace WebApp.Services;
/// <summary>
/// Options for applying a year rollover.
/// </summary>
public sealed class YearRolloverOptions
{
public required YearTransitionPlan Plan { get; init; }
public bool ClearEventOccurrences { get; init; } = true;
}
/// <summary>
/// Result of a successful year rollover.
/// </summary>
public sealed class YearRolloverResult
{
public required string BackupPath { get; init; }
public required int StudentsRemoved { get; init; }
public required int StudentsPromoted { get; init; }
public required int TeamsDeleted { get; init; }
public required int RankingsDeleted { get; init; }
public required int MeetingHistoriesDeleted { get; init; }
public required int EventOccurrencesDeleted { get; init; }
public required string CompetitionYear { get; init; }
public required IReadOnlyList<string> OfficerSummary { get; init; }
}
/// <summary>
/// Applies a year-transition plan to the database.
/// </summary>
public interface IYearRolloverService
{
Task<YearRolloverResult> ApplyAsync(YearRolloverOptions options, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,111 @@
using Core.Entities;
using Core.Models;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Replaces event rankings for students present in a successful ranking import.
/// </summary>
public class StudentEventRankingSaveService : IStudentEventRankingSaveService
{
private readonly AppDbContext _context;
private readonly ILogger<StudentEventRankingSaveService> _logger;
public StudentEventRankingSaveService(AppDbContext context, ILogger<StudentEventRankingSaveService> logger)
{
_context = context;
_logger = logger;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetStudentsWithExistingRankingsAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default)
{
var studentIds = GetStudentIds(parseResult);
if (studentIds.Count == 0)
return [];
return await _context.Students
.AsNoTracking()
.Where(s => studentIds.Contains(s.Id) && s.EventRankings.Any())
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.Select(s => s.FirstName + " " + s.LastName)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<StudentEventRankingSaveResult> SaveAsync(
StudentEventRankingParseResult parseResult,
CancellationToken cancellationToken = default)
{
var studentIds = GetStudentIds(parseResult);
if (studentIds.Count == 0)
return new StudentEventRankingSaveResult();
var students = await _context.Students
.Include(s => s.EventRankings)
.Where(s => studentIds.Contains(s.Id))
.ToListAsync(cancellationToken);
var eventIds = parseResult.Matches
.Select(m => m.Ranking.EventDefinition.Id)
.Where(id => id != 0)
.Distinct()
.ToList();
var events = await _context.Events
.Where(e => eventIds.Contains(e.Id))
.ToDictionaryAsync(e => e.Id, cancellationToken);
var matchesByStudent = parseResult.Matches
.Where(m => m.Ranking.Student.Id != 0)
.GroupBy(m => m.Ranking.Student.Id)
.ToDictionary(g => g.Key, g => g.ToList());
var rankingsSaved = 0;
foreach (var student in students)
{
if (!matchesByStudent.TryGetValue(student.Id, out var matches))
continue;
student.EventRankings.Clear();
foreach (var match in matches.OrderBy(m => m.Ranking.Rank))
{
if (!events.TryGetValue(match.Ranking.EventDefinition.Id, out var eventDefinition))
{
_logger.LogWarning(
"Skipping ranking for student {StudentId}: event {EventId} was not found",
student.Id,
match.Ranking.EventDefinition.Id);
continue;
}
student.EventRankings.Add(new StudentEventRanking
{
Student = student,
EventDefinition = eventDefinition,
Rank = match.Ranking.Rank
});
rankingsSaved++;
}
}
await _context.SaveChangesAsync(cancellationToken);
return new StudentEventRankingSaveResult
{
StudentsUpdated = students.Count,
RankingsSaved = rankingsSaved
};
}
private static List<int> GetStudentIds(StudentEventRankingParseResult parseResult) =>
[.. parseResult.Matches
.Select(m => m.Ranking.Student.Id)
.Where(id => id != 0)
.Distinct()];
}
+182
View File
@@ -0,0 +1,182 @@
using Core.YearTransition;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Applies a year-transition plan: backup, wipe season data, promote/remove students, update year.
/// </summary>
public class YearRolloverService : IYearRolloverService
{
private readonly AppDbContext _context;
private readonly IDatabaseBackupService _backupService;
private readonly IChapterSettingsWriter _chapterSettingsWriter;
private readonly ILogger<YearRolloverService> _logger;
public YearRolloverService(
AppDbContext context,
IDatabaseBackupService backupService,
IChapterSettingsWriter chapterSettingsWriter,
ILogger<YearRolloverService> logger)
{
_context = context;
_backupService = backupService;
_chapterSettingsWriter = chapterSettingsWriter;
_logger = logger;
}
public async Task<YearRolloverResult> ApplyAsync(
YearRolloverOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(options.Plan);
var plan = options.Plan;
var returningIds = plan.Promotions.Select(p => p.Student.Id).ToHashSet();
var removalIds = plan.StudentsToRemove.Select(s => s.Id).ToHashSet();
_logger.LogInformation(
"Starting year rollover to {Year}: {Returning} returning, {Removing} removing, clearOccurrences={ClearOccurrences}",
plan.TargetCompetitionYear,
plan.ReturningCount,
plan.RemovalCount,
options.ClearEventOccurrences);
// VACUUM INTO cannot run inside a transaction — backup first and abort if it fails.
var backupPath = await _backupService.CreatePreRolloverBackupAsync(cancellationToken);
int meetingHistoriesDeleted;
int teamsDeleted;
int rankingsDeleted;
int eventOccurrencesDeleted;
int studentsRemoved;
int studentsPromoted;
List<string> officerSummary;
await using (var transaction = await _context.Database.BeginTransactionAsync(cancellationToken))
{
try
{
// Delete season data with ExecuteDelete / SQL so we never leave tracked Team
// entities in the change tracker (Include+Remove then ExecuteDelete caused
// optimistic concurrency failures when later deleting captain students).
meetingHistoriesDeleted = await _context.TeamMeetingHistories.CountAsync(cancellationToken);
await _context.Database.ExecuteSqlRawAsync(
"""DELETE FROM "TeamMeetingHistoryTeams" """, cancellationToken);
await _context.Database.ExecuteSqlRawAsync(
"""DELETE FROM "TeamMeetingHistoryStudents" """, cancellationToken);
await _context.TeamMeetingHistories.ExecuteDeleteAsync(cancellationToken);
teamsDeleted = await _context.Teams.ExecuteDeleteAsync(cancellationToken);
rankingsDeleted = await _context.StudentEventRanking.ExecuteDeleteAsync(cancellationToken);
eventOccurrencesDeleted = 0;
if (options.ClearEventOccurrences)
{
eventOccurrencesDeleted = await _context.EventOccurrences.ExecuteDeleteAsync(cancellationToken);
}
// Drop any stale tracked entities from earlier queries in this request scope.
_context.ChangeTracker.Clear();
var students = await _context.Students.ToListAsync(cancellationToken);
var toRemove = students.Where(s => removalIds.Contains(s.Id)).ToList();
var toPromote = students.Where(s => returningIds.Contains(s.Id)).ToList();
if (toRemove.Count != removalIds.Count)
{
throw new InvalidOperationException(
"Some students marked for removal were not found in the database. Aborting rollover.");
}
if (toPromote.Count != returningIds.Count)
{
throw new InvalidOperationException(
"Some returning students were not found in the database. Aborting rollover.");
}
// Extra students added while the wizard was open — refuse rather than leave them unprocessed.
var plannedIds = returningIds.Union(removalIds).ToHashSet();
var unexpected = students.Where(s => !plannedIds.Contains(s.Id)).ToList();
if (unexpected.Count > 0)
{
var names = string.Join(", ", unexpected.Select(s => s.LastNameFirstName));
throw new InvalidOperationException(
$"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again.");
}
_context.Students.RemoveRange(toRemove);
var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id);
foreach (var student in toPromote)
{
var promotion = promotionById[student.Id];
student.Grade = promotion.NewGrade;
student.TsaYear = promotion.NewTsaYear;
student.OfficerRole = promotion.NewOfficerRole;
}
await _context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
studentsRemoved = toRemove.Count;
studentsPromoted = toPromote.Count;
officerSummary = plan.OfficerChanges
.Select(c => c.NewOfficer == null
? $"{c.Role}: (vacant)"
: $"{c.Role}: {c.NewOfficer.LastNameFirstName}")
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Year rollover failed after backup at {BackupPath}; rolling back database changes", backupPath);
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
try
{
await _chapterSettingsWriter.UpdateCompetitionYearAsync(
plan.TargetCompetitionYear,
cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Year rollover DB changes committed but CompetitionYear file update failed. Backup={BackupPath}",
backupPath);
throw new InvalidOperationException(
$"Database rollover succeeded (backup at '{backupPath}'), but updating CompetitionYear failed: {ex.Message}. Set the year in Chapter Settings, then restart.",
ex);
}
var result = new YearRolloverResult
{
BackupPath = backupPath,
StudentsRemoved = studentsRemoved,
StudentsPromoted = studentsPromoted,
TeamsDeleted = teamsDeleted,
RankingsDeleted = rankingsDeleted,
MeetingHistoriesDeleted = meetingHistoriesDeleted,
EventOccurrencesDeleted = eventOccurrencesDeleted,
CompetitionYear = plan.TargetCompetitionYear,
OfficerSummary = officerSummary
};
_logger.LogInformation(
"Year rollover complete. Backup={BackupPath}, Removed={Removed}, Promoted={Promoted}, Teams={Teams}, Rankings={Rankings}, Histories={Histories}, Occurrences={Occurrences}, Officers={Officers}",
result.BackupPath,
result.StudentsRemoved,
result.StudentsPromoted,
result.TeamsDeleted,
result.RankingsDeleted,
result.MeetingHistoriesDeleted,
result.EventOccurrencesDeleted,
string.Join("; ", officerSummary));
return result;
}
}
@@ -0,0 +1,79 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Utility;
/// <summary>
/// Determines which special <see cref="EventOccurrence"/> rows belong on a per-student handout.
/// </summary>
public static class StateScheduleOccurrenceFilter
{
public static bool NameMatchesStudentExclude(string? name, StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(name)) return false;
foreach (var sub in options.StudentExcludeOccurrenceNameSubstrings ?? [])
{
if (string.IsNullOrWhiteSpace(sub)) continue;
if (name.Contains(sub.Trim(), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// True when the occurrence name refers to Meet the Candidates (covers General Schedule lines duplicated under another type).
/// </summary>
public static bool NameLooksLikeMeetTheCandidates(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Meet the Candidate", StringComparison.OrdinalIgnoreCase);
public static bool NameLooksLikeChapterOfficerMeeting(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Special rows have <see cref="EventOccurrence.EventDefinitionId"/> null and <see cref="EventOccurrence.SpecialEventType"/> set.
/// </summary>
public static bool IncludeSpecialOccurrenceForStudent(
EventOccurrence occurrence,
Student student,
StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(occurrence.SpecialEventType))
return false;
if (NameMatchesStudentExclude(occurrence.Name, options))
return false;
if (occurrence.SpecialEventType == "VotingDelegateMeeting")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "MeetTheCandidates")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "ChapterOfficerMeeting")
return student.OfficerRole.HasValue;
// Same event often appears once as MeetTheCandidates and again under GeneralSchedule; non-delegates should see neither.
if (!student.VotingDelegate && NameLooksLikeMeetTheCandidates(occurrence.Name))
return false;
// Chapter Officer Meeting lines under GeneralSchedule for non-officers
if (!student.OfficerRole.HasValue && NameLooksLikeChapterOfficerMeeting(occurrence.Name))
return false;
var allowed = options.StudentSpecialEventTypes ?? [];
return allowed.Contains(occurrence.SpecialEventType);
}
/// <summary>
/// Competition rows: optional name-based exclusion on student pages.
/// </summary>
public static bool IncludeCompetitionOccurrenceForStudent(EventOccurrence occurrence, StateScheduleHandoutOptions options)
{
if (occurrence.EventDefinitionId == null)
return false;
return !NameMatchesStudentExclude(occurrence.Name, options);
}
}
-1
View File
@@ -13,7 +13,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
<PackageReference Include="Heron.MudCalendar" Version="3.4.0" /> <PackageReference Include="Heron.MudCalendar" Version="3.4.0" />
<PackageReference Include="VisNetwork.Blazor" Version="3.0.0" /> <PackageReference Include="VisNetwork.Blazor" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
+2 -1
View File
@@ -15,6 +15,7 @@
"NationalId": "2227", "NationalId": "2227",
"StateId": "12227", "StateId": "12227",
"RegionalId": "12227", "RegionalId": "12227",
"CompetitionYear": "2026" "CompetitionYear": "2026",
"YearlyTheme": "Unity Through Community"
} }
} }
+17 -1
View File
@@ -16,7 +16,23 @@
"NationalId": "0000", "NationalId": "0000",
"StateId": "00000", "StateId": "00000",
"RegionalId": "00000", "RegionalId": "00000",
"CompetitionYear": "2026" "CompetitionYear": "2026",
"YearlyTheme": "Unity Through Community",
"StateAbbrev": "ST",
"StateScheduleHandout": {
"StudentSpecialEventTypes": [
"GeneralSchedule",
"SocialGathering"
],
"StudentExcludeOccurrenceNameSubstrings": [
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
]
}
}, },
"ValidationSettings": { "ValidationSettings": {
"MinRecommendedEvents": 2, "MinRecommendedEvents": 2,
+70
View File
@@ -48,6 +48,76 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.state-schedule-table {
width: 100%;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top !important;
}
.combined-sub-event {
padding-left: 1.5rem !important;
}
@media print {
.state-schedule-handout {
margin-left: -30pt !important;
margin-right: -12pt !important;
padding-left: 0 !important;
padding-right: 0 !important;
width: calc(100% + 42pt) !important;
max-width: none !important;
}
.state-schedule-handout .mud-paper,
.state-schedule-handout .mud-table-container,
.state-schedule-handout .mud-table {
box-shadow: none !important;
}
.state-schedule-table,
.state-schedule-table table {
width: 100%;
border-collapse: collapse;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top;
padding: 4px 8px;
border-top: none !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
.state-schedule-table thead th,
.state-schedule-table table thead th {
border-bottom: 2px solid #000 !important;
}
.state-schedule-table tbody td,
.state-schedule-table table tbody td {
border-bottom: 1px solid #000 !important;
}
.state-schedule-table tbody tr:last-child td,
.state-schedule-table table tbody tr:last-child td {
border-bottom: none !important;
}
.state-schedule-table .combined-sub-event,
.state-schedule-table table .combined-sub-event {
padding-left: 1.5rem !important;
}
}
.page-header { .page-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
+30
View File
@@ -0,0 +1,30 @@
window.tsaLogin = {
/**
* Copies Blazor-bound credentials into the classic form fields and submits.
* Avoids building JS via string eval (empty returnUrl previously produced ".value = ;").
*/
submitForm: function (email, password, rememberMe, returnUrl) {
var emailInput = document.getElementById('emailInput');
var passwordInput = document.getElementById('passwordInput');
var rememberMeInput = document.getElementById('rememberMeInput');
var returnUrlInput = document.getElementById('returnUrlInput');
var form = document.getElementById('loginForm');
if (!emailInput || !passwordInput || !rememberMeInput || !returnUrlInput || !form) {
console.error('tsaLogin.submitForm: login form elements not found');
return;
}
emailInput.value = email ?? '';
passwordInput.value = password ?? '';
rememberMeInput.value = rememberMe ? 'true' : 'false';
returnUrlInput.value = returnUrl ?? '';
form.submit();
},
blurActiveElement: function () {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
}
};
+3 -2
View File
@@ -26,8 +26,9 @@ services:
# Option 1: Volume-mounted secrets file (recommended for easy editing) # Option 1: Volume-mounted secrets file (recommended for easy editing)
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro - ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
# Database persistence # Database persistence — must use capital D (/app/Data). Linux paths are
- ./data:/app/data # case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
- ./data:/app/Data
# HTTPS certificate (if needed) # HTTPS certificate (if needed)
# - ./certs:/https:ro # - ./certs:/https:ro
+32
View File
@@ -0,0 +1,32 @@
# Import Student Event Rankings
**Created:** 2026-08-28
**Last updated:** 2026-08-28
**Description:** How to import student event preference rankings from a CSV.
## CSV format
```
Student Name,1,2,3,4,5,6
"Last, First",Video Game Design,Coding,Flight
First Last,Video Game,Coding
```
- `Student Name` is required. `Last, First` and `First Last` both work.
- Rank columns are `1` through `10`. Empty cells are skipped. Extra columns are ignored.
- Event cells may be the official name, the catalog short name, or a close miss. Fuzzy matching scores both.
- Known nicknames that fuzzy cannot reach are also accepted, including `Solar Racer`, `Challenging Tech`, `Digital Photo`, `Forensics`, `Micro Controller`, `Med Tech`, `Innovations & Inventions`, `Systems Control Tech`, `Structural Eng`, `Drone Challenge`, `Robotics`, and `Audio Podcast`.
## Steps
1. Sign in and open **Student Event Ranks** (`/students/event-ranking`).
2. Click **Import**.
3. Choose the CSV and click **Parse**.
4. Review matched rows (raw text, chosen event, score) and any unmatched or ambiguous issues.
5. Click **Save to Database**. If any matched student already has rankings, confirm the replace dialog.
Save replaces rankings only for students who have at least one accepted rank in the file. Other students are unchanged.
## What is not imported
Unmatched students, unmatched events, and ambiguous event names are reported in the preview and are not saved. Fix the CSV text or add the student/event in the app, then parse again.
+67
View File
@@ -0,0 +1,67 @@
# Year Rollover Runbook
**Created:** 2026-08-14
**Last updated:** 2026-08-28
**Description:** How to roll the chapter into a new competition year using the locked New Year wizard.
## Prerequisites
1. Confirm **School Level** is set correctly on [Chapter Settings](/settings/chapter) (`Middle School` or `High School`). That drives the graduating grade (8 or 12).
2. Have the list of **returning students** and the **new officer slate** ready.
3. Plan a short maintenance window: after apply you must **restart the app** so printouts pick up the new competition year.
4. On Docker, confirm the data volume is `./data:/app/Data` (capital **D**). See `DEPLOYMENT.md` — otherwise the automatic backup may not land on the host.
## Steps
1. Sign in as an **Administrator**.
2. Open **New Year Rollover** at `/settings/new-year` (Admin nav: “New Year Rollover (locked)”).
3. **Unlock the wizard** (required every session):
- Check both acknowledgment boxes.
- Type `ROLLOVER` and click **Unlock wizard**.
- Refreshing or using **Lock again** re-locks it.
4. **Step 1 — Year & grades**
- Confirm the target competition year (defaults to current + 1).
- Confirm the chapter type / graduating grade shown from Chapter Settings.
5. **Step 2 — Returning roster**
- Uncheck anyone who is not returning (grade at/above graduating is unchecked by default).
- Optionally paste a name list (`Last, First` or `First Last`) and click **Apply pasted names**.
6. **Step 3 — Officers**
- Assign each office from returning students, or leave vacant.
- Officers who are brand-new students can be set later on the student edit page.
7. **Step 4 — Season reset**
- Teams, event rankings, and meeting history are always cleared.
- Leave **Clear all event occurrences** checked unless you have a reason to keep last year's calendar rows.
8. **Step 5 — Preview & apply**
- Review promotions, removals, officers, and warnings.
- Type the target competition year exactly to enable **Apply rollover**.
- Confirm the destructive dialog. The wizard creates `Data/backups/pre-rollover-yyyyMMdd-HHmmss.db` first; that file is the only undo.
9. **Restart the application** so the home page and printouts show the new competition year.
10. **Add new students** via `/students/create` or `/import`.
- `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners.
11. **Import the new state schedule** from the calendar import page.
12. On **Meeting Schedule**, click **Reset** once. That page keeps team/student ids in browser localStorage; after a rollover those ids are stale.
13. Collect new event rankings (CSV import at `/students/event-ranking/import`, or the ranking editor) and run team assignment as usual.
## What is deleted vs kept
| Cleared | Kept |
|---------|------|
| Non-returning students | Returning students (promoted) |
| All teams | Event definitions (national catalog) |
| All event rankings | Notes (including meeting notes by title) |
| All meeting history attendance snapshots | Database backup under `Data/backups/` |
| Event occurrences (when checked) | |
## Backup location
| Environment | Backup path |
|-------------|-------------|
| Local (`dotnet run`) | `WebApp/Data/backups/pre-rollover-*.db` |
| Docker (with `./data:/app/Data`) | Host: `./data/backups/pre-rollover-*.db` (container: `/app/Data/backups/`) |
## Restore from backup (emergency)
1. Stop the application (or `docker-compose stop`).
2. Replace `Data/app.db` (local) or `./data/app.db` (Docker host) with a copy of the `pre-rollover-*.db` file from the backups folder.
3. If needed, restore `CompetitionYear` in `Data/appsettings.json` / `./data/appsettings.json` (or Chapter Settings after restart).
4. Start the application.