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
87 changed files with 4040 additions and 2216 deletions
-10
View File
@@ -1,10 +0,0 @@
using Core.Entities;
namespace Core.Models;
/// <summary>
/// Groups parsed occurrences by event definition and optional section school level from headers
/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same <see cref="EventDefinition"/>
/// can appear in multiple groups.
/// </summary>
public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel);
+4 -4
View File
@@ -9,11 +9,11 @@ namespace Core.Models;
public class EventOccurrenceParseResult
{
/// <summary>
/// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
/// Special events use <see cref="EventOccurrenceParseGroup.EventDefinition"/> static instances with
/// <see cref="EventOccurrenceParseGroup.SectionSchoolLevel"/> typically null.
/// Dictionary of parsed event occurrences, keyed by EventDefinition.
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering),
/// the EventDefinition key will be the static instance.
/// </summary>
public IDictionary<EventOccurrenceParseGroup, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<EventOccurrence>>();
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
/// <summary>
/// List of parsing errors (critical issues that prevented parsing).
@@ -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
}
+8 -10
View File
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using Core.Entities;
using Core.Models;
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
@@ -12,7 +12,7 @@ namespace Core.Parsers;
/// </summary>
public class EventOccurrenceParserResult
{
public IDictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>>();
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
public List<ParsingIssue> Issues { get; set; } = new();
public List<string> SkippedSectionHeaders { get; set; } = new();
public int SkippedEventCount { get; set; }
@@ -296,14 +296,12 @@ public class EventOccurrenceParser
Location = location
};
var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
if (!occurrences.TryGetValue(groupKey, out var groupList))
{
groupList = [];
occurrences[groupKey] = groupList;
}
groupList.Add(eventOccurrence);
if (!occurrences.ContainsKey(eventDefinition))
occurrences.Add(eventDefinition, []);
occurrences[eventDefinition].Add(eventOccurrence);
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
currentSectionLevel = null;
}
return result;
+218 -54
View File
@@ -1,77 +1,241 @@
using Core.Entities;
using Core.Models;
using FuzzySharp;
namespace Core.Parsers;
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 StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events)
{
var rankings = new List<StudentEventRanking>();
public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
{
}
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> events)
{
var result = new StudentEventRankingParseResult();
CsvReader.Read();
CsvReader.ReadHeader();
while (CsvReader.Read())
{
var name = CsvReader.GetField("Student Name");
if (CsvReader.HeaderRecord is null ||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
{
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))
continue;
var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90);
if (student == null)
continue;
var competitiveEvents = new List<EventDefinition>();
for (var i = 1; i <= 6; i++)
var studentMatch = FindStudent(students, name);
if (studentMatch is null)
{
var eventName = CsvReader.GetField(i.ToString());
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())
result.Issues.Add(new StudentEventRankingIssue
{
matches =
(from e in events
where e.Name.StartsWith(eventName)
select e).ToList();
}
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});
RowNumber = rowNumber,
RawStudentName = name,
IssueType = StudentEventRankingIssueType.UnmatchedStudent,
Message = $"No student matched '{name}'."
});
continue;
}
}
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);
}
@@ -65,8 +65,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
// Convert parsed occurrences to result format, handling special event types
foreach (var kvp in parsedOccurrences)
{
var group = kvp.Key;
var eventDefinition = group.EventDefinition;
var eventDefinition = kvp.Key;
var occurrences = kvp.Value;
// Check if this is a special event type (not stored in database)
@@ -91,7 +90,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
};
}
result.Occurrences[group] = occurrences;
// Add to result with the special EventDefinition as key
result.Occurrences[eventDefinition] = occurrences;
}
else
{
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
occurrence.SpecialEventType = null;
}
result.Occurrences[group] = occurrences;
result.Occurrences[eventDefinition] = occurrences;
}
}
@@ -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
### 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
-6
View File
@@ -8,8 +8,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoogleSheetsScheduleImport", "tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj", "{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
ProjectSection(ProjectDependencies) = postProject
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
@@ -37,10 +35,6 @@ Global
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1,28 +0,0 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class GlobalEventDeduplicatorTests
{
[Test]
public void Curfew_same_time_multiple_locations_becomes_one_line_without_location()
{
var lines = new List<ParsedOccurrenceLine>
{
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1),
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2),
new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1)
};
var result = GlobalEventDeduplicator.Deduplicate(lines);
Assert.Multiple(() =>
{
Assert.That(result.Count, Is.EqualTo(2));
var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase));
Assert.That(curfew.Location, Is.Empty);
Assert.That(result.Any(l => l.Name == "Meeting"), Is.True);
});
}
}
@@ -1,40 +0,0 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ImportTextEmitterRoundTripTests
{
[Test]
public void EmittedText_Parses_UnderGeneralSchedule()
{
var sheets = new List<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)>
{
("Thursday", "General Schedule", new List<ParsedOccurrenceLine>
{
new(
Name: "Opening Ceremony",
Month: "April",
Day: 3,
TimeRange: "9 a.m. - 10 a.m.",
Location: "Main Hall",
SourceRowStart: 1,
SourceRowEnd: 1,
SourceCol: 1)
})
};
var text = ImportTextEmitter.Build(sheets);
var result = ParserRoundTripValidator.Validate(text, new List<EventDefinition>
{
EventDefinition.GeneralSchedule
});
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
Assert.That(result.TotalParsed, Is.EqualTo(1));
});
}
}
@@ -1,29 +0,0 @@
using Core.Entities;
using Core.Models;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceDisplayNameReducerTests
{
private static EventDefinition Cyber() =>
new()
{
Id = 1,
Name = "Cybersecurity",
ShortName = "Cyber",
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Strips_ms_prefix_and_event_name()
{
var n = OccurrenceDisplayNameReducer.ReduceForSection(
"MS Cybersecurity Semifinals Presentations",
Cyber(),
SchoolLevel.MiddleSchool);
Assert.That(n, Is.EqualTo("Semifinals Presentations"));
}
}
@@ -1,39 +0,0 @@
using Core.Entities;
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class OccurrenceEventMatcherTests
{
private static EventDefinition E(string name, int id) =>
new()
{
Id = id,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
};
[Test]
public void Ms_prefix_matches_Biotechnology()
{
var events = new List<EventDefinition> { E("Biotechnology", 1), E("Biotechnology Design", 2) };
var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl);
Assert.Multiple(() =>
{
Assert.That(ok, Is.True);
Assert.That(evt!.Name, Is.EqualTo("Biotechnology"));
Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool));
});
}
[Test]
public void No_Clear_prefix_goes_unmatched_or_general_bucket()
{
var events = new List<EventDefinition> { E("Opening Session", 1) };
var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl);
Assert.That(ok, Is.False);
}
}
@@ -1,42 +0,0 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class ScheduleGridExtractorTests
{
[Test]
public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot()
{
string?[][] values =
[
[null, "Main Hall"],
["9:00 a.m.", "Opening Ceremony"],
["10:00 a.m.", null]
];
string?[][] bg =
[
[null, null],
[null, null],
[null, null]
];
var grid = new GridSheetModel
{
SheetTitle = "Day1",
Values = values,
BackgroundKeys = bg
};
var warnings = new List<string>();
var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings);
Assert.Multiple(() =>
{
Assert.That(lines, Has.Count.EqualTo(1));
Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony"));
Assert.That(lines[0].Month, Is.EqualTo("April"));
Assert.That(lines[0].Day, Is.EqualTo(2));
Assert.That(lines[0].Location, Is.EqualTo("Main Hall"));
Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m."));
});
}
}
@@ -1,14 +0,0 @@
using GoogleSheetsScheduleImport;
namespace Tests.GoogleSheets;
[TestFixture]
public class TextNormalizationTests
{
[Test]
public void Collapses_line_separator_and_newlines()
{
var s = "Banquet Room\u2028E";
Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E"));
}
}
@@ -226,10 +226,9 @@ public class EventOccurrenceParserIssues_Tests
// Verify successful occurrence is still parsed (if any valid lines exist)
// The "Valid Event" line should parse successfully despite other issues
var validEvent = events.First(e => e.Name == "Valid Event");
var validGroup = new EventOccurrenceParseGroup(validEvent, null);
if (result.Occurrences.ContainsKey(validGroup))
if (result.Occurrences.ContainsKey(validEvent))
{
Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
}
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
// but typically it should still parse since it's a valid line
@@ -351,12 +350,11 @@ public class EventOccurrenceParserIssues_Tests
// Verify occurrences were parsed correctly (if they were parsed)
var testEvent = events.First(e => e.Name == "Test Event");
var testGroup = new EventOccurrenceParseGroup(testEvent, null);
if (result.Occurrences.ContainsKey(testGroup))
if (result.Occurrences.ContainsKey(testEvent))
{
Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
var occurrence = result.Occurrences[testGroup].First();
var occurrence = result.Occurrences[testEvent].First();
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
}
@@ -364,8 +362,8 @@ public class EventOccurrenceParserIssues_Tests
// The important thing is that the parser doesn't crash and processes the input
// Verify locations are extracted correctly (pattern matching is no longer used)
var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
? result.Occurrences[testGroup].FirstOrDefault()
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
? result.Occurrences[testEvent].FirstOrDefault()
: null;
if (testEventOccurrence != null)
{
@@ -414,11 +412,10 @@ public class EventOccurrenceParserIssues_Tests
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
// General Schedule section uses EventDefinition.GeneralSchedule
var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
$"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
var occurrences = result.Occurrences[gsGroup];
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
Assert.That(occurrences, Has.Count.GreaterThan(0),
"Should have at least one occurrence in General Schedule");
@@ -504,7 +501,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
Assert.That(result.Occurrences.ContainsKey(events[0]));
}
finally
{
@@ -531,7 +528,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
Assert.That(result.Occurrences.ContainsKey(events[0]));
}
finally
{
@@ -557,7 +554,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
Assert.That(result.Occurrences.ContainsKey(events[0]));
}
finally
{
@@ -583,7 +580,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
Assert.That(result.Occurrences.ContainsKey(events[0]));
}
finally
{
+59 -84
View File
@@ -107,24 +107,17 @@ public class EventOccurrenceParser_Tests
/// <summary>
/// Writes special events summary to console.
/// </summary>
private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
{
Console.WriteLine($"\n--- Special Events Found ---");
static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
if (gs > 0)
Console.WriteLine($" GeneralSchedule: {gs} occurrences");
var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
if (mtc > 0)
Console.WriteLine($" MeetTheCandidates: {mtc} occurrences");
var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting);
if (com > 0)
Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences");
var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting);
if (vdm > 0)
Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
}
/// <summary>
@@ -244,26 +237,43 @@ public class EventOccurrenceParser_Tests
/// <summary>
/// Writes special events to console output.
/// </summary>
private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
{
static List<Core.Entities.EventOccurrence> ListFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
Console.WriteLine("General Schedule");
foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
{
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Meet the Candidates");
foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
{
foreach (var eo in meetTheCandidates)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Chapter Officer Meeting");
foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
{
foreach (var eo in chapterOfficerMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
Console.WriteLine("Voting Delegate Meeting");
foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
{
foreach (var eo in votingDelegateMeeting)
{
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
}
}
#endregion
@@ -280,11 +290,7 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -314,11 +320,7 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
var eventOccurrences = dictionary
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
.SelectMany(kvp => kvp.Value)
.ToList();
if (eventOccurrences.Count == 0)
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -445,13 +447,13 @@ public class EventOccurrenceParser_Tests
// Total expected MS occurrences: 16
var msEventCount = 0;
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
msEventCount += csOccurrences.Count;
if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
msEventCount += codingOccurrences.Count;
if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
msEventCount += csvOccurrences.Count;
if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
msEventCount += ccOccurrences.Count;
// When no school level is set, HS events should be processed (not skipped)
@@ -510,7 +512,7 @@ public class EventOccurrenceParser_Tests
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
// Verify specific locations are parsed
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
{
var locations = childrensStoriesOccurrences
.Select(eo => eo.Location)
@@ -561,17 +563,20 @@ public class EventOccurrenceParser_Tests
"HS section header should NOT be in SkippedSectionHeaders when no school level is set");
// With no school level filtering, both MS and HS events are processed
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
msOccurrences ??= [];
hsOccurrences ??= [];
Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
Assert.That(allNames, Does.Contain("Submit Entry"));
Assert.That(allNames, Does.Contain("Judging"));
Assert.That(allNames, Does.Contain("Pick-up"));
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
{
// With no school level set, we process all occurrences (both MS and HS)
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
Assert.That(allOccurrences, Has.Count.EqualTo(5),
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
$"Found {allOccurrences.Count} occurrences total.");
// Verify all expected occurrence names are present
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
}
Assert.Pass("All events processed when no school level is set");
}
@@ -580,34 +585,4 @@ public class EventOccurrenceParser_Tests
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
[Test]
public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups()
{
var testContent = "Prepared Speech - HS\n" +
"Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" +
"Prepared Speech - MS\n" +
"Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10";
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") };
var parser = new EventOccurrenceParser(tempFile, events);
try
{
var result = parser.Parse();
Assert.That(result.Issues, Has.Count.EqualTo(0));
var def = events[0];
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True);
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True);
Assert.That(hsList, Has.Count.EqualTo(1));
Assert.That(msList, Has.Count.EqualTo(1));
Assert.That(hsList![0].Name, Does.Contain("Extemporaneous"));
Assert.That(msList![0].Name, Does.Contain("Prelims"));
}
finally
{
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
}
@@ -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)
{
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);
return rankingParser.Parse(students, events);
using var rankingParser = new StudentEventRankingParser(fileInfo);
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
+1 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -18,7 +18,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Parsers\TestInput\2025 Assumptions.csv">
@@ -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="_framework/blazor.web.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/markdownEditor.js"></script>
<script src="js/markdownTablePaste.js"></script>
<script src="js/login.js"></script>
</body>
</html>
@@ -134,7 +134,7 @@
if (e.Key == "Enter")
{
// 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
await Task.Delay(50);
@@ -146,14 +146,11 @@
private async Task HandleFormSubmit()
{
// Update hidden inputs with current model values, then submit the form
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
await JS.InvokeVoidAsync("eval", $@"
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
document.getElementById('returnUrlInput').value = {returnUrlValue};
document.getElementById('loginForm').submit();
");
await JS.InvokeVoidAsync(
"tsaLogin.submitForm",
_loginModel.Email ?? string.Empty,
_loginModel.Password ?? string.Empty,
_loginModel.RememberMe,
_returnUrl ?? string.Empty);
}
}
@@ -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();
}
}
@@ -121,7 +121,7 @@
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{
<MudAlert Severity="Severity.Success" Dense="true">
Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s)
Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s)
@if (_parseResult.SkippedEventCount > 0)
{
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
@@ -201,11 +201,9 @@
{
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
<MudExpansionPanels Elevation="0">
@foreach (var kvp in _parseResult.Occurrences
.OrderBy(x => GetEventName(x.Key.EventDefinition))
.ThenBy(x => x.Key.SectionSchoolLevel switch { SchoolLevel.MiddleSchool => 0, SchoolLevel.HighSchool => 1, _ => 2 }))
@foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key)))
{
<MudExpansionPanel Text="@GetOccurrenceGroupTitle(kvp.Key)">
<MudExpansionPanel Text="@GetEventName(kvp.Key)">
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Name</MudTh>
@@ -397,17 +395,6 @@
return eventDefinition.Name;
}
private string GetOccurrenceGroupTitle(EventOccurrenceParseGroup group)
{
var title = GetEventName(group.EventDefinition);
return group.SectionSchoolLevel switch
{
SchoolLevel.MiddleSchool => $"{title} (MS)",
SchoolLevel.HighSchool => $"{title} (HS)",
_ => title
};
}
private Color GetIssueTypeColor(ParsingIssueType issueType)
{
return issueType switch
@@ -14,6 +14,9 @@
<MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</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">
<MudTooltip Text="Admin">
<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
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="Yearly theme: Unity Through Community" />
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
@if (_events == null)
{
@@ -66,7 +66,7 @@ else
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i>
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
</MudText>
</MudItem>
<MudItem xs="8">
@@ -91,83 +91,6 @@ else
<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>
</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>
}
@code {
private EventDefinition[]? _events;
@@ -11,6 +11,14 @@
Title="Student Event Ranks"
Icon="@AppIcons.EventRank">
<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" />
</ActionButtons>
</PageHeader>
@@ -1,9 +1,7 @@
@page "/students/event-ranking-edit/{StudentId:int}"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using BlazorSortableList
@using WebApp.Models
@using WebApp.Components.Shared.Components
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@@ -33,7 +31,6 @@ else
</ActionButtons>
</PageHeader>
@* https://github.com/AlexNek/BlazorSortableList *@
<MudGrid Class="mt-2">
<MudItem xs="12" md="6" xl="4">
<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" />
<MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText>
</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>
<SortableList
Group="GroupId" Id="ListId1" Context="item"
Items="_rankedEvents" OnRemove="RankedEventsRemove" OnUpdate="Update">
<SortableItemTemplate>
<MudCard Outlined="true" Class="mb-2">
<MudCardContent Class="pa-2">
<div class="d-flex align-center">
<MudBadge Content="@(_rankedEvents.IndexOf(item) + 1)" Color="Color.Primary" Overlap="true" Class="mr-3">
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" />
</MudBadge>
<div class="flex-grow-1">
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
<MudText Typo="Typo.caption">
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
</MudText>
</div>
</div>
</MudCardContent>
</MudCard>
</SortableItemTemplate>
</SortableList>
@if (_rankedEvents.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
No ranked events yet. Add events from the list on the right.
</MudAlert>
}
@for (var i = 0; i < _rankedEvents.Count; i++)
{
var index = i;
var item = _rankedEvents[index];
var rank = index + 1;
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
<MudCardContent Class="pa-2">
<div class="d-flex align-center">
<MudBadge Content="@rank" Color="Color.Primary" Overlap="true" Class="mr-3">
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" />
</MudBadge>
<div class="flex-grow-1">
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
<MudText Typo="Typo.caption">
@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 xs="12" md="6" xl="4">
<MudPaper Class="pa-3 mb-3" Elevation="2">
@@ -71,99 +91,117 @@ else
<MudIcon Icon="@AppIcons.Events" Class="mr-2" />
<MudText Typo="Typo.h6">Available Events</MudText>
</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>
<SortableList
Group="GroupId" Id="ListId2" Context="item"
Items="_availableEvents" OnRemove="AvailableEventsRemove" Sort="false">
<SortableItemTemplate>
<MudCard Outlined="true" Class="mb-2">
<MudCardContent Class="pa-2">
@if (_availableEvents.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense="true">
@(_rankedEvents.Count == 0 ? "No events are available to rank." : "All events are already ranked.")
</MudAlert>
}
@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.caption">
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
</MudText>
</MudCardContent>
</MudCard>
</SortableItemTemplate>
</SortableList>
</div>
<MudIconButton Icon="@Icons.Material.Filled.Add"
Color="Color.Primary"
Disabled="@AtMaxRank"
aria-label="@($"Add {item.Name} to ranked events")"
OnClick="@(() => AddRanked(item))" />
</div>
</MudCardContent>
</MudCard>
}
</MudItem>
</MudGrid>
}
@code {
private const string ListId1 = "SharedListId1";
private const string ListId2 = "SharedListId2";
private const string GroupId = "CommonGroup";
[Parameter] public int? StudentId { get; set; }
private Student? _student;
private List<EventDefinition>? _events;
public List<EventDefinition> _rankedEvents = [];
public List<EventDefinition> _availableEvents = [];
private List<EventDefinition> _rankedEvents = [];
private List<EventDefinition> _availableEvents = [];
private void RankedEventsRemove((int oldIndex, int newIndex) indices)
{
// 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]);
}
private bool AtMaxRank => _rankedEvents.Count >= StudentEventRanking.MaxRank;
protected override async Task OnInitializedAsync()
{
_student =
await Context.Students
.Include(e => e.EventRankings)
.Where(e => e.Id == StudentId).FirstAsync();
_events =
.ThenInclude(r => r.EventDefinition)
.Where(e => e.Id == StudentId)
.FirstAsync();
var events =
await Context.Events
.OrderBy(e => e.Name)
.ToListAsync();
.ToListAsync();
_rankedEvents = _student.EventRankings.OrderBy(e => e.Rank).Select(e => e.EventDefinition).ToList();
_availableEvents = _events.Where(e => !_rankedEvents.Contains(e)).ToList();
_rankedEvents = _student.EventRankings
.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;
var items = _rankedEvents;
var itemToMove = items[oldIndex];
items.RemoveAt(oldIndex);
if (newIndex < items.Count)
if (AtMaxRank || _rankedEvents.Any(e => e.Id == item.Id))
{
items.Insert(newIndex, itemToMove);
}
else
{
items.Add(itemToMove);
return;
}
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()
{
@@ -172,10 +210,12 @@ else
try
{
var uniqueRanked = _rankedEvents.DistinctBy(e => e.Id).Take(StudentEventRanking.MaxRank).ToList();
_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
{
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
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)
{
@@ -74,7 +74,7 @@ else
{
<MudItem xs="3">
<MudText Class="d-flex py-1">
<i>Theme for 2025-26:</i>
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
</MudText>
</MudItem>
<MudItem xs="8">
@@ -19,6 +19,9 @@
<MudTooltip Text="Handout">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</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")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
@@ -9,7 +9,7 @@
<PageHeader
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
Description="Yearly theme: Unity Through Community" />
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
<Legend></Legend>
@if (_teams == null)
@@ -41,9 +41,8 @@ else
@{
var students
= context.Students
.OrderByDescending(s => s == context.Captain)
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue)
.ThenByDescending(e => e.Grade)
.OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
.ThenByDescending(e => e.Grade + e.TsaYear)
.ThenBy(e => e.FirstName)
.ToArray();
}
@@ -233,6 +232,7 @@ else
.AsNoTracking()
.Include(e => e.Event)
.Include(e => e.Students)
.Include(e => e.Captain)
.OrderByEventFormatFirst()
.ThenBy(e => e.Event.Name)
.ThenBy(e => e.Identifier ?? "")
+9 -46
View File
@@ -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
@@ -44,6 +44,12 @@
MaxLength="4"
Required="true" />
</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">
<MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel"
Label="School Level"
@@ -125,19 +131,10 @@
protected override void OnInitialized()
{
// Load from IConfiguration
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings();
}
private string GetAppSettingsPath()
{
return Path.Combine(
Environment.ContentRootPath,
"Data",
"appsettings.json");
}
private async Task SaveSettings()
{
if (_settings == null) return;
@@ -147,41 +144,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<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();
await ChapterSettingsWriter.WriteAsync(_settings);
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_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">
<MudDivider Class="my-2"/>
<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>
</AuthorizeView>
</MudNavMenu>
+10
View File
@@ -37,6 +37,16 @@ public class ChapterSettings
/// </summary>
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>
/// School level for the chapter (null = import both MS and HS events)
/// </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.Components;
using WebApp.Logging;
using WebApp.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
.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())
{
// 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_");
}
@@ -201,6 +201,14 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
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)
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
.Include(t => t.Event)
.Include(t => t.Students)
.Include(t => t.Captain)
.Where(t => ids.Contains(t.Event.Id))
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
.ToListAsync();
return teams
.GroupBy(t => t.Event.Id)
.GroupBy(t => t.Event!.Id)
.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>
<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="VisNetwork.Blazor" Version="3.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
+2 -1
View File
@@ -15,6 +15,7 @@
"NationalId": "2227",
"StateId": "12227",
"RegionalId": "12227",
"CompetitionYear": "2026"
"CompetitionYear": "2026",
"YearlyTheme": "Unity Through Community"
}
}
+17 -1
View File
@@ -16,7 +16,23 @@
"NationalId": "0000",
"StateId": "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": {
"MinRecommendedEvents": 2,
+70
View File
@@ -48,6 +48,76 @@
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 {
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)
- ./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
+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.
@@ -1,78 +0,0 @@
---
created: 2026-03-30
description: Extract a multi-day Google Sheets schedule grid into event-occurrence import text for the web app.
---
# Import event schedule from Google Sheets
This repo includes a console tool that reads a **public** Google Sheet (with grid data and cell formatting), interprets each tab as one calendar day, and writes **plain text** in the same format as the legacy PDF-derived files consumed by **Calendar → Import Event Occurrences** (`/calendar/event-occurrences/import`).
## Prerequisites
1. **Google Cloud API key** with the **Google Sheets API** enabled.
2. The spreadsheet must be readable with that key (typically **File → Share → Anyone with the link** *Viewer*, or share explicitly as needed for your key type).
3. A **mapping JSON** file that lists each tab title and the calendar **month/day** for that tab (see sample under `docs/notes/`).
## Expected grid layout
- **Row 1:** Column `B` onward = **location** names (column `A` may be blank or a label).
- **Column A (from row 2 down):** **Start time** for each row (e.g. `9:00 AM`, `9:00 a.m.`). The tool infers **slot length** from consecutive times and uses the next row’s time as the **end** of a block when cells span multiple rows.
- **Data cells:** Event title (required for a block) and/or **non-white background** (treated as part of the same block as adjacent cells with the same text + color). **Merged cells** are expanded so the anchor value applies to the whole merge.
## Default section header
Output uses **`General Schedule`** as the section header unless you override it in mapping. That matches grids where each cell is its own schedule item (conference-style). If every line should belong to a specific competition event, set `sectionHeader` for that tab to something like `Biotechnology - MS` (must fuzzy-match an event in your database when importing).
Under **General Schedule**, the parser assigns occurrences to the **General Schedule** event type in the app (see `EventDefinitionResolver` / `EventDefinition.GeneralSchedule`).
## Grouping into competition events (PDF-style sections)
If you set **`eventDefinitionsCsv`** in the mapping JSON (or pass **`--events-csv`**), the tool loads event names from the CSV **`Event`** column and, when possible, rewrites output like the state schedule text:
- Section header: **`{Event Name} - MS`** or **`{Event Name} - HS`**
- Occurrence lines under that section use **activity text only** (the tool drops the leading `MS`/`HS` and the repeated event name so lines read like the PDF, e.g. `On-Site Preliminary Exam …` not `MS Cybersecurity On-Site …`).
- Only rows whose cell text has a **leading** `MS `, `HS `, `MS/`, or `HS/` prefix (after normalization) are grouped; the remainder is fuzzy-matched to an event (see `OccurrenceEventMatcher`).
- **`MS/HS …`** combined rows → **General Schedule** (no single section).
- Unmatched lines (opening session, meet-the-candidates, help desk, etc.) stay under a final **`General Schedule`** block.
Use **`--no-group-by-event`** to force the old “one General Schedule per sheet” layout.
## Site-wide rows (e.g. CURFEW)
If the same label appears across **every location column** for the same time (typical for **CURFEW**), the tool emits **one line per date and time** with **no location**. Built-in: `CURFEW` (case-insensitive). Optional mapping field **`siteWideEventNames`** adds more titles (e.g. `["Fire drill"]`).
## Run the tool
From the repository root:
```powershell
$env:GOOGLE_SHEETS_API_KEY = "<your-api-key>"
dotnet run --project tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj -- `
--sheet-url "https://docs.google.com/spreadsheets/d/<spreadsheetId>/edit" `
--mapping path/to/mapping.json `
--output path/to/event-times.txt
```
Optional flags:
| Flag | Purpose |
|------|--------|
| `--year 2026` | Year for date validation (also in mapping file). |
| `--tabs "Day 1,Day 2"` | Only these tab titles (exact match). |
| `--events-csv path\to\Event Definitions.csv` | Load event names for stricter parser validation (`Event` column). |
| `--strict` | Exit code `1` if the built-in parser reports errors or parses zero occurrences. |
The tool always runs a **parser round-trip** on the generated text and prints errors/issues to the console.
## Import into the app
1. Open **Import Event Occurrences** in the web app.
2. Paste the contents of the generated `.txt` file.
3. **Parse**, review results, then **Save to Database** as usual.
## Troubleshooting
- **403 / access denied:** Confirm the sheet is visible to the API key and Sheets API is enabled.
- **Wrong durations:** Ensure time labels in column A are consistent; the tool infers slot length from the most common delta between consecutive rows.
- **Parser issues on import:** Use `--events-csv` pointing at your chapter’s event definitions export; fix `sectionHeader` in mapping if items should sit under a specific `Event Name - MS/HS` section.
+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.
-163
View File
@@ -1,163 +0,0 @@
# THURSDAY 4/9
General Schedule
Registration April 9 3 p.m. - 7 p.m. CCC Lobby (outside of Banquet Rooms)
FCCLA/TSA Store April 9 3 p.m. - 7 p.m. Meeting Room 1
MS/HS Prompt Releases (Virtual via App) April 9 6 p.m. - 9 p.m. Banquet Room E
MS/HS Event Turn-In April 9 6 p.m. - 9 p.m. Banquet Room G
HS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room H
MS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room I
MEMCO Meeting April 9 6 p.m. - 7 p.m. Banquet Room J
MS/HS Time Sign-Ups (Virtual via App) April 9 6:30 p.m. - 7:30 p.m. Banquet Room F
Coordinators Meeting April 9 7 p.m. - 8 p.m. Banquet Room J
SOT Candidates Meeting April 9 8 p.m. - 9:30 p.m. Banquet Room J
CURFEW April 9 11 p.m. - 12:30 a.m.
# FRIDAY 4/10
CAD foundations - MS
HS 2D CAD Architecture/ HS 3D CAD Engineering On-Site Challenge April 10 10:30 a.m. - 4:30 p.m. Banquet Room G
Career Prep - MS
Semifinals Interviews April 10 1 p.m. - 3 p.m. Meeting Room 10
Challenging Technology Issues - HS
Debating Technological Issues Prelims Pre-Debate Meeting April 10 10 a.m. - 10:30 a.m. Meeting Room 6
Debating Technological Issues Prelims Presentation Room (Heat 1) April 10 1 p.m. - 5 p.m. Meeting Room 4
Debating Technological Issues Prelims Presentation Room (Heat 2) April 10 1 p.m. - 5 p.m. Meeting Room 5
Challenging Technology Issues - MS
Leadership Strategies Holding Room April 10 10:30 a.m. - 4 p.m. Meeting Room 7
Prelims Presentation Room April 10 10:30 a.m. - 1 p.m. Meeting Room 8
Coding - HS
Extemporaneous Speech/Debating Technological Issues Holding Room Room April 10 10 a.m. - 5 p.m. Meeting Room 3
Data Science & Analytics - HS
"Quarterfinals" Presentation April 10 12:30 p.m. - 5 p.m. Meeting Room 9
Digital Photography - MS
Semifinals Challenge April 10 10 a.m. - 1 p.m. Meeting Room 19
Semifinals Interviews April 10 3 p.m. - 4 p.m. Meeting Room 16
Electrical Applications - MS
Semifinals Challenge April 10 2 p.m. - 3:30 p.m. Meeting Room 19
Forensic Technology - HS
Future Technology and Engineering Teacher Semifinals Presentations April 10 10 a.m. - 1 p.m. Meeting Room 18
Leadership Strategies - MS
Prelims Presentation Room April 10 1:30 p.m. - 4 p.m. Meeting Room 8
Mass Production - HS
Digital Video Production Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 17
Music Production Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 17
Prepared Speech - HS
Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4
Extemporaneous Speech Presentation Room (Heat 2) April 10 10 a.m. - 12:30 p.m. Meeting Room 5
Prepared Presentation Prelims Presentation April 10 10 a.m. - 3:30 p.m. Meeting Room 21
Prepared Speech - MS
Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
System Control Technology - MS
HS System Control Technology On-Site Challenge April 10 10 a.m. - 2 p.m. Banquet Room F
Tech Bowl - HS
Photographic Tech Semifinals Prompt Release April 10 11 a.m. - 12 p.m. Meeting Room 6
Video Game Design - MS
Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
Website Design - MS
Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 16
General Schedule
FCCLA/TSA Store April 10 8 a.m. - 4 p.m. Meeting Room 1
MS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
HS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
Opening Session April 10 9 a.m. - 10 a.m. Exhibit Hall A
TECHSPO April 10 10 a.m. - 4 p.m. Main Hallway
Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
Advisor Meeting April 10 10 a.m. - 11 a.m. Banquet Room E
HS Software Development Semifinals Presentations April 10 10 a.m. - 12 p.m. Meeting Room 9
MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
Workshop April 10 11:30 a.m. - 1:30 p.m. Banquet Room E
HS STEM Mass Media Semifinals Press Conference April 10 12:30 p.m. - 3:30 p.m. Meeting Room 6
TSA Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
HS Animatronics Presentations/ Interviews April 10 1:30 p.m. - 4:30 p.m. Meeting Room 18
Workshop April 10 2:30 p.m. - 4:30 p.m. Banquet Room E
HS VR Semifinals Interviews April 10 2:30 p.m. - 4:30 p.m. Banquet Room F
Community Service Video Seminfinal Interviews April 10 3 p.m. - 4 p.m. Meeting Room 17
TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
(no title) April 10 4:30 p.m. - 5:30 p.m. Banquet Room E
Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
(no title) April 10 10 p.m. - 11 p.m. Banquet Room E
CURFEW April 10 11 p.m. - 12:30 a.m.
# SATURDAY 4/11
Challenging Technology Issues - HS
Debating Technological Issues Semifinals Pre-Debate Meeting April 11 9:30 a.m. - 10 a.m. Meeting Room 7
Debating Technological Issues Semifinals Presentation Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 8
Challenging Technology Issues - MS
Semifinals Holding Room April 11 9:30 a.m. - 11 a.m. Meeting Room 9
Semifinals Presentation Room April 11 9:30 a.m. - 11 a.m. Meeting Room 10
Chapter Team - HS
Semifinals Presentation April 11 1:30 p.m. - 3:30 p.m. Meeting Room 16
Chapter Team - MS
Semifinals Presentation April 11 11:30 a.m. - 1 p.m. Meeting Room 16
Children's Stories - HS
Semifinals Interviews April 11 9:30 a.m. - 12:30 p.m. Meeting Room 18
Children's Stories - MS
Semifinals Interviews April 11 1 p.m. - 4 p.m. Meeting Room 18
Coding - HS
Semifinals Challenge April 11 9:30 a.m. - 12 p.m. Meeting Room 19
Debating Technological Issues Semifinals Holding Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 7
Extemporaneous Speech Semifinals Holding Room April 11 1 p.m. - 2:30 p.m. Meeting Room 7
Coding - MS
Semifinals Challenge April 11 12:30 p.m. - 3 p.m. Meeting Room 19
Cybersecurity - MS
Seminals Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
Data Science & Analytics - HS
Forensic Science Written Analysis Room April 11 9:30 a.m. - 2:30 p.m. Meeting Room 5
Semifinals Challenge April 11 3 p.m. - 5 p.m. Meeting Room 6
Data Science & Analytics - MS
Data Science and Analytics Presentations April 11 3 p.m. - 4:30 p.m. Meeting Room 4
Data Science and Analytics Preparation Room April 11 3 p.m. - 4:30 p.m. Meeting Room 5
Forensic Technology - HS
Photographic Technology Semifinals Interviews April 11 3 p.m. - 5 p.m. Meeting Room 7
Forensic Technology - MS
Semifinals Presentation Room April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
Leadership Strategies - MS
Semifinals Holding Room April 11 11:30 a.m. - 1 p.m. Meeting Room 9
Semifinals Presentation Room April 11 11:30 a.m. - 1 p.m. Meeting Room 10
Medical Technology - HS
Fashion Design and Technology Semifinals Presentation/ Interviews April 11 2:30 p.m. - 4:30 p.m. Meeting Room 17
Prepared Speech - HS
Extemporaneous Speech Semifinals Presentation Room April 11 1 p.m. - 2:30 p.m. Meeting Room 8
Prepared Presentation Semifinals Presentation Room April 11 3 p.m. - 5 p.m. Meeting Room 8
Prepared Speech - MS
Semifinals Presentation Room April 11 1:30 p.m. - 3 p.m. Meeting Room 10
Promotional Marketing - HS
Promotional Design Semifinals Challenge April 11 11:30 a.m. - 2:30 p.m. Meeting Room 6
Promotional Marketing - MS
Semifinals Challenge April 11 9:30 a.m. - 11 a.m. Meeting Room 6
STEM Animation - MS
Semifinals Interviews April 11 9:30 a.m. - 11 a.m. Meeting Room 16
Tech Bowl - MS
HS Technology Bowl Semifinals Bracket Play April 11 9 a.m. - 5 p.m. Banquet Rooms G
HS Technology Bowl Semifinals Holding Room April 11 9 a.m. - 5 p.m. Banquet Rooms H
Video Game Design - HS
Semifinals Interviews April 11 12 p.m. - 2 p.m. Meeting Room 17
Website Design - HS
Webmaster Semifinals Interviews April 11 9:30 a.m. - 11:30 a.m. Meeting Room 17
General Schedule
Voting Delegate Meeting April 11 8 a.m. - 9 a.m. Banquet Room F
Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
Tennessee TSA Store April 11 9 a.m. - 4 p.m. Meeting Room 1
HS Forensic Science CSI April 11 9:30 a.m. - 2:30 p.m. Meeting Room 4
MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
TECHSPO April 11 10 a.m. - 4 p.m. Main Hallway
MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
General Session 2: Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
Senior Social April 11 8:30 p.m. - 9:30 p.m. Banquet Room F
Chapter Officer Meeting April 11 8:30 p.m. - 9 p.m. Banquet Rooms G
CURFEW April 11 11 p.m. - 12:30 a.m.
# SUNDAY 4/12
General Schedule
General Session 3: Awards Ceremony April 12 8:30 a.m. - 12 p.m. Exhibit Hall A
New SOT Pictures April 12 12 p.m. - 12:30 p.m. Exhibit Hall A
@@ -1,23 +0,0 @@
{
"year": 2026,
"defaultSectionHeader": "General Schedule",
"sheets": [
{
"title": "Wednesday",
"month": "April",
"day": 2,
"sectionHeader": null
},
{
"title": "Thursday",
"month": "April",
"day": 3
},
{
"title": "Friday",
"month": "April",
"day": 4,
"sectionHeader": "General Schedule"
}
]
}
-27
View File
@@ -1,27 +0,0 @@
{
"year": 2026,
"eventDefinitionsCsv": "../../Tests/Parsers/TestInput/2024 Event Definitions.csv",
"defaultSectionHeader": "General Schedule",
"sheets": [
{
"title": "THURSDAY 4/9",
"month": "April",
"day": 9
},
{
"title": "FRIDAY 4/10",
"month": "April",
"day": 10
},
{
"title": "SATURDAY 4/11",
"month": "April",
"day": 11
},
{
"title": "SUNDAY 4/12",
"month": "April",
"day": 12
}
]
}
@@ -1,82 +0,0 @@
using System.Text;
using Core.Entities;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Loads minimal <see cref="EventDefinition"/> rows from Tests-style CSV (column "Event").
/// </summary>
public static class EventDefinitionsCsvLoader
{
public static List<EventDefinition> Load(string path)
{
var list = new List<EventDefinition>();
var lines = File.ReadAllLines(path);
if (lines.Length < 2)
return list;
var header = ParseCsvLine(lines[0]);
var eventIdx = header.FindIndex(h => h.Equals("Event", StringComparison.OrdinalIgnoreCase));
if (eventIdx < 0)
throw new InvalidOperationException($"CSV '{path}' must contain an 'Event' column header.");
for (var i = 1; i < lines.Length; i++)
{
if (string.IsNullOrWhiteSpace(lines[i]))
continue;
var cols = ParseCsvLine(lines[i]);
if (eventIdx >= cols.Count)
continue;
var name = cols[eventIdx].Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
list.Add(new EventDefinition
{
Id = i,
Name = name,
ShortName = name,
Eligibility = "",
EventFormat = EventFormat.Team
});
}
return list;
}
private static List<string> ParseCsvLine(string line)
{
var result = new List<string>();
var cur = new StringBuilder();
var inQuotes = false;
for (var i = 0; i < line.Length; i++)
{
var ch = line[i];
if (inQuotes)
{
if (ch == '"')
{
if (i + 1 < line.Length && line[i + 1] == '"')
{
cur.Append('"');
i++;
}
else inQuotes = false;
}
else cur.Append(ch);
}
else
{
if (ch == '"') inQuotes = true;
else if (ch == ',')
{
result.Add(cur.ToString());
cur.Clear();
}
else cur.Append(ch);
}
}
result.Add(cur.ToString());
return result;
}
}
@@ -1,54 +0,0 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Collapses duplicate site-wide rows (same event name, date, time) that appear in every location column
/// (e.g. CURFEW shaded across all rooms) into a single line with no location — still under
/// <c>General Schedule</c>, which the parser maps to <see cref="Core.Entities.EventDefinition.GeneralSchedule"/>.
/// </summary>
public static class GlobalEventDeduplicator
{
private static readonly HashSet<string> BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase)
{
"CURFEW"
};
/// <summary>
/// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific.
/// </summary>
public static List<ParsedOccurrenceLine> Deduplicate(
IReadOnlyList<ParsedOccurrenceLine> lines,
IReadOnlyCollection<string>? extraSiteWideNames = null)
{
var siteWide = new HashSet<string>(BuiltinSiteWideNames, StringComparer.OrdinalIgnoreCase);
if (extraSiteWideNames != null)
{
foreach (var n in extraSiteWideNames)
{
if (!string.IsNullOrWhiteSpace(n))
siteWide.Add(n.Trim());
}
}
var seen = new HashSet<(string Name, string Month, int Day, string Time)>();
var ordered = lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol).ToList();
var result = new List<ParsedOccurrenceLine>(ordered.Count);
foreach (var line in ordered)
{
var name = TextNormalization.ForEmitLine(line.Name);
if (string.IsNullOrEmpty(name) || !siteWide.Contains(name))
{
result.Add(line);
continue;
}
var key = (name, line.Month, line.Day, line.TimeRange);
if (!seen.Add(key))
continue;
result.Add(line with { Location = string.Empty, Name = name });
}
return result;
}
}
@@ -1,143 +0,0 @@
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using Color = Google.Apis.Sheets.v4.Data.Color;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Fetches raw grid data via Sheets API (public API key).
/// </summary>
public sealed class GoogleSheetGridReader
{
private readonly SheetsService _service;
public GoogleSheetGridReader(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new ArgumentException("API key is required for Google Sheets access.", nameof(apiKey));
_service = new SheetsService(new Google.Apis.Services.BaseClientService.Initializer
{
ApiKey = apiKey,
ApplicationName = "TSA GoogleSheetsScheduleImport"
});
}
public Spreadsheet FetchSpreadsheet(string spreadsheetId)
{
var req = _service.Spreadsheets.Get(spreadsheetId);
req.IncludeGridData = true;
return req.Execute();
}
public static GridSheetModel BuildModel(Sheet sheet)
{
var title = sheet.Properties?.Title ?? "(untitled)";
var grid = sheet.Data?.FirstOrDefault();
if (grid?.RowData == null || grid.RowData.Count == 0)
{
return new GridSheetModel
{
SheetTitle = title,
Values = Array.Empty<string?[]>(),
BackgroundKeys = Array.Empty<string?[]>()
};
}
var rowCount = grid.RowData.Count;
var colCount = grid.RowData.Max(r => r.Values?.Count ?? 0);
var values = new string?[rowCount][];
var bg = new string?[rowCount][];
for (var r = 0; r < rowCount; r++)
{
values[r] = new string?[colCount];
bg[r] = new string?[colCount];
var row = grid.RowData[r];
for (var c = 0; c < colCount; c++)
{
string? text = null;
string? hex = null;
if (row.Values != null && c < row.Values.Count)
{
var cell = row.Values[c];
text = string.IsNullOrWhiteSpace(cell.FormattedValue)
? cell.EffectiveValue?.StringValue
: cell.FormattedValue;
if (!string.IsNullOrEmpty(text))
text = TextNormalization.ForSheetCell(text);
var color = cell.UserEnteredFormat?.BackgroundColor
?? cell.EffectiveFormat?.BackgroundColor;
hex = ColorToHexKey(color);
}
values[r][c] = string.IsNullOrWhiteSpace(text) ? null : text;
bg[r][c] = hex;
}
}
ApplyMerges(sheet.Merges, values, bg);
NormalizeAllValueCells(values);
return new GridSheetModel
{
SheetTitle = title,
Values = values,
BackgroundKeys = bg
};
}
private static void ApplyMerges(IList<GridRange>? merges, string?[][] values, string?[][] bg)
{
if (merges == null || merges.Count == 0)
return;
foreach (var range in merges)
{
var r0 = range.StartRowIndex ?? 0;
var r1 = range.EndRowIndex ?? r0;
var c0 = range.StartColumnIndex ?? 0;
var c1 = range.EndColumnIndex ?? c0;
if (r1 <= r0 || c1 <= c0)
continue;
var anchorText = values[r0][c0];
var anchorBg = bg[r0][c0];
for (var r = r0; r < r1; r++)
{
for (var c = c0; c < c1; c++)
{
if (values[r][c] == null && anchorText != null)
values[r][c] = anchorText;
if (bg[r][c] == null && anchorBg != null)
bg[r][c] = anchorBg;
}
}
}
}
/// <summary>Re-run after merges so copied anchor text is also single-line.</summary>
private static void NormalizeAllValueCells(string?[][] values)
{
for (var r = 0; r < values.Length; r++)
for (var c = 0; c < values[r].Length; c++)
{
if (values[r][c] is { } v && !string.IsNullOrWhiteSpace(v))
values[r][c] = TextNormalization.ForEmitLine(v);
}
}
private static string? ColorToHexKey(Color? color)
{
if (color == null)
return null;
var r = color.Red ?? 1f;
var g = color.Green ?? 1f;
var b = color.Blue ?? 1f;
// Treat near-white as no color key for grouping
if (r >= 0.99f && g >= 0.99f && b >= 0.99f)
return null;
static byte F(float x) => (byte)(Math.Clamp(x, 0f, 1f) * 255f);
return $"#{F(r):X2}{F(g):X2}{F(b):X2}";
}
}
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GoogleSheetsScheduleImport</AssemblyName>
<RootNamespace>GoogleSheetsScheduleImport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.70.0.3806" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
</Project>
@@ -1,28 +0,0 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored).
/// </summary>
public sealed class GridSheetModel
{
public required string SheetTitle { get; init; }
/// <summary>display values, sanitized hyphens; [row][col]</summary>
public required string?[][] Values { get; init; }
/// <summary>Optional RGB hex backgrounds for block grouping (#RRGGBB or null)</summary>
public required string?[][]? BackgroundKeys { get; init; }
public int RowCount => Values.Length;
public int ColCount => Values.Length == 0 ? 0 : Values[0].Length;
}
public readonly record struct ParsedOccurrenceLine(
string Name,
string Month,
int Day,
string TimeRange,
string Location,
int SourceRowStart,
int SourceRowEnd,
int SourceCol);
@@ -1,91 +0,0 @@
using System.Text;
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Emits PDF-style section headers <c>Event Name - MS</c> / <c>Event Name - HS</c> before occurrence lines,
/// matching competition schedule imports.
/// </summary>
public static class GroupedImportTextEmitter
{
private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable<SectionKey>
{
public int EventDefinitionId { get; } = eventDefinitionId;
public SchoolLevel Level { get; } = level;
public bool Equals(SectionKey other) =>
EventDefinitionId == other.EventDefinitionId && Level == other.Level;
public override bool Equals(object? obj) => obj is SectionKey other && Equals(other);
public override int GetHashCode() => HashCode.Combine(EventDefinitionId, Level);
}
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets,
IReadOnlyList<EventDefinition> matchableEvents,
int year)
{
var idToDef = matchableEvents.Where(e => e.Id != 0).ToDictionary(e => e.Id);
var sb = new StringBuilder();
foreach (var (sheetTitle, _, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
var general = new List<ParsedOccurrenceLine>();
var bySection = new Dictionary<SectionKey, List<ParsedOccurrenceLine>>();
foreach (var line in lines)
{
if (!OccurrenceEventMatcher.TryMatch(line.Name, matchableEvents, out var evt, out var lvl)
|| evt == null
|| !lvl.HasValue)
{
general.Add(line);
continue;
}
var key = new SectionKey(evt.Id, lvl.Value);
if (!bySection.TryGetValue(key, out var list))
{
list = [];
bySection[key] = list;
}
list.Add(line);
}
foreach (var key in bySection.Keys.OrderBy(k => HeaderSortKey(idToDef, k), StringComparer.OrdinalIgnoreCase))
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
continue;
sb.AppendLine($"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}");
foreach (var line in OccurrenceChronologicalSort.Sort(bySection[key], year))
{
var displayName = OccurrenceDisplayNameReducer.ReduceForSection(line.Name, def, key.Level);
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line with { Name = displayName }));
}
}
if (general.Count > 0)
{
sb.AppendLine("General Schedule");
foreach (var line in OccurrenceChronologicalSort.Sort(general, year))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private static string HeaderSortKey(Dictionary<int, EventDefinition> idToDef, SectionKey key)
{
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
return $"{key.EventDefinitionId} - {key.Level}";
return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}";
}
}
@@ -1,13 +0,0 @@
namespace GoogleSheetsScheduleImport;
public static class ImportLineFormatter
{
public static string FormatOccurrenceLine(ParsedOccurrenceLine line)
{
var name = TextNormalization.ForEmitLine(line.Name);
var time = TextNormalization.ForEmitLine(line.TimeRange);
var loc = TextNormalization.ForEmitLine(line.Location);
var tail = string.IsNullOrEmpty(loc) ? string.Empty : $" {loc}";
return $"{name} {line.Month} {line.Day} {time}{tail}";
}
}
@@ -1,26 +0,0 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
public static class ImportTextEmitter
{
/// <summary>
/// Builds text compatible with <see cref="Core.Parsers.EventOccurrenceParser"/> / Import.razor paste target.
/// </summary>
public static string Build(
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets)
{
var sb = new StringBuilder();
foreach (var (sheetTitle, sectionHeader, lines) in sheets)
{
sb.AppendLine($"# {sheetTitle}");
sb.AppendLine(sectionHeader);
foreach (var line in lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol))
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
}
@@ -1,61 +0,0 @@
using System.Text.Json.Serialization;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// JSON config: sheet tab titles mapped to calendar days plus optional defaults.
/// </summary>
public sealed class MappingConfig
{
/// <summary>Calendar year for occurrence dates (overridden by CLI --year).</summary>
public int? Year { get; set; }
/// <summary>
/// Emitted before occurrence lines for each sheet (unless overridden per sheet).
/// Use "General Schedule" when grid cells are generic schedule items.
/// </summary>
public string? DefaultSectionHeader { get; set; }
/// <summary>Per-sheet overrides and day mapping.</summary>
public List<SheetDayMapping>? Sheets { get; set; }
/// <summary>
/// Event titles (cell text) to treat as site-wide: one output line per date+time, no location.
/// If omitted, built-in rules still include CURFEW.
/// </summary>
public List<string>? SiteWideEventNames { get; set; }
/// <summary>
/// Path to event definitions CSV (column <c>Event</c>), relative to this mapping file or absolute.
/// When set (or when <c>--events-csv</c> is passed), output is grouped into <c>Event - MS/HS</c> sections when possible.
/// </summary>
public string? EventDefinitionsCsv { get; set; }
}
public sealed class SheetDayMapping
{
/// <summary>Exact tab title as it appears in Google Sheets.</summary>
public string Title { get; set; } = "";
/// <summary>Month name matching EventOccurrenceGrammar (e.g. "April").</summary>
public string Month { get; set; } = "";
/// <summary>Day of month (1-31).</summary>
public int Day { get; set; }
/// <summary>Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS").</summary>
public string? SectionHeader { get; set; }
[JsonIgnore]
public string? NormalizedMonth => string.IsNullOrWhiteSpace(Month) ? null : Month.Trim();
public void Validate()
{
if (string.IsNullOrWhiteSpace(Title))
throw new InvalidOperationException("Mapping entry must include a non-empty Title (sheet tab name).");
if (string.IsNullOrWhiteSpace(Month))
throw new InvalidOperationException($"Sheet '{Title}': Month is required.");
if (Day is < 1 or > 31)
throw new InvalidOperationException($"Sheet '{Title}': Day must be between 1 and 31.");
}
}
@@ -1,31 +0,0 @@
using Core.Parsers.EventOccurrence;
using Core.Utility;
namespace GoogleSheetsScheduleImport;
public static class OccurrenceChronologicalSort
{
public static List<ParsedOccurrenceLine> Sort(IEnumerable<ParsedOccurrenceLine> lines, int year)
{
return lines
.OrderBy(l => Key(l, year))
.ThenBy(l => l.SourceRowStart)
.ThenBy(l => l.SourceCol)
.ToList();
}
private static DateTime Key(ParsedOccurrenceLine line, int year)
{
try
{
var d = TextUtil.ParseDate(line.Month, line.Day.ToString(), year);
var timePart = TimeParser.ExtractStartTime(line.TimeRange);
var t = TimeParser.Parse(timePart);
return new DateTime(d, t);
}
catch
{
return DateTime.MaxValue;
}
}
}
@@ -1,50 +0,0 @@
using Core.Entities;
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not
/// <c>MS EventName Activity</c> when the section is already <c>EventName - MS</c>.
/// </summary>
public static class OccurrenceDisplayNameReducer
{
public static string ReduceForSection(string occurrenceName, EventDefinition matched, SchoolLevel level)
{
var (remainder, lvl) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(occurrenceName);
if (!lvl.HasValue || lvl.Value != level)
return TextNormalization.ForEmitLine(occurrenceName);
var r = remainder.Trim();
var eventName = matched.Name.Trim();
if (r.StartsWith(eventName, StringComparison.OrdinalIgnoreCase))
{
r = r[eventName.Length..].TrimStart();
r = TrimLeadingJoiners(r);
}
r = TextNormalization.ForEmitLine(r);
if (string.IsNullOrWhiteSpace(r))
return TextNormalization.ForEmitLine(remainder.Trim());
return r;
}
private static string TrimLeadingJoiners(string s)
{
var r = s;
while (r.Length > 0)
{
var c = r[0];
if (c is '/' or '-' or ':' or '&' or '–' or '—' or ',' or '.')
{
r = r[1..].TrimStart();
continue;
}
break;
}
return r;
}
}
@@ -1,71 +0,0 @@
using Core.Entities;
using Core.Models;
using FuzzySharp;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers.
/// </summary>
public static class OccurrenceEventMatcher
{
private const int MinTokenScore = 62;
public static bool TryMatch(
string occurrenceName,
IReadOnlyList<EventDefinition> events,
out EventDefinition? matched,
out SchoolLevel? level)
{
matched = null;
level = null;
if (string.IsNullOrWhiteSpace(occurrenceName) || events.Count == 0)
return false;
var normalized = TextNormalization.ForEmitLine(occurrenceName);
var (remainder, prefixLevel) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(normalized);
// Section headers require "Event Name - MS|HS". Titles without a clear school prefix stay in General Schedule.
if (!prefixLevel.HasValue)
return false;
var best = FindBest(remainder, events);
if (best == null || string.IsNullOrWhiteSpace(remainder))
return false;
matched = best;
level = prefixLevel;
return true;
}
private static EventDefinition? FindBest(string text, IReadOnlyList<EventDefinition> events)
{
if (string.IsNullOrWhiteSpace(text))
return null;
EventDefinition? best = null;
var bestScore = 0;
foreach (var e in events)
{
if (string.IsNullOrWhiteSpace(e.Name))
continue;
var score = Math.Max(
Fuzz.TokenSetRatio(text, e.Name),
Fuzz.PartialRatio(text, e.Name));
if (score > bestScore)
{
bestScore = score;
best = e;
}
else if (score == bestScore && best != null && e.Name.Length > best.Name.Length)
best = e;
}
if (best == null || bestScore < MinTokenScore)
return null;
return best;
}
}
@@ -1,14 +0,0 @@
using Core.Entities;
using Core.Models;
using Core.Services;
namespace GoogleSheetsScheduleImport;
public static class ParserRoundTripValidator
{
public static EventOccurrenceParseResult Validate(string text, ICollection<EventDefinition> events)
{
var parser = new EventOccurrenceParserService(null);
return parser.ParseFromText(text, events);
}
}
-242
View File
@@ -1,242 +0,0 @@
using System.Linq;
using System.Text.Json;
using Core.Entities;
using Core.Parsers;
using Google.Apis.Sheets.v4.Data;
using GoogleSheetsScheduleImport;
static void PrintUsage()
{
Console.Error.WriteLine("""
Google Sheets -> Event occurrence import text (for /calendar/event-occurrences/import)
Required:
--sheet-url <url-or-id> Google Sheet URL or raw spreadsheet id
--mapping <path.json> Tab titles -> month/day (+ optional section headers)
--output <path.txt> Output file path
Optional:
--year <yyyy> Calendar year (overrides mapping file)
--api-key <key> Google API key (else env GOOGLE_SHEETS_API_KEY)
--tabs <a,b> Only process these tab titles (exact match)
--events-csv <path> Event definitions CSV with 'Event' column (grouping + validation)
--no-group-by-event Keep a single General Schedule block per sheet (no MS/HS sections)
--strict Exit code 1 if parser reports errors or zero occurrences
Environment:
GOOGLE_SHEETS_API_KEY Default API key for Sheets API (spreadsheet must be accessible to the key)
""");
}
try
{
var argsDict = ParseArgs(args);
if (argsDict.ContainsKey("help") || argsDict.ContainsKey("h"))
{
PrintUsage();
return 0;
}
var sheetUrl = GetRequired(argsDict, "sheet-url");
var mappingPath = GetRequired(argsDict, "mapping");
var outputPath = GetRequired(argsDict, "output");
var mappingJson = await File.ReadAllTextAsync(mappingPath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip };
var mapping = JsonSerializer.Deserialize<MappingConfig>(mappingJson, options)
?? throw new InvalidOperationException("Mapping file is empty or invalid JSON.");
var year = int.TryParse(argsDict.GetValueOrDefault("year"), out var y) ? y
: mapping.Year ?? DateTime.Now.Year;
var apiKey = argsDict.GetValueOrDefault("api-key")
?? Environment.GetEnvironmentVariable("GOOGLE_SHEETS_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Missing API key: pass --api-key or set GOOGLE_SHEETS_API_KEY.");
return 2;
}
HashSet<string>? tabFilter = null;
if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg))
{
tabFilter = new HashSet<string>(
tabsArg.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
StringComparer.Ordinal);
}
var spreadsheetId = SpreadsheetId.FromUrlOrId(sheetUrl);
var reader = new GoogleSheetGridReader(apiKey);
var spreadsheet = reader.FetchSpreadsheet(spreadsheetId);
var sheetMappings = mapping.Sheets ?? [];
if (sheetMappings.Count == 0)
throw new InvalidOperationException("Mapping file must include a non-empty \"sheets\" array with tab titles and dates.");
foreach (var sm in sheetMappings)
sm.Validate();
var defaultSection = mapping.DefaultSectionHeader?.Trim();
if (string.IsNullOrEmpty(defaultSection))
defaultSection = "General Schedule";
var mappingDir = Path.GetDirectoryName(Path.GetFullPath(mappingPath)) ?? Directory.GetCurrentDirectory();
var eventsCsvArg = argsDict.GetValueOrDefault("events-csv");
var eventsCsvConfigured = mapping.EventDefinitionsCsv;
var eventsCsvPath = !string.IsNullOrWhiteSpace(eventsCsvArg)
? (Path.IsPathRooted(eventsCsvArg) ? eventsCsvArg : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), eventsCsvArg)))
: (!string.IsNullOrWhiteSpace(eventsCsvConfigured)
? Path.GetFullPath(Path.Combine(mappingDir, eventsCsvConfigured!))
: null);
List<EventDefinition> csvEvents = new();
if (!string.IsNullOrWhiteSpace(eventsCsvPath) && File.Exists(eventsCsvPath))
csvEvents = EventDefinitionsCsvLoader.Load(eventsCsvPath);
else if (!string.IsNullOrWhiteSpace(eventsCsvPath))
throw new InvalidOperationException($"Event definitions CSV not found: {eventsCsvPath}");
var groupByEvent = csvEvents.Count > 0 && !argsDict.ContainsKey("no-group-by-event");
List<EventDefinition> validationEvents = MergeValidationEvents(csvEvents);
var warnings = new List<string>();
var sheetOutputs = new List<(string Title, string SectionHeader, List<ParsedOccurrenceLine> Lines)>();
foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty<Sheet>())
{
var title = sheet.Properties?.Title ?? "";
if (tabFilter != null && !tabFilter.Contains(title))
continue;
var dayMap = sheetMappings.FirstOrDefault(m =>
m.Title.Equals(title, StringComparison.Ordinal));
if (dayMap == null)
{
warnings.Add($"Skipping sheet '{title}': no entry in mapping JSON.");
continue;
}
var month = dayMap.NormalizedMonth!;
if (!EventOccurrenceGrammar.MonthNames.Any(m => m.Equals(month, StringComparison.OrdinalIgnoreCase)))
warnings.Add($"Sheet '{title}': month '{month}' is not a standard grammar month name.");
try
{
Core.Utility.TextUtil.ParseDate(month, dayMap.Day.ToString(), year);
}
catch (Exception ex)
{
warnings.Add($"Sheet '{title}': invalid date {month} {dayMap.Day}, {year}: {ex.Message}");
}
var grid = GoogleSheetGridReader.BuildModel(sheet);
var lines = ScheduleGridExtractor.Extract(grid, month, dayMap.Day, warnings);
lines = GlobalEventDeduplicator.Deduplicate(lines, mapping.SiteWideEventNames);
var section = string.IsNullOrWhiteSpace(dayMap.SectionHeader) ? defaultSection : dayMap.SectionHeader!.Trim();
sheetOutputs.Add((title, section, lines));
}
foreach (var sm in sheetMappings)
{
var exists = spreadsheet.Sheets?.Any(s => string.Equals(s.Properties?.Title, sm.Title, StringComparison.Ordinal)) ?? false;
if (!exists)
warnings.Add($"Mapping references sheet '{sm.Title}' but it was not found in the spreadsheet.");
}
var text = groupByEvent
? GroupedImportTextEmitter.Build(sheetOutputs, csvEvents, year)
: ImportTextEmitter.Build(sheetOutputs);
var outDir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(outDir))
Directory.CreateDirectory(outDir);
await File.WriteAllTextAsync(outputPath, text, System.Text.Encoding.UTF8);
Console.WriteLine($"Wrote {outputPath} ({text.Length} characters).");
foreach (var w in warnings)
Console.WriteLine($"WARNING: {w}");
var strict = argsDict.ContainsKey("strict");
var parseResult = ParserRoundTripValidator.Validate(text, validationEvents);
Console.WriteLine($"Parser round-trip: success={parseResult.IsSuccess}, occurrences={parseResult.TotalParsed}, issues={parseResult.Issues.Count}, errors={parseResult.Errors.Count}");
foreach (var err in parseResult.Errors)
Console.WriteLine($" ERROR: {err}");
foreach (var issue in parseResult.Issues.Take(20))
Console.WriteLine($" Issue L{issue.LineNumber}: {issue.Message}");
if (parseResult.Issues.Count > 20)
Console.WriteLine($" ... and {parseResult.Issues.Count - 20} more issues.");
if (strict && (!parseResult.IsSuccess || parseResult.TotalParsed == 0))
{
Console.Error.WriteLine("Strict mode: failing due to parser errors or zero occurrences parsed.");
return 1;
}
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
Console.Error.WriteLine(ex.ToString());
return 1;
}
static Dictionary<string, string> ParseArgs(string[] args)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var a = args[i];
if (!a.StartsWith("--", StringComparison.Ordinal))
continue;
var key = a[2..];
if (string.IsNullOrEmpty(key))
continue;
if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
{
d[key] = args[i + 1];
i++;
}
else
d[key] = "true";
}
return d;
}
static string GetRequired(Dictionary<string, string> d, string key)
{
if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v))
throw new InvalidOperationException($"Missing required --{key}");
return v;
}
static List<EventDefinition> MergeValidationEvents(List<EventDefinition> fromCsv)
{
if (fromCsv.Count == 0)
{
return
[
EventDefinition.GeneralSchedule,
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
];
}
var list = new List<EventDefinition>(fromCsv);
foreach (var extra in new EventDefinition[]
{
EventDefinition.MeetTheCandidates,
EventDefinition.ChapterOfficerMeeting,
EventDefinition.VotingDelegateMeeting,
EventDefinition.SocialGathering
})
{
if (list.All(e => !string.Equals(e.Name, extra.Name, StringComparison.OrdinalIgnoreCase)))
list.Add(extra);
}
return list;
}
@@ -1,149 +0,0 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Interprets location headers + time rows + colored/value blocks as occurrence lines.
/// </summary>
public static class ScheduleGridExtractor
{
public static List<ParsedOccurrenceLine> Extract(
GridSheetModel grid,
string month,
int day,
List<string> warnings)
{
var result = new List<ParsedOccurrenceLine>();
if (grid.RowCount < 2 || grid.ColCount < 2)
{
warnings.Add($"Sheet '{grid.SheetTitle}': grid too small; need at least a header row and time column.");
return result;
}
var locations = new string[grid.ColCount];
for (var c = 1; c < grid.ColCount; c++)
{
var header = grid.Values[0][c];
locations[c] = string.IsNullOrWhiteSpace(header) ? $"Column {c + 1}" : header.Trim();
}
var rowTimes = new TimeOnly?[grid.RowCount];
rowTimes[0] = null;
for (var r = 1; r < grid.RowCount; r++)
{
var cell = grid.Values[r][0];
if (!TimeCellParser.TryParse(cell, out var t))
{
if (!string.IsNullOrWhiteSpace(cell))
warnings.Add($"Sheet '{grid.SheetTitle}' row {r + 1}: could not parse time label '{cell}'.");
rowTimes[r] = null;
}
else
rowTimes[r] = t;
}
var slot = InferSlotDuration(rowTimes, warnings, grid.SheetTitle);
for (var c = 1; c < grid.ColCount; c++)
{
var location = locations[c];
var r = 1;
while (r < grid.RowCount)
{
if (!IsOccupied(grid, r, c))
{
r++;
continue;
}
var startRow = r;
var name = grid.Values[r][c] ?? "";
var key = BlockKey(grid, r, c);
var endRow = r;
while (endRow + 1 < grid.RowCount &&
IsOccupied(grid, endRow + 1, c) &&
BlockKey(grid, endRow + 1, c) == key)
{
endRow++;
}
var startTime = rowTimes[startRow];
if (startTime == null)
{
warnings.Add(
$"Sheet '{grid.SheetTitle}' ({location}): block at row {startRow + 1} has no parseable start time in column A.");
r = endRow + 1;
continue;
}
var endInstant = ComputeEndTime(rowTimes, endRow, grid.RowCount, slot);
var timeRange = TimeFormatter.ToParserTimeRange(startTime.Value, endInstant);
var title = name.Trim();
if (string.IsNullOrEmpty(title))
title = "(no title)";
result.Add(new ParsedOccurrenceLine(
Name: title,
Month: month,
Day: day,
TimeRange: timeRange,
Location: location,
SourceRowStart: startRow,
SourceRowEnd: endRow,
SourceCol: c));
r = endRow + 1;
}
}
return result;
}
private static bool IsOccupied(GridSheetModel grid, int r, int c)
{
var v = grid.Values[r][c];
var bg = grid.BackgroundKeys?[r][c];
if (!string.IsNullOrWhiteSpace(v))
return true;
return bg != null; // colored empty cell
}
private static (string NameKey, string? Bg) BlockKey(GridSheetModel grid, int r, int c)
{
var raw = grid.Values[r][c] ?? "";
var v = raw.Trim();
var bg = grid.BackgroundKeys?[r][c];
return (v, bg);
}
private static TimeOnly ComputeEndTime(TimeOnly?[] rowTimes, int endRow, int rowCount, TimeSpan slot)
{
if (endRow + 1 < rowCount && rowTimes[endRow + 1] != null)
return rowTimes[endRow + 1]!.Value;
var lastStart = rowTimes[endRow] ?? throw new InvalidOperationException();
return lastStart.Add(slot);
}
private static TimeSpan InferSlotDuration(TimeOnly?[] rowTimes, List<string> warnings, string sheetTitle)
{
var deltas = new List<int>();
for (var i = 1; i < rowTimes.Length - 1; i++)
{
if (rowTimes[i] == null || rowTimes[i + 1] == null)
continue;
var minutes = (int)(rowTimes[i + 1]!.Value - rowTimes[i]!.Value).TotalMinutes;
if (minutes > 0 && minutes <= 24 * 60)
deltas.Add(minutes);
}
if (deltas.Count == 0)
{
warnings.Add($"Sheet '{sheetTitle}': could not infer time-slot length from column A; defaulting to 30 minutes.");
return TimeSpan.FromMinutes(30);
}
var g = deltas.GroupBy(d => d).OrderByDescending(g => g.Count()).First();
return TimeSpan.FromMinutes(g.Key);
}
}
@@ -1,40 +0,0 @@
using Core.Models;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to <see cref="Core.Entities.EventDefinition.Name"/>.
/// </summary>
public static class SchoolLevelPrefixParser
{
/// <returns>Remainder text and school level when unambiguous; <c>null</c> level for MS/HS combined or unknown.</returns>
public static (string Remainder, SchoolLevel? Level) StripLeadingSchoolPrefix(string raw)
{
var s = TextNormalization.ForEmitLine(raw);
if (string.IsNullOrEmpty(s))
return (s, null);
if (s.StartsWith("MS/HS", StringComparison.OrdinalIgnoreCase))
{
var rest = s[5..].TrimStart();
if (rest.StartsWith('/'))
rest = rest[1..].TrimStart();
return (rest, null);
}
if (s.Length >= 3 && s.StartsWith("MS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS ", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
if (s.Length >= 3 && s.StartsWith("MS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
if (s.Length >= 3 && s.StartsWith("HS/", StringComparison.OrdinalIgnoreCase))
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
return (s, null);
}
public static string ToSectionSuffix(SchoolLevel level) =>
level == SchoolLevel.MiddleSchool ? "MS" : "HS";
}
@@ -1,25 +0,0 @@
using System.Text.RegularExpressions;
namespace GoogleSheetsScheduleImport;
public static class SpreadsheetId
{
private static readonly Regex IdRegex = new(
@"/spreadsheets/d/([a-zA-Z0-9-_]+)",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id.
/// </summary>
public static string FromUrlOrId(string input)
{
var trimmed = input.Trim();
var m = IdRegex.Match(trimmed);
if (m.Success)
return m.Groups[1].Value;
if (trimmed.Length > 20 && !trimmed.Contains('/') && !trimmed.Contains(':'))
return trimmed;
throw new ArgumentException(
"Expected a Google Sheets URL (…/spreadsheets/d/{id}/…) or a raw spreadsheet id.", nameof(input));
}
}
@@ -1,44 +0,0 @@
using System.Text;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators.
/// Import text must be one logical line per occurrence.
/// </summary>
public static class TextNormalization
{
public static string ForSheetCell(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
public static string ForEmitLine(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
}
private static string CollapseWhitespace(string s)
{
var sb = new StringBuilder(s.Length);
var pendingSpace = false;
foreach (var ch in s)
{
if (char.IsWhiteSpace(ch))
pendingSpace = true;
else
{
if (pendingSpace && sb.Length > 0)
sb.Append(' ');
pendingSpace = false;
sb.Append(ch);
}
}
return sb.ToString().Trim();
}
}
@@ -1,53 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Core.Parsers.EventOccurrence;
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Parses time labels from the first column of schedule grids.
/// </summary>
public static class TimeCellParser
{
private static readonly Regex ClockRegex = new(
@"^(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ap>a\.?m\.?|p\.?m\.?|AM|PM|am|pm)\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static bool TryParse(string? cellText, out TimeOnly time)
{
time = default;
if (string.IsNullOrWhiteSpace(cellText))
return false;
var t = cellText.Trim();
if (t.Equals("NOON", StringComparison.OrdinalIgnoreCase))
{
time = new TimeOnly(12, 0);
return true;
}
var m = ClockRegex.Match(t);
if (m.Success)
{
var h = int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture);
var minute = m.Groups["m"].Success ? int.Parse(m.Groups["m"].Value, CultureInfo.InvariantCulture) : 0;
var apStr = m.Groups["ap"].Value;
var isPm = apStr.Contains('P', StringComparison.OrdinalIgnoreCase);
var isAm = apStr.Contains('A', StringComparison.OrdinalIgnoreCase);
if (isPm && h < 12) h += 12;
if (isAm && h == 12) h = 0;
time = new TimeOnly(h, minute);
return true;
}
// Fallback: use core TimeParser if string already looks like parsed format
try
{
time = TimeParser.Parse(t);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -1,26 +0,0 @@
namespace GoogleSheetsScheduleImport;
/// <summary>
/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns).
/// </summary>
public static class TimeFormatter
{
public static string ToParserTimeString(TimeOnly time)
{
var h12 = time.Hour % 12;
if (h12 == 0) h12 = 12;
var minute = time.Minute;
var isPm = time.Hour >= 12;
var ap = isPm ? "p.m." : "a.m.";
if (minute == 0)
return $"{h12} {ap}";
return $"{h12}:{minute:D2} {ap}";
}
public static string ToParserTimeRange(TimeOnly start, TimeOnly end)
{
if (start == end)
return ToParserTimeString(start);
return $"{ToParserTimeString(start)} - {ToParserTimeString(end)}";
}
}