diff --git a/Core/YearTransition/GraduatingGradeResolver.cs b/Core/YearTransition/GraduatingGradeResolver.cs
new file mode 100644
index 0000000..4710e90
--- /dev/null
+++ b/Core/YearTransition/GraduatingGradeResolver.cs
@@ -0,0 +1,32 @@
+using Core.Models;
+
+namespace Core.YearTransition;
+
+///
+/// Resolves the graduating grade from chapter school level configuration.
+///
+public static class GraduatingGradeResolver
+{
+ ///
+ /// Middle school students graduate after grade 8; high school after grade 12.
+ /// Returns null when school level is unset (both / unspecified).
+ ///
+ public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch
+ {
+ SchoolLevel.MiddleSchool => 8,
+ SchoolLevel.HighSchool => 12,
+ _ => null
+ };
+
+ ///
+ /// Human-readable label for wizard display.
+ ///
+ 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}"
+ };
+}
diff --git a/Core/YearTransition/YearTransitionPlanner.cs b/Core/YearTransition/YearTransitionPlanner.cs
new file mode 100644
index 0000000..a7ad58b
--- /dev/null
+++ b/Core/YearTransition/YearTransitionPlanner.cs
@@ -0,0 +1,294 @@
+using Core.Entities;
+
+namespace Core.YearTransition;
+
+///
+/// Inputs for building a year-transition plan.
+///
+public sealed class YearTransitionRequest
+{
+ public required IReadOnlyList Students { get; init; }
+ public required IReadOnlySet ReturningStudentIds { get; init; }
+ public IReadOnlyDictionary OfficerAssignments { get; init; }
+ = new Dictionary();
+ public required int GraduatingGrade { get; init; }
+ public required string TargetCompetitionYear { get; init; }
+ public IReadOnlyList PastedNames { get; init; } = [];
+}
+
+///
+/// Preview of a year transition before it is applied.
+///
+public sealed class YearTransitionPlan
+{
+ public required string TargetCompetitionYear { get; init; }
+ public required int GraduatingGrade { get; init; }
+ public required IReadOnlyList Promotions { get; init; }
+ public required IReadOnlyList StudentsToRemove { get; init; }
+ public required IReadOnlyList OfficerChanges { get; init; }
+ public required IReadOnlyList UnmatchedPastedNames { get; init; }
+ public required IReadOnlyList AmbiguousPastedNames { get; init; }
+ public required IReadOnlyList 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; }
+}
+
+///
+/// Pure planner for year-end student promotion, graduation, and officer assignment.
+///
+public static class YearTransitionPlanner
+{
+ private const int AbsoluteMaxGrade = 12;
+
+ ///
+ /// Students at or above the graduating grade are suggested as non-returning.
+ ///
+ public static bool SuggestReturning(Student student, int graduatingGrade) =>
+ student.Grade < graduatingGrade;
+
+ ///
+ /// Parses pasted name lines and matches them to students.
+ ///
+ public static NameMatchResult MatchPastedNames(
+ IReadOnlyList students,
+ IEnumerable pastedLines)
+ {
+ var matchedIds = new HashSet();
+ var unmatched = new List();
+ var ambiguous = new List();
+
+ 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();
+ var promotions = new List();
+ var toRemove = new List();
+
+ 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()
+ .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();
+ 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()
+ .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 FindNameMatches(IReadOnlyList 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));
+ }
+
+ ///
+ /// Parses a name line into (first, last), using for
+ /// "Last, First" and a last-space split for "First Last".
+ ///
+ 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 matchedStudentIds,
+ IReadOnlyList unmatchedNames,
+ IReadOnlyList ambiguousNames)
+ {
+ MatchedStudentIds = matchedStudentIds;
+ UnmatchedNames = unmatchedNames;
+ AmbiguousNames = ambiguousNames;
+ }
+
+ public IReadOnlySet MatchedStudentIds { get; }
+ public IReadOnlyList UnmatchedNames { get; }
+ public IReadOnlyList AmbiguousNames { get; }
+}
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index e8b12f0..d24db05 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -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
### Adding a New User
@@ -310,10 +335,11 @@ curl http://localhost:8080
- [ ] Set file permissions to 600
- [ ] Configured HTTPS/SSL certificates
- [ ] 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)
- [ ] 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 rate limiting (5 failed attempts)
- [ ] Documented admin password securely
diff --git a/Tests/YearTransition/YearTransitionPlanner_Tests.cs b/Tests/YearTransition/YearTransitionPlanner_Tests.cs
new file mode 100644
index 0000000..6e40132
--- /dev/null
+++ b/Tests/YearTransition/YearTransitionPlanner_Tests.cs
@@ -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 { 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 { 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 { 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 { president.Id, vp.Id },
+ GraduatingGrade = 8,
+ TargetCompetitionYear = "2027",
+ OfficerAssignments = new Dictionary
+ {
+ [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 { returning.Id },
+ GraduatingGrade = 8,
+ TargetCompetitionYear = "2027",
+ OfficerAssignments = new Dictionary
+ {
+ [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 { student.Id },
+ GraduatingGrade = 8,
+ TargetCompetitionYear = "2027",
+ OfficerAssignments = new Dictionary
+ {
+ [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 { 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"));
+ }
+}
diff --git a/WebApp/Components/Pages/ChapterSettings.razor b/WebApp/Components/Pages/ChapterSettings.razor
index b032e85..7bb82dc 100644
--- a/WebApp/Components/Pages/ChapterSettings.razor
+++ b/WebApp/Components/Pages/ChapterSettings.razor
@@ -3,10 +3,10 @@
@using WebApp.Authentication
@using WebApp.Models
@using WebApp.Components.Shared.Components
-@using System.Text.Json
+@using WebApp.Services
@using Core.Models
-@inject IWebHostEnvironment Environment
@inject IConfiguration Configuration
+@inject IChapterSettingsWriter ChapterSettingsWriter
@rendermode InteractiveServer
@@ -125,19 +125,10 @@
protected override void OnInitialized()
{
- // Load from IConfiguration
_settings = Configuration.GetSection("ChapterSettings").Get()
?? new Models.ChapterSettings();
}
- private string GetAppSettingsPath()
- {
- return Path.Combine(
- Environment.ContentRootPath,
- "Data",
- "appsettings.json");
- }
-
private async Task SaveSettings()
{
if (_settings == null) return;
@@ -147,41 +138,7 @@
try
{
- var appSettingsPath = GetAppSettingsPath();
-
- // 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 settings;
-
- if (File.Exists(appSettingsPath))
- {
- var existingJson = await File.ReadAllTextAsync(appSettingsPath);
- existingDoc = JsonDocument.Parse(existingJson);
- settings = JsonSerializer.Deserialize>(existingJson)
- ?? new Dictionary();
- }
- else
- {
- settings = new Dictionary();
- }
-
- // 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();
-
+ await ChapterSettingsWriter.WriteAsync(_settings);
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_statusSeverity = Severity.Success;
}
diff --git a/WebApp/Components/Pages/YearRollover.razor b/WebApp/Components/Pages/YearRollover.razor
new file mode 100644
index 0000000..8c22a50
--- /dev/null
+++ b/WebApp/Components/Pages/YearRollover.razor
@@ -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 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;
+ }
+}
diff --git a/WebApp/Components/Shared/Layout/NavMenu.razor b/WebApp/Components/Shared/Layout/NavMenu.razor
index 809f890..b7474be 100644
--- a/WebApp/Components/Shared/Layout/NavMenu.razor
+++ b/WebApp/Components/Shared/Layout/NavMenu.razor
@@ -35,6 +35,7 @@
Chapter Settings
+ New Year Rollover (locked)
Validation Settings
diff --git a/WebApp/Program.cs b/WebApp/Program.cs
index c9626f4..531770b 100644
--- a/WebApp/Program.cs
+++ b/WebApp/Program.cs
@@ -201,6 +201,9 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.Configure(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
diff --git a/WebApp/Services/ChapterSettingsWriter.cs b/WebApp/Services/ChapterSettingsWriter.cs
new file mode 100644
index 0000000..1781e5c
--- /dev/null
+++ b/WebApp/Services/ChapterSettingsWriter.cs
@@ -0,0 +1,67 @@
+using System.Text.Json;
+using WebApp.Models;
+
+namespace WebApp.Services;
+
+///
+/// Persists chapter settings to Data/appsettings.json.
+///
+public class ChapterSettingsWriter : IChapterSettingsWriter
+{
+ private readonly IWebHostEnvironment _environment;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+
+ public ChapterSettingsWriter(
+ IWebHostEnvironment environment,
+ IConfiguration configuration,
+ ILogger 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 root;
+ if (File.Exists(appSettingsPath))
+ {
+ var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
+ root = JsonSerializer.Deserialize>(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()
+ ?? new ChapterSettings();
+ settings.CompetitionYear = competitionYear;
+ await WriteAsync(settings, cancellationToken);
+ }
+
+ private string GetAppSettingsPath() =>
+ Path.Combine(_environment.ContentRootPath, "Data", "appsettings.json");
+}
diff --git a/WebApp/Services/DatabaseBackupService.cs b/WebApp/Services/DatabaseBackupService.cs
new file mode 100644
index 0000000..d8df90f
--- /dev/null
+++ b/WebApp/Services/DatabaseBackupService.cs
@@ -0,0 +1,48 @@
+using Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace WebApp.Services;
+
+///
+/// Creates SQLite database backups via VACUUM INTO.
+///
+public class DatabaseBackupService : IDatabaseBackupService
+{
+ private readonly AppDbContext _context;
+ private readonly IWebHostEnvironment _environment;
+ private readonly ILogger _logger;
+
+ public DatabaseBackupService(
+ AppDbContext context,
+ IWebHostEnvironment environment,
+ ILogger logger)
+ {
+ _context = context;
+ _environment = environment;
+ _logger = logger;
+ }
+
+ public async Task 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;
+ }
+}
diff --git a/WebApp/Services/IChapterSettingsWriter.cs b/WebApp/Services/IChapterSettingsWriter.cs
new file mode 100644
index 0000000..dd10c70
--- /dev/null
+++ b/WebApp/Services/IChapterSettingsWriter.cs
@@ -0,0 +1,19 @@
+using WebApp.Models;
+
+namespace WebApp.Services;
+
+///
+/// Persists chapter settings to Data/appsettings.json.
+///
+public interface IChapterSettingsWriter
+{
+ ///
+ /// Writes the given chapter settings, preserving other top-level sections in the file.
+ ///
+ Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default);
+
+ ///
+ /// Updates only the competition year while preserving other chapter settings from configuration.
+ ///
+ Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default);
+}
diff --git a/WebApp/Services/IDatabaseBackupService.cs b/WebApp/Services/IDatabaseBackupService.cs
new file mode 100644
index 0000000..1c6dcd9
--- /dev/null
+++ b/WebApp/Services/IDatabaseBackupService.cs
@@ -0,0 +1,13 @@
+namespace WebApp.Services;
+
+///
+/// Creates SQLite database backups.
+///
+public interface IDatabaseBackupService
+{
+ ///
+ /// Creates a pre-rollover backup of the application database using SQLite VACUUM INTO.
+ ///
+ /// The absolute path of the backup file.
+ Task CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default);
+}
diff --git a/WebApp/Services/IYearRolloverService.cs b/WebApp/Services/IYearRolloverService.cs
new file mode 100644
index 0000000..40a7062
--- /dev/null
+++ b/WebApp/Services/IYearRolloverService.cs
@@ -0,0 +1,36 @@
+using Core.YearTransition;
+
+namespace WebApp.Services;
+
+///
+/// Options for applying a year rollover.
+///
+public sealed class YearRolloverOptions
+{
+ public required YearTransitionPlan Plan { get; init; }
+ public bool ClearEventOccurrences { get; init; } = true;
+}
+
+///
+/// Result of a successful year rollover.
+///
+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 OfficerSummary { get; init; }
+}
+
+///
+/// Applies a year-transition plan to the database.
+///
+public interface IYearRolloverService
+{
+ Task ApplyAsync(YearRolloverOptions options, CancellationToken cancellationToken = default);
+}
diff --git a/WebApp/Services/YearRolloverService.cs b/WebApp/Services/YearRolloverService.cs
new file mode 100644
index 0000000..9f3a137
--- /dev/null
+++ b/WebApp/Services/YearRolloverService.cs
@@ -0,0 +1,182 @@
+using Core.YearTransition;
+using Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace WebApp.Services;
+
+///
+/// Applies a year-transition plan: backup, wipe season data, promote/remove students, update year.
+///
+public class YearRolloverService : IYearRolloverService
+{
+ private readonly AppDbContext _context;
+ private readonly IDatabaseBackupService _backupService;
+ private readonly IChapterSettingsWriter _chapterSettingsWriter;
+ private readonly ILogger _logger;
+
+ public YearRolloverService(
+ AppDbContext context,
+ IDatabaseBackupService backupService,
+ IChapterSettingsWriter chapterSettingsWriter,
+ ILogger logger)
+ {
+ _context = context;
+ _backupService = backupService;
+ _chapterSettingsWriter = chapterSettingsWriter;
+ _logger = logger;
+ }
+
+ public async Task 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 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;
+ }
+}
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index 49469c3..34e9355 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -26,8 +26,9 @@ services:
# Option 1: Volume-mounted secrets file (recommended for easy editing)
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
- # Database persistence
- - ./data:/app/data
+ # Database persistence — must use capital D (/app/Data). Linux paths are
+ # case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
+ - ./data:/app/Data
# HTTPS certificate (if needed)
# - ./certs:/https:ro
diff --git a/docs/instructions/year-rollover.md b/docs/instructions/year-rollover.md
new file mode 100644
index 0000000..59583eb
--- /dev/null
+++ b/docs/instructions/year-rollover.md
@@ -0,0 +1,67 @@
+# Year Rollover Runbook
+
+**Created:** 2026-08-14
+**Last updated:** 2026-08-14
+**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 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.