@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 Logger @rendermode InteractiveServer @if (_result != null) { Rollover to @_result.CompetitionYear completed successfully. Summary Backup: @_result.BackupPath Students promoted: @_result.StudentsPromoted Students removed: @_result.StudentsRemoved Teams deleted: @_result.TeamsDeleted Event rankings deleted: @_result.RankingsDeleted Meeting histories deleted: @_result.MeetingHistoriesDeleted Event occurrences deleted: @_result.EventOccurrencesDeleted Officers @foreach (var line in _result.OfficerSummary) { @line } 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. } else if (_students == null) { } else if (!_wizardUnlocked) { This wizard permanently changes production chapter data. It is locked until you intentionally unlock it. Before you continue 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. Non-returning students are permanently deleted All teams, event rankings, and meeting history are cleared Event occurrences are cleared by default (state schedule) Unlock wizard } else { Wizard unlocked for this session. Close or refresh this page to lock it again. Lock again Year & grades Returning roster Officers Season reset Preview & apply @if (Step == 0) { Competition year Chapter type @if (_configuredSchoolLevel is { } configured) { @GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value) Change in Chapter Settings } else { 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 Chapter Settings page. Middle School (graduate after grade 8) High School (graduate after grade 12) } Applying the rollover will create an automatic backup at Data/backups/pre-rollover-*.db before making any changes. That backup is the only undo. } else if (Step == 1) { Returning students Students at or above graduating grade @_graduatingGrade are unchecked by default. Paste a list of names (one per line) to check matches. Apply pasted names @if (_pasteUnmatched.Count > 0) { Unmatched: @string.Join("; ", _pasteUnmatched) } @if (_pasteAmbiguous.Count > 0) { Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous) } Returning Name Grade TSA Year Officer @context.LastNameFirstName @context.Grade @context.TsaYear @(context.OfficerRole?.ToString() ?? "—") @_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed } else if (Step == 2) { New officer slate 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. @foreach (var role in _officerRoles) { @foreach (var student in ReturningStudents) { @student.LastNameFirstName } } } else if (Step == 3) { Season reset The following are always cleared: all teams, all event rankings, and all meeting history records. Written notes on the Notes page are not affected. Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward. } else if (Step == 4) { var plan = BuildPlan(); Preview Competition year → @plan.TargetCompetitionYear Promote @plan.ReturningCount students · Remove @plan.RemovalCount students Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" ) @if (plan.Warnings.Count > 0) { Warnings
    @foreach (var warning in plan.Warnings) {
  • @warning
  • }
} Name Grade TSA Year Officer @context.Student.LastNameFirstName @context.PreviousGrade → @context.NewGrade @context.PreviousTsaYear → @context.NewTsaYear @(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—") @foreach (var student in plan.StudentsToRemove) { @student.LastNameFirstName (grade @student.Grade) } @foreach (var change in plan.OfficerChanges) { @change.Role: @(change.NewOfficer?.LastNameFirstName ?? "(vacant)") @if (change.PreviousOfficer != null) { (was @change.PreviousOfficer.LastNameFirstName) } } @if (_isApplying) { Applying... } else { Apply rollover }
} Back @if (Step < 4) { Next } }
@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? _students; private HashSet _returningIds = []; private Dictionary _officerSelections = []; private readonly OfficerRole[] _officerRoles = Enum.GetValues(); private string _targetYear = "2027"; private SchoolLevel? _configuredSchoolLevel; private SchoolLevel? _overrideSchoolLevel; private int? _graduatingGrade; private string _pasteBox = ""; private List _pasteUnmatched = []; private List _pasteAmbiguous = []; private bool _clearEventOccurrences = true; private bool _isApplying; private YearRolloverResult? _result; private bool _returningInitialized; private IEnumerable ReturningStudents => _students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName) ?? Enumerable.Empty(); 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()?.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(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; } }