Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f400813667 |
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -9,11 +9,11 @@ namespace Core.Models;
|
|||||||
public class EventOccurrenceParseResult
|
public class EventOccurrenceParseResult
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Dictionary of parsed event occurrences, keyed by EventDefinition.
|
/// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
|
||||||
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering),
|
/// Special events use <see cref="EventOccurrenceParseGroup.EventDefinition"/> static instances with
|
||||||
/// the EventDefinition key will be the static instance.
|
/// <see cref="EventOccurrenceParseGroup.SectionSchoolLevel"/> typically null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
public IDictionary<EventOccurrenceParseGroup, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<EventOccurrence>>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// List of parsing errors (critical issues that prevented parsing).
|
/// List of parsing errors (critical issues that prevented parsing).
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Core.Models;
|
using Core.Models;
|
||||||
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
||||||
@@ -12,7 +12,7 @@ namespace Core.Parsers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EventOccurrenceParserResult
|
public class EventOccurrenceParserResult
|
||||||
{
|
{
|
||||||
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
|
public IDictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>>();
|
||||||
public List<ParsingIssue> Issues { get; set; } = new();
|
public List<ParsingIssue> Issues { get; set; } = new();
|
||||||
public List<string> SkippedSectionHeaders { get; set; } = new();
|
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||||
public int SkippedEventCount { get; set; }
|
public int SkippedEventCount { get; set; }
|
||||||
@@ -296,12 +296,14 @@ public class EventOccurrenceParser
|
|||||||
Location = location
|
Location = location
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!occurrences.ContainsKey(eventDefinition))
|
var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
|
||||||
occurrences.Add(eventDefinition, []);
|
if (!occurrences.TryGetValue(groupKey, out var groupList))
|
||||||
occurrences[eventDefinition].Add(eventOccurrence);
|
{
|
||||||
|
groupList = [];
|
||||||
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
|
occurrences[groupKey] = groupList;
|
||||||
currentSectionLevel = null;
|
}
|
||||||
|
|
||||||
|
groupList.Add(eventOccurrence);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,241 +1,77 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Core.Models;
|
|
||||||
using FuzzySharp;
|
using FuzzySharp;
|
||||||
|
|
||||||
namespace Core.Parsers;
|
namespace Core.Parsers;
|
||||||
|
|
||||||
public class StudentEventRankingParser : CsvParserBase
|
public class StudentEventRankingParser : CsvParserBase
|
||||||
{
|
{
|
||||||
public const int StudentMatchThreshold = 90;
|
|
||||||
public const int EventMatchThreshold = 70;
|
|
||||||
public const int EventAmbiguityGap = 8;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Informal names that share too little text with the catalog for fuzzy matching.
|
|
||||||
/// Keyed by official <see cref="EventDefinition.Name"/>.
|
|
||||||
/// </summary>
|
|
||||||
private static readonly Dictionary<string, string[]> EventAliases = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["Junior Solar Sprint"] = ["Solar Racer", "Solar Race"],
|
|
||||||
["Challenging Technology Issues"] = ["Challenging Tech", "Challenging Technology"],
|
|
||||||
["Digital Photography"] = ["Digital Photo"],
|
|
||||||
["Forensic Technology"] = ["Forensics", "Forensic"],
|
|
||||||
["Microcontroller Design"] = ["Micro Controller", "Micro Controller Design"],
|
|
||||||
["Medical Technology"] = ["Med Tech"],
|
|
||||||
["Inventions & Innovations"] = ["Innovations & Inventions", "Inventions and Innovations"],
|
|
||||||
["System Control Technology"] = ["Systems Control Tech", "Systems Control Technology"],
|
|
||||||
["Structural Engineering"] = ["Structural Eng"],
|
|
||||||
["Drone Challenge (UAV)"] = ["Drone Challenge", "Drone"],
|
|
||||||
["Drone Challenge"] = ["Drone Challenge (UAV)", "Drone"],
|
|
||||||
["TSA Robotics"] = ["Robotics"],
|
|
||||||
["Audio Podcasting"] = ["Audio Podcast", "Podcasting"]
|
|
||||||
};
|
|
||||||
|
|
||||||
public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
|
public StudentEventRankingParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public StudentEventRankingParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines)
|
public StudentEventRanking[] Parse(ICollection<Student> students, ICollection<EventDefinition> events)
|
||||||
{
|
{
|
||||||
}
|
var rankings = new List<StudentEventRanking>();
|
||||||
|
|
||||||
public StudentEventRankingParseResult Parse(ICollection<Student> students, ICollection<EventDefinition> events)
|
|
||||||
{
|
|
||||||
var result = new StudentEventRankingParseResult();
|
|
||||||
|
|
||||||
CsvReader.Read();
|
CsvReader.Read();
|
||||||
CsvReader.ReadHeader();
|
CsvReader.ReadHeader();
|
||||||
|
|
||||||
if (CsvReader.HeaderRecord is null ||
|
while (CsvReader.Read())
|
||||||
!CsvReader.HeaderRecord.Contains("Student Name", StringComparer.OrdinalIgnoreCase))
|
{
|
||||||
{
|
var name = CsvReader.GetField("Student Name");
|
||||||
result.Errors.Add("CSV must include a 'Student Name' column.");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (CsvReader.Read())
|
|
||||||
{
|
|
||||||
var rowNumber = CsvReader.Context.Parser?.Row ?? 0;
|
|
||||||
var name = CsvReader.GetField("Student Name")?.Trim();
|
|
||||||
if (string.IsNullOrEmpty(name))
|
if (string.IsNullOrEmpty(name))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var studentMatch = FindStudent(students, name);
|
var student = students.FirstOrDefault(s => Fuzz.Ratio(s.FirstNameLastName, name) > 90);
|
||||||
if (studentMatch is null)
|
if (student == null)
|
||||||
{
|
|
||||||
result.Issues.Add(new StudentEventRankingIssue
|
|
||||||
{
|
|
||||||
RowNumber = rowNumber,
|
|
||||||
RawStudentName = name,
|
|
||||||
IssueType = StudentEventRankingIssueType.UnmatchedStudent,
|
|
||||||
Message = $"No student matched '{name}'."
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
var (student, studentScore) = studentMatch.Value;
|
var competitiveEvents = new List<EventDefinition>();
|
||||||
var acceptedEventsForStudent = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var acceptedRanksForStudent = new HashSet<int>();
|
|
||||||
|
|
||||||
for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++)
|
for (var i = 1; i <= 6; i++)
|
||||||
{
|
{
|
||||||
var eventName = CsvReader.GetField(rank.ToString())?.Trim();
|
var eventName = CsvReader.GetField(i.ToString());
|
||||||
if (string.IsNullOrEmpty(eventName))
|
if (string.IsNullOrEmpty(eventName) || eventName == "") continue;
|
||||||
continue;
|
|
||||||
|
|
||||||
var eventResolution = ResolveEvent(events, eventName);
|
eventName = eventName.Trim();
|
||||||
if (eventResolution.Status == EventMatchStatus.Unmatched)
|
|
||||||
|
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
|
||||||
RowNumber = rowNumber,
|
where e.Name.StartsWith(eventName)
|
||||||
Rank = rank,
|
select e).ToList();
|
||||||
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)
|
var competitiveEvent = matches.FirstOrDefault();
|
||||||
|
if (competitiveEvent == null)
|
||||||
{
|
{
|
||||||
result.Issues.Add(new StudentEventRankingIssue
|
|
||||||
{
|
//todo: throw new ArgumentException($"Event named '{eventName}' not found");
|
||||||
RowNumber = rowNumber,
|
continue;
|
||||||
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!;
|
rankings.Add(new StudentEventRanking{
|
||||||
if (!acceptedEventsForStudent.Add($"{matchedEvent.Id}:{matchedEvent.Name}"))
|
Student = student, EventDefinition = competitiveEvent,
|
||||||
{
|
Rank = i});
|
||||||
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)
|
return rankings.ToArray();
|
||||||
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,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
// Convert parsed occurrences to result format, handling special event types
|
// Convert parsed occurrences to result format, handling special event types
|
||||||
foreach (var kvp in parsedOccurrences)
|
foreach (var kvp in parsedOccurrences)
|
||||||
{
|
{
|
||||||
var eventDefinition = kvp.Key;
|
var group = kvp.Key;
|
||||||
|
var eventDefinition = group.EventDefinition;
|
||||||
var occurrences = kvp.Value;
|
var occurrences = kvp.Value;
|
||||||
|
|
||||||
// Check if this is a special event type (not stored in database)
|
// Check if this is a special event type (not stored in database)
|
||||||
@@ -90,8 +91,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to result with the special EventDefinition as key
|
result.Occurrences[group] = occurrences;
|
||||||
result.Occurrences[eventDefinition] = occurrences;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
occurrence.SpecialEventType = null;
|
occurrence.SpecialEventType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Occurrences[eventDefinition] = occurrences;
|
result.Occurrences[group] = occurrences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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}"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
+2
-28
@@ -172,31 +172,6 @@ docker-compose logs -f webapp
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Persistence (SQLite + Backups)
|
|
||||||
|
|
||||||
The app stores its SQLite database and runtime config under **`Data/`** (capital D) relative to the content root:
|
|
||||||
|
|
||||||
| Path in container | Purpose |
|
|
||||||
|-------------------|---------|
|
|
||||||
| `/app/Data/app.db` | Live SQLite database |
|
|
||||||
| `/app/Data/appsettings.json` | Chapter settings overrides (e.g. CompetitionYear) |
|
|
||||||
| `/app/Data/backups/pre-rollover-*.db` | Automatic backups created by New Year Rollover |
|
|
||||||
|
|
||||||
**Docker volume mount must use capital `Data`:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/Data
|
|
||||||
```
|
|
||||||
|
|
||||||
On Linux, `./data:/app/data` is a **different** path and will not persist the database or rollover backups. After a correct mount, host files appear under `./data/` (for example `./data/app.db` and `./data/backups/`).
|
|
||||||
|
|
||||||
Ensure the container user can write to the mounted directory (the image runs as a non-root `APP_UID`). If directory creation for `backups/` fails, year rollover aborts before changing data.
|
|
||||||
|
|
||||||
For the rollover workflow itself, see `docs/instructions/year-rollover.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing Users
|
## Managing Users
|
||||||
|
|
||||||
### Adding a New User
|
### Adding a New User
|
||||||
@@ -335,11 +310,10 @@ curl http://localhost:8080
|
|||||||
- [ ] Set file permissions to 600
|
- [ ] Set file permissions to 600
|
||||||
- [ ] Configured HTTPS/SSL certificates
|
- [ ] Configured HTTPS/SSL certificates
|
||||||
- [ ] Updated `ASPNETCORE_URLS` for production domain
|
- [ ] Updated `ASPNETCORE_URLS` for production domain
|
||||||
- [ ] Configured volume for database persistence as `./data:/app/Data` (capital D)
|
- [ ] Configured volume for database persistence
|
||||||
- [ ] Verified `app.db` appears on the host under `./data/` after first run
|
|
||||||
- [ ] Removed development endpoints (already done in code)
|
- [ ] Removed development endpoints (already done in code)
|
||||||
- [ ] Set up log monitoring
|
- [ ] Set up log monitoring
|
||||||
- [ ] Confirmed year-rollover backups write to `./data/backups/` (see `docs/instructions/year-rollover.md`)
|
- [ ] Configured automatic backups
|
||||||
- [ ] Tested login with all user roles
|
- [ ] Tested login with all user roles
|
||||||
- [ ] Tested rate limiting (5 failed attempts)
|
- [ ] Tested rate limiting (5 failed attempts)
|
||||||
- [ ] Documented admin password securely
|
- [ ] Documented admin password securely
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
|
||||||
ProjectSection(ProjectDependencies) = postProject
|
ProjectSection(ProjectDependencies) = postProject
|
||||||
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
|
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
|
||||||
@@ -35,6 +37,10 @@ Global
|
|||||||
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// Verify successful occurrence is still parsed (if any valid lines exist)
|
// Verify successful occurrence is still parsed (if any valid lines exist)
|
||||||
// The "Valid Event" line should parse successfully despite other issues
|
// The "Valid Event" line should parse successfully despite other issues
|
||||||
var validEvent = events.First(e => e.Name == "Valid Event");
|
var validEvent = events.First(e => e.Name == "Valid Event");
|
||||||
if (result.Occurrences.ContainsKey(validEvent))
|
var validGroup = new EventOccurrenceParseGroup(validEvent, null);
|
||||||
|
if (result.Occurrences.ContainsKey(validGroup))
|
||||||
{
|
{
|
||||||
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
|
Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
|
||||||
}
|
}
|
||||||
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
|
// 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
|
// but typically it should still parse since it's a valid line
|
||||||
@@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
|
|
||||||
// Verify occurrences were parsed correctly (if they were parsed)
|
// Verify occurrences were parsed correctly (if they were parsed)
|
||||||
var testEvent = events.First(e => e.Name == "Test Event");
|
var testEvent = events.First(e => e.Name == "Test Event");
|
||||||
if (result.Occurrences.ContainsKey(testEvent))
|
var testGroup = new EventOccurrenceParseGroup(testEvent, null);
|
||||||
|
if (result.Occurrences.ContainsKey(testGroup))
|
||||||
{
|
{
|
||||||
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
|
Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
|
||||||
|
|
||||||
var occurrence = result.Occurrences[testEvent].First();
|
var occurrence = result.Occurrences[testGroup].First();
|
||||||
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
|
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
|
||||||
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
|
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
|
||||||
}
|
}
|
||||||
@@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// The important thing is that the parser doesn't crash and processes the input
|
// 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)
|
// Verify locations are extracted correctly (pattern matching is no longer used)
|
||||||
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
|
var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
|
||||||
? result.Occurrences[testEvent].FirstOrDefault()
|
? result.Occurrences[testGroup].FirstOrDefault()
|
||||||
: null;
|
: null;
|
||||||
if (testEventOccurrence != null)
|
if (testEventOccurrence != null)
|
||||||
{
|
{
|
||||||
@@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
|
|
||||||
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
|
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
|
||||||
// General Schedule section uses EventDefinition.GeneralSchedule
|
// General Schedule section uses EventDefinition.GeneralSchedule
|
||||||
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
|
var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
|
||||||
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
|
Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
|
||||||
|
$"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
|
||||||
|
|
||||||
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
|
var occurrences = result.Occurrences[gsGroup];
|
||||||
Assert.That(occurrences, Has.Count.GreaterThan(0),
|
Assert.That(occurrences, Has.Count.GreaterThan(0),
|
||||||
"Should have at least one occurrence in General Schedule");
|
"Should have at least one occurrence in General Schedule");
|
||||||
|
|
||||||
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
|
|||||||
// Assert
|
// Assert
|
||||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes special events summary to console.
|
/// Writes special events summary to console.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"\n--- Special Events Found ---");
|
Console.WriteLine($"\n--- Special Events Found ---");
|
||||||
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
|
static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
|
||||||
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
|
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
|
||||||
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
|
|
||||||
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
|
var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
|
||||||
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
|
if (gs > 0)
|
||||||
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
|
Console.WriteLine($" GeneralSchedule: {gs} occurrences");
|
||||||
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
|
var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
|
||||||
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes special events to console output.
|
/// Writes special events to console output.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, 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");
|
Console.WriteLine("General Schedule");
|
||||||
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
|
foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
|
||||||
{
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
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");
|
Console.WriteLine("Meet the Candidates");
|
||||||
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
|
foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
|
||||||
{
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
foreach (var eo in meetTheCandidates)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Chapter Officer Meeting");
|
Console.WriteLine("Chapter Officer Meeting");
|
||||||
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
|
foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
|
||||||
{
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
foreach (var eo in chapterOfficerMeeting)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Voting Delegate Meeting");
|
Console.WriteLine("Voting Delegate Meeting");
|
||||||
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
|
foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
|
||||||
{
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
foreach (var eo in votingDelegateMeeting)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
|
|||||||
{
|
{
|
||||||
Console.WriteLine($"{@event.Name}");
|
Console.WriteLine($"{@event.Name}");
|
||||||
|
|
||||||
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
var eventOccurrences = dictionary
|
||||||
|
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
|
||||||
|
.SelectMany(kvp => kvp.Value)
|
||||||
|
.ToList();
|
||||||
|
if (eventOccurrences.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||||
continue;
|
continue;
|
||||||
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
|
|||||||
{
|
{
|
||||||
Console.WriteLine($"{@event.Name}");
|
Console.WriteLine($"{@event.Name}");
|
||||||
|
|
||||||
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
var eventOccurrences = dictionary
|
||||||
|
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
|
||||||
|
.SelectMany(kvp => kvp.Value)
|
||||||
|
.ToList();
|
||||||
|
if (eventOccurrences.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||||
continue;
|
continue;
|
||||||
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
|
|||||||
// Total expected MS occurrences: 16
|
// Total expected MS occurrences: 16
|
||||||
|
|
||||||
var msEventCount = 0;
|
var msEventCount = 0;
|
||||||
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
|
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
|
||||||
msEventCount += csOccurrences.Count;
|
msEventCount += csOccurrences.Count;
|
||||||
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
|
if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
|
||||||
msEventCount += codingOccurrences.Count;
|
msEventCount += codingOccurrences.Count;
|
||||||
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
|
if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
|
||||||
msEventCount += csvOccurrences.Count;
|
msEventCount += csvOccurrences.Count;
|
||||||
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
|
if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
|
||||||
msEventCount += ccOccurrences.Count;
|
msEventCount += ccOccurrences.Count;
|
||||||
|
|
||||||
// When no school level is set, HS events should be processed (not skipped)
|
// When no school level is set, HS events should be processed (not skipped)
|
||||||
@@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests
|
|||||||
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
|
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
|
||||||
|
|
||||||
// Verify specific locations are parsed
|
// Verify specific locations are parsed
|
||||||
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
|
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
|
||||||
{
|
{
|
||||||
var locations = childrensStoriesOccurrences
|
var locations = childrensStoriesOccurrences
|
||||||
.Select(eo => eo.Location)
|
.Select(eo => eo.Location)
|
||||||
@@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests
|
|||||||
"HS section header should NOT be in SkippedSectionHeaders when no school level is set");
|
"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
|
// With no school level filtering, both MS and HS events are processed
|
||||||
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
|
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
|
||||||
{
|
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
|
||||||
// With no school level set, we process all occurrences (both MS and HS)
|
msOccurrences ??= [];
|
||||||
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
|
hsOccurrences ??= [];
|
||||||
Assert.That(allOccurrences, Has.Count.EqualTo(5),
|
Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
|
||||||
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
|
Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
|
||||||
$"Found {allOccurrences.Count} occurrences total.");
|
|
||||||
|
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
|
||||||
// Verify all expected occurrence names are present
|
Assert.That(allNames, Does.Contain("Submit Entry"));
|
||||||
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
|
Assert.That(allNames, Does.Contain("Judging"));
|
||||||
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
|
Assert.That(allNames, Does.Contain("Pick-up"));
|
||||||
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");
|
Assert.Pass("All events processed when no school level is set");
|
||||||
}
|
}
|
||||||
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
|
|||||||
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,9 +69,9 @@ public static class TestEntityHandler
|
|||||||
|
|
||||||
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
|
public static StudentEventRanking[] GetStudentEventRankings(Student[] students, EventDefinition[] events)
|
||||||
{
|
{
|
||||||
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2024 Student Event Rankings.csv");
|
var fileInfo = FileUtility.GetContentFile(ContentDirectory, "2025 Student Event Rankings.csv");
|
||||||
|
|
||||||
using var rankingParser = new StudentEventRankingParser(fileInfo);
|
var rankingParser = new StudentEventRankingParser(fileInfo);
|
||||||
return [.. rankingParser.Parse(students, events).Rankings];
|
return rankingParser.Parse(students, events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
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
|
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
|
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Parsers\TestInput\2025 Assumptions.csv">
|
<Content Include="Parsers\TestInput\2025 Assumptions.csv">
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="_framework/blazor.web.js"></script>
|
||||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||||
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||||
<script src="js/markdownTablePaste.js"></script>
|
<script src="js/markdownTablePaste.js"></script>
|
||||||
<script src="js/login.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
if (e.Key == "Enter")
|
if (e.Key == "Enter")
|
||||||
{
|
{
|
||||||
// Blur the active element to ensure MudTextField bindings update
|
// Blur the active element to ensure MudTextField bindings update
|
||||||
await JS.InvokeVoidAsync("tsaLogin.blurActiveElement");
|
await JS.InvokeVoidAsync("eval", "document.activeElement.blur()");
|
||||||
|
|
||||||
// Small delay to allow bindings to process
|
// Small delay to allow bindings to process
|
||||||
await Task.Delay(50);
|
await Task.Delay(50);
|
||||||
@@ -146,11 +146,14 @@
|
|||||||
|
|
||||||
private async Task HandleFormSubmit()
|
private async Task HandleFormSubmit()
|
||||||
{
|
{
|
||||||
await JS.InvokeVoidAsync(
|
// Update hidden inputs with current model values, then submit the form
|
||||||
"tsaLogin.submitForm",
|
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
|
||||||
_loginModel.Email ?? string.Empty,
|
await JS.InvokeVoidAsync("eval", $@"
|
||||||
_loginModel.Password ?? string.Empty,
|
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||||
_loginModel.RememberMe,
|
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||||
_returnUrl ?? string.Empty);
|
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||||
|
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||||
|
document.getElementById('loginForm').submit();
|
||||||
|
");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1 @@
|
|||||||
@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)
|
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Success" Dense="true">
|
<MudAlert Severity="Severity.Success" Dense="true">
|
||||||
Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s)
|
Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s)
|
||||||
@if (_parseResult.SkippedEventCount > 0)
|
@if (_parseResult.SkippedEventCount > 0)
|
||||||
{
|
{
|
||||||
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
|
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
|
||||||
@@ -201,9 +201,11 @@
|
|||||||
{
|
{
|
||||||
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
|
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
|
||||||
<MudExpansionPanels Elevation="0">
|
<MudExpansionPanels Elevation="0">
|
||||||
@foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key)))
|
@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 }))
|
||||||
{
|
{
|
||||||
<MudExpansionPanel Text="@GetEventName(kvp.Key)">
|
<MudExpansionPanel Text="@GetOccurrenceGroupTitle(kvp.Key)">
|
||||||
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
|
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Name</MudTh>
|
<MudTh>Name</MudTh>
|
||||||
@@ -395,6 +397,17 @@
|
|||||||
return eventDefinition.Name;
|
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)
|
private Color GetIssueTypeColor(ParsingIssueType issueType)
|
||||||
{
|
{
|
||||||
return issueType switch
|
return issueType switch
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
<MudTooltip Text="Import">
|
<MudTooltip Text="Import">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
<MudTooltip Text="Schedule handout (print)">
|
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
|
|
||||||
</MudTooltip>
|
|
||||||
<AuthorizeView Roles="@AuthRoles.Administrator">
|
<AuthorizeView Roles="@AuthRoles.Administrator">
|
||||||
<MudTooltip Text="Admin">
|
<MudTooltip Text="Admin">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
@page "/calendar/state-schedule-handout"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using Microsoft.Extensions.Options
|
|
||||||
@using System.Globalization
|
|
||||||
@using WebApp.Models
|
|
||||||
@using WebApp.Utility
|
|
||||||
@using WebApp.Services
|
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject IConfiguration Configuration
|
|
||||||
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
|
|
||||||
@inject IEventOccurrenceService EventOccurrenceService
|
|
||||||
|
|
||||||
<div class="no-print">
|
|
||||||
<PageHeader
|
|
||||||
Title="State schedule handout"
|
|
||||||
Description="Print per-student schedules and the combined master list."
|
|
||||||
Icon="@Icons.Material.Filled.Print"
|
|
||||||
ShowBackButton="true"
|
|
||||||
BackButtonUrl="/calendar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (_students == null || _allOccurrences == null)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var opts = HandoutOptionsMonitor.CurrentValue;
|
|
||||||
|
|
||||||
<MudContainer Class="state-schedule-handout">
|
|
||||||
@foreach (var student in _students)
|
|
||||||
{
|
|
||||||
<MudContainer Class="pagebreak">
|
|
||||||
<MudText Typo="Typo.h5">
|
|
||||||
@if (string.IsNullOrWhiteSpace(student.StateId))
|
|
||||||
{
|
|
||||||
@student.Name
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@($"{student.Name} - {student.StateId}")
|
|
||||||
}
|
|
||||||
</MudText>
|
|
||||||
<MudText Typo="Typo.h6" Class="mb-3">
|
|
||||||
TSA @_competitionYear @_stateAbbrev State Schedule
|
|
||||||
</MudText>
|
|
||||||
|
|
||||||
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>State ID</th>
|
|
||||||
<th>Event</th>
|
|
||||||
<th>Activity</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var eventRow in GetEventSummaryRows(student))
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@eventRow.StateRegistrationId</td>
|
|
||||||
<td>@eventRow.EventName</td>
|
|
||||||
<td>@eventRow.Activity</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
|
|
||||||
@{
|
|
||||||
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
|
|
||||||
}
|
|
||||||
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
|
|
||||||
@if (scheduleRows.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Event</th>
|
|
||||||
<th>Location</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@FormatTimeDisplay(occ)</td>
|
|
||||||
<td>@FormatEventColumn(occ)</td>
|
|
||||||
<td>@(occ.Location ?? "")</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</MudContainer>
|
|
||||||
}
|
|
||||||
|
|
||||||
<MudContainer Class="pagebreak">
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
|
|
||||||
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
|
|
||||||
@{
|
|
||||||
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
|
|
||||||
}
|
|
||||||
@if (combinedOccurrences.Count == 0)
|
|
||||||
{
|
|
||||||
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
|
|
||||||
}
|
|
||||||
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
|
||||||
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Event</th>
|
|
||||||
<th>Location</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var tlGroup in dateGroup
|
|
||||||
.OrderBy(o => o.StartTime)
|
|
||||||
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
|
|
||||||
.Select(g => g.ToList()))
|
|
||||||
{
|
|
||||||
if (tlGroup.Count == 1)
|
|
||||||
{
|
|
||||||
var occ = tlGroup[0];
|
|
||||||
<tr>
|
|
||||||
<td>@FormatTimeDisplay(occ)</td>
|
|
||||||
<td>@FormatCombinedScheduleEventCell(occ)</td>
|
|
||||||
<td>@(occ.Location ?? "")</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
|
|
||||||
var specificOccs = tlGroup
|
|
||||||
.Where(o => o.EventDefinitionId.HasValue)
|
|
||||||
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
|
|
||||||
var representative = genericOcc ?? specificOccs[0];
|
|
||||||
|
|
||||||
if (genericOcc != null)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
|
||||||
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
|
|
||||||
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
|
||||||
</tr>
|
|
||||||
@foreach (var sub in specificOccs)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
|
||||||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
|
|
||||||
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
|
||||||
</tr>
|
|
||||||
@foreach (var sub in specificOccs.Skip(1))
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</MudSimpleTable>
|
|
||||||
}
|
|
||||||
</MudContainer>
|
|
||||||
</MudContainer>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private Student[]? _students;
|
|
||||||
private List<EventOccurrence>? _allOccurrences;
|
|
||||||
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
|
|
||||||
private string _competitionYear = "";
|
|
||||||
private string _stateAbbrev = "";
|
|
||||||
private string? _chapterStateId;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
|
|
||||||
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
|
|
||||||
_chapterStateId = Configuration["ChapterSettings:StateId"];
|
|
||||||
|
|
||||||
_allOccurrences = await Context.EventOccurrences
|
|
||||||
.AsNoTracking()
|
|
||||||
.Include(eo => eo.EventDefinition)
|
|
||||||
.OrderBy(eo => eo.StartTime)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var eventDefIds = _allOccurrences
|
|
||||||
.Where(o => o.EventDefinitionId.HasValue)
|
|
||||||
.Select(o => o.EventDefinitionId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
|
|
||||||
|
|
||||||
// Tracking required: Include Teams->Students creates a graph cycle (Student–Team–Student) that EF disallows with AsNoTracking().
|
|
||||||
_students = await Context.Students
|
|
||||||
.Include(s => s.Teams)
|
|
||||||
.ThenInclude(t => t!.Event)
|
|
||||||
.Include(s => s.Teams)
|
|
||||||
.ThenInclude(t => t!.Captain)
|
|
||||||
.Include(s => s.Teams)
|
|
||||||
.ThenInclude(t => t!.Students)
|
|
||||||
.OrderBy(s => s.FirstName)
|
|
||||||
.ThenBy(s => s.LastName)
|
|
||||||
.ToArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
|
|
||||||
{
|
|
||||||
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
|
|
||||||
|
|
||||||
var competition = _allOccurrences!
|
|
||||||
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
|
|
||||||
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
|
|
||||||
|
|
||||||
var special = _allOccurrences!
|
|
||||||
.Where(o => o.EventDefinitionId == null)
|
|
||||||
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
|
|
||||||
|
|
||||||
return competition
|
|
||||||
.Concat(special)
|
|
||||||
.OrderBy(o => o.StartTime)
|
|
||||||
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
|
|
||||||
{
|
|
||||||
return _allOccurrences!
|
|
||||||
.Where(o =>
|
|
||||||
{
|
|
||||||
// Keep chapter-wide/special schedule rows.
|
|
||||||
if (!o.EventDefinitionId.HasValue)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
// Keep only competition events where this chapter has registered teams.
|
|
||||||
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
|
|
||||||
})
|
|
||||||
.OrderBy(o => o.StartTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
|
|
||||||
{
|
|
||||||
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
|
|
||||||
{
|
|
||||||
yield return new EventSummaryRow(
|
|
||||||
StateRegistrationId: FormatStateRegistrationId(team, student),
|
|
||||||
EventName: team.Event.Name,
|
|
||||||
Activity: FormatActivitySummary(team, student));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
|
|
||||||
/// Individual events: competitor's <see cref="Student.StateId"/>.
|
|
||||||
/// </summary>
|
|
||||||
private string FormatStateRegistrationId(Team team, Student student)
|
|
||||||
{
|
|
||||||
if (team.Event.EventFormat == EventFormat.Individual)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(student.StateId)
|
|
||||||
? "—"
|
|
||||||
: student.StateId.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
var chap = _chapterStateId?.Trim();
|
|
||||||
var ident = team.Identifier?.Trim();
|
|
||||||
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
|
|
||||||
return "—";
|
|
||||||
|
|
||||||
// Already a full registration id (e.g. "12227-1" or state id stored on team)
|
|
||||||
if (!string.IsNullOrEmpty(ident))
|
|
||||||
{
|
|
||||||
if (ident.Contains('-', StringComparison.Ordinal))
|
|
||||||
return ident;
|
|
||||||
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
|
|
||||||
return ident;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
|
|
||||||
return $"{chap}-{ident}";
|
|
||||||
return !string.IsNullOrEmpty(chap) ? chap : ident!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
|
|
||||||
private static string FormatActivitySummary(Team team, Student student)
|
|
||||||
{
|
|
||||||
var parts = new List<string>();
|
|
||||||
if (team.Captain?.Id == student.Id)
|
|
||||||
parts.Add("(Cpt.)");
|
|
||||||
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
|
|
||||||
parts.Add(team.Event.SemifinalistActivity!);
|
|
||||||
return string.Join(" ", parts).Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDateHeading(DateTime date) =>
|
|
||||||
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
|
|
||||||
|
|
||||||
private static string FormatTimeDisplay(EventOccurrence o)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(o.Time))
|
|
||||||
return o.Time.Trim();
|
|
||||||
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatEventColumn(EventOccurrence o)
|
|
||||||
{
|
|
||||||
if (o.EventDefinition != null)
|
|
||||||
{
|
|
||||||
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
|
|
||||||
? o.EventDefinition.ShortName
|
|
||||||
: o.EventDefinition.Name;
|
|
||||||
if (string.IsNullOrWhiteSpace(o.Name))
|
|
||||||
return ev;
|
|
||||||
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
|
|
||||||
return o.Name.Trim();
|
|
||||||
return $"{ev} {o.Name}".Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
|
|
||||||
{
|
|
||||||
var baseText = FormatEventColumn(occ);
|
|
||||||
if (!occ.EventDefinitionId.HasValue)
|
|
||||||
return baseText;
|
|
||||||
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
|
|
||||||
return baseText;
|
|
||||||
|
|
||||||
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
|
|
||||||
|
|
||||||
var orderedTeams = teams
|
|
||||||
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
|
|
||||||
{
|
|
||||||
var cmp = CombinedScheduleTeamSortOrder(a, b);
|
|
||||||
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
|
|
||||||
}))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var rosterStrings = orderedTeams
|
|
||||||
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
|
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (rosterStrings.Count == 0)
|
|
||||||
return baseText;
|
|
||||||
|
|
||||||
var suffix = rosterStrings.Count == 1
|
|
||||||
? rosterStrings[0]
|
|
||||||
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
|
|
||||||
|
|
||||||
return $"{baseText} — {suffix}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
|
|
||||||
{
|
|
||||||
var ka = a.Identifier?.Trim() ?? "";
|
|
||||||
var kb = b.Identifier?.Trim() ?? "";
|
|
||||||
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
|
|
||||||
return na.CompareTo(nb);
|
|
||||||
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
|
|
||||||
{
|
|
||||||
var students = team.Students?.ToList() ?? [];
|
|
||||||
if (students.Count == 0)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
if (isIndividual)
|
|
||||||
{
|
|
||||||
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
|
||||||
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var cap = team.Captain;
|
|
||||||
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
|
|
||||||
IEnumerable<Student> orderedTeam = capInRoster
|
|
||||||
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
|
|
||||||
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
|
|
||||||
{
|
|
||||||
if (isIndividual)
|
|
||||||
{
|
|
||||||
var sid = student.StateId?.Trim();
|
|
||||||
return !string.IsNullOrEmpty(sid)
|
|
||||||
? $"{student.FirstName} ({sid})"
|
|
||||||
: student.FirstName;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isCpt = team.Captain?.Id == student.Id;
|
|
||||||
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
|
Title="@($"TSA Events {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
Description="Yearly theme: Unity Through Community" />
|
||||||
|
|
||||||
@if (_events == null)
|
@if (_events == null)
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,7 @@ else
|
|||||||
{
|
{
|
||||||
<MudItem xs="3">
|
<MudItem xs="3">
|
||||||
<MudText Class="d-flex py-1">
|
<MudText Class="d-flex py-1">
|
||||||
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
|
<i>Theme for 2025-26:</i>
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="8">
|
<MudItem xs="8">
|
||||||
@@ -91,6 +91,83 @@ else
|
|||||||
<MudDivider />
|
<MudDivider />
|
||||||
}
|
}
|
||||||
</MudContainer>
|
</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 {
|
@code {
|
||||||
private EventDefinition[]? _events;
|
private EventDefinition[]? _events;
|
||||||
|
|||||||
@@ -11,14 +11,6 @@
|
|||||||
Title="Student Event Ranks"
|
Title="Student Event Ranks"
|
||||||
Icon="@AppIcons.EventRank">
|
Icon="@AppIcons.EventRank">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudTooltip Text="Import rankings from CSV">
|
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.UploadFile"
|
|
||||||
Href="students/event-ranking/import"
|
|
||||||
Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary">
|
|
||||||
Import
|
|
||||||
</MudButton>
|
|
||||||
</MudTooltip>
|
|
||||||
<PageNoteButton PageIdentifier="Event Ranking" />
|
<PageNoteButton PageIdentifier="Event Ranking" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
@page "/students/event-ranking-edit/{StudentId:int}"
|
@page "/students/event-ranking-edit/{StudentId:int}"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using BlazorSortableList
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@@ -31,6 +33,7 @@ else
|
|||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
@* https://github.com/AlexNek/BlazorSortableList *@
|
||||||
<MudGrid Class="mt-2">
|
<MudGrid Class="mt-2">
|
||||||
<MudItem xs="12" md="6" xl="4">
|
<MudItem xs="12" md="6" xl="4">
|
||||||
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
||||||
@@ -38,52 +41,29 @@ else
|
|||||||
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" Class="mr-2" Color="Color.Primary" />
|
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" Class="mr-2" Color="Color.Primary" />
|
||||||
<MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText>
|
<MudText Typo="Typo.h6" Color="Color.Primary">Ranked Events</MudText>
|
||||||
</div>
|
</div>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events here in order of preference</MudText>
|
||||||
Use the arrows to set rank. Use the X to remove an event. Up to @StudentEventRanking.MaxRank events.
|
|
||||||
</MudText>
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@if (_rankedEvents.Count == 0)
|
<SortableList
|
||||||
{
|
Group="GroupId" Id="ListId1" Context="item"
|
||||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
Items="_rankedEvents" OnRemove="RankedEventsRemove" OnUpdate="Update">
|
||||||
No ranked events yet. Add events from the list on the right.
|
<SortableItemTemplate>
|
||||||
</MudAlert>
|
<MudCard Outlined="true" Class="mb-2">
|
||||||
}
|
<MudCardContent Class="pa-2">
|
||||||
@for (var i = 0; i < _rankedEvents.Count; i++)
|
<div class="d-flex align-center">
|
||||||
{
|
<MudBadge Content="@(_rankedEvents.IndexOf(item) + 1)" Color="Color.Primary" Overlap="true" Class="mr-3">
|
||||||
var index = i;
|
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" />
|
||||||
var item = _rankedEvents[index];
|
</MudBadge>
|
||||||
var rank = index + 1;
|
<div class="flex-grow-1">
|
||||||
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
|
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
||||||
<MudCardContent Class="pa-2">
|
<MudText Typo="Typo.caption">
|
||||||
<div class="d-flex align-center">
|
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
||||||
<MudBadge Content="@rank" Color="Color.Primary" Overlap="true" Class="mr-3">
|
</MudText>
|
||||||
<MudIcon Icon="@Icons.Material.Filled.FormatListNumbered" />
|
</div>
|
||||||
</MudBadge>
|
</div>
|
||||||
<div class="flex-grow-1">
|
</MudCardContent>
|
||||||
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
</MudCard>
|
||||||
<MudText Typo="Typo.caption">
|
</SortableItemTemplate>
|
||||||
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
</SortableList>
|
||||||
</MudText>
|
|
||||||
</div>
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowUp"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="@(index == 0)"
|
|
||||||
aria-label="Move up"
|
|
||||||
OnClick="@(() => MoveRanked(item, -1))" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.KeyboardArrowDown"
|
|
||||||
Size="Size.Small"
|
|
||||||
Disabled="@(index == _rankedEvents.Count - 1)"
|
|
||||||
aria-label="Move down"
|
|
||||||
OnClick="@(() => MoveRanked(item, 1))" />
|
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
|
||||||
Size="Size.Small"
|
|
||||||
Color="Color.Default"
|
|
||||||
aria-label="Remove from ranked events"
|
|
||||||
OnClick="@(() => RemoveRanked(item))" />
|
|
||||||
</div>
|
|
||||||
</MudCardContent>
|
|
||||||
</MudCard>
|
|
||||||
}
|
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="6" xl="4">
|
<MudItem xs="12" md="6" xl="4">
|
||||||
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
<MudPaper Class="pa-3 mb-3" Elevation="2">
|
||||||
@@ -91,117 +71,99 @@ else
|
|||||||
<MudIcon Icon="@AppIcons.Events" Class="mr-2" />
|
<MudIcon Icon="@AppIcons.Events" Class="mr-2" />
|
||||||
<MudText Typo="Typo.h6">Available Events</MudText>
|
<MudText Typo="Typo.h6">Available Events</MudText>
|
||||||
</div>
|
</div>
|
||||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Drag events to rank them</MudText>
|
||||||
@if (AtMaxRank)
|
|
||||||
{
|
|
||||||
<text>Maximum of @StudentEventRanking.MaxRank ranked events reached. Remove one to add another.</text>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<text>Use Add to place an event at the end of the ranked list.</text>
|
|
||||||
}
|
|
||||||
</MudText>
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@if (_availableEvents.Count == 0)
|
<SortableList
|
||||||
{
|
Group="GroupId" Id="ListId2" Context="item"
|
||||||
<MudAlert Severity="Severity.Info" Dense="true">
|
Items="_availableEvents" OnRemove="AvailableEventsRemove" Sort="false">
|
||||||
@(_rankedEvents.Count == 0 ? "No events are available to rank." : "All events are already ranked.")
|
<SortableItemTemplate>
|
||||||
</MudAlert>
|
<MudCard Outlined="true" Class="mb-2">
|
||||||
}
|
<MudCardContent Class="pa-2">
|
||||||
@foreach (var item in _availableEvents)
|
|
||||||
{
|
|
||||||
<MudCard @key="item.Id" Outlined="true" Class="mb-2">
|
|
||||||
<MudCardContent Class="pa-2">
|
|
||||||
<div class="d-flex align-center">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
<MudText Typo="Typo.body2"><strong>@item.Name</strong></MudText>
|
||||||
<MudText Typo="Typo.caption">
|
<MudText Typo="Typo.caption">
|
||||||
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
@AppIcons.EventAttributes(item) @AppIcons.EventEffort(item)
|
||||||
</MudText>
|
</MudText>
|
||||||
</div>
|
</MudCardContent>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
</MudCard>
|
||||||
Color="Color.Primary"
|
</SortableItemTemplate>
|
||||||
Disabled="@AtMaxRank"
|
</SortableList>
|
||||||
aria-label="@($"Add {item.Name} to ranked events")"
|
|
||||||
OnClick="@(() => AddRanked(item))" />
|
|
||||||
</div>
|
|
||||||
</MudCardContent>
|
|
||||||
</MudCard>
|
|
||||||
}
|
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|
||||||
|
private const string ListId1 = "SharedListId1";
|
||||||
|
private const string ListId2 = "SharedListId2";
|
||||||
|
private const string GroupId = "CommonGroup";
|
||||||
|
|
||||||
[Parameter] public int? StudentId { get; set; }
|
[Parameter] public int? StudentId { get; set; }
|
||||||
|
|
||||||
private Student? _student;
|
private Student? _student;
|
||||||
|
private List<EventDefinition>? _events;
|
||||||
|
|
||||||
private List<EventDefinition> _rankedEvents = [];
|
public List<EventDefinition> _rankedEvents = [];
|
||||||
private List<EventDefinition> _availableEvents = [];
|
public List<EventDefinition> _availableEvents = [];
|
||||||
|
|
||||||
private bool AtMaxRank => _rankedEvents.Count >= StudentEventRanking.MaxRank;
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_student =
|
_student =
|
||||||
await Context.Students
|
await Context.Students
|
||||||
.Include(e => e.EventRankings)
|
.Include(e => e.EventRankings)
|
||||||
.ThenInclude(r => r.EventDefinition)
|
.Where(e => e.Id == StudentId).FirstAsync();
|
||||||
.Where(e => e.Id == StudentId)
|
_events =
|
||||||
.FirstAsync();
|
|
||||||
|
|
||||||
var events =
|
|
||||||
await Context.Events
|
await Context.Events
|
||||||
.OrderBy(e => e.Name)
|
.OrderBy(e => e.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
_rankedEvents = _student.EventRankings
|
_rankedEvents = _student.EventRankings.OrderBy(e => e.Rank).Select(e => e.EventDefinition).ToList();
|
||||||
.Where(e => e.EventDefinition != null)
|
_availableEvents = _events.Where(e => !_rankedEvents.Contains(e)).ToList();
|
||||||
.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 AddRanked(EventDefinition item)
|
private void Update((int oldIndex, int newIndex) indices)
|
||||||
{
|
{
|
||||||
if (AtMaxRank || _rankedEvents.Any(e => e.Id == item.Id))
|
var (oldIndex, newIndex) = indices;
|
||||||
|
|
||||||
|
var items = _rankedEvents;
|
||||||
|
var itemToMove = items[oldIndex];
|
||||||
|
items.RemoveAt(oldIndex);
|
||||||
|
|
||||||
|
if (newIndex < items.Count)
|
||||||
{
|
{
|
||||||
return;
|
items.Insert(newIndex, itemToMove);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(itemToMove);
|
||||||
}
|
}
|
||||||
|
|
||||||
_availableEvents.RemoveAll(e => e.Id == item.Id);
|
StateHasChanged();
|
||||||
_rankedEvents.Add(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveRanked(EventDefinition item)
|
|
||||||
{
|
|
||||||
_rankedEvents.RemoveAll(e => e.Id == item.Id);
|
|
||||||
if (_availableEvents.All(e => e.Id != item.Id))
|
|
||||||
{
|
|
||||||
_availableEvents.Add(item);
|
|
||||||
_availableEvents.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveRanked(EventDefinition item, int offset)
|
|
||||||
{
|
|
||||||
var index = _rankedEvents.FindIndex(e => e.Id == item.Id);
|
|
||||||
var newIndex = index + offset;
|
|
||||||
if (index < 0 || newIndex < 0 || newIndex >= _rankedEvents.Count)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_rankedEvents.RemoveAt(index);
|
|
||||||
_rankedEvents.Insert(newIndex, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task Save()
|
async Task Save()
|
||||||
{
|
{
|
||||||
@@ -210,12 +172,10 @@ else
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var uniqueRanked = _rankedEvents.DistinctBy(e => e.Id).Take(StudentEventRanking.MaxRank).ToList();
|
|
||||||
|
|
||||||
_student.EventRankings.Clear();
|
_student.EventRankings.Clear();
|
||||||
for (var index = 0; index < uniqueRanked.Count; index++)
|
for (var index = 0; index < _rankedEvents.Count; index++)
|
||||||
{
|
{
|
||||||
var evt = uniqueRanked[index];
|
var evt = _rankedEvents[index];
|
||||||
_student.EventRankings.Add(new StudentEventRanking
|
_student.EventRankings.Add(new StudentEventRanking
|
||||||
{
|
{
|
||||||
EventDefinition = evt,
|
EventDefinition = evt,
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
@page "/students/event-ranking/import"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@implements IAsyncDisposable
|
|
||||||
@using Core.Models
|
|
||||||
@using Core.Services
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using WebApp.Models
|
|
||||||
@inject IStudentEventRankingImportService ImportService
|
|
||||||
@inject IStudentEventRankingSaveService SaveService
|
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject NavigationManager NavigationManager
|
|
||||||
@inject ISnackbar Snackbar
|
|
||||||
@inject IDialogService DialogService
|
|
||||||
@inject ILogger<EventRankingImport> Logger
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
Title="Import Event Rankings"
|
|
||||||
Description="Upload a CSV of student event preferences, preview matches, then save."
|
|
||||||
Icon="@AppIcons.EventRank"
|
|
||||||
ShowBackButton="true"
|
|
||||||
BackButtonUrl="/students/event-ranking" />
|
|
||||||
|
|
||||||
<MudGrid>
|
|
||||||
<MudItem xs="12" md="5">
|
|
||||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Upload CSV</MudText>
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
<MudText Typo="Typo.body2">
|
|
||||||
Required column: <code>Student Name</code>. Rank columns are <code>1</code> through <code>10</code>.
|
|
||||||
Names can be <code>Last, First</code> or <code>First Last</code>. Event cells can be a full name, short name, or a close match.
|
|
||||||
</MudText>
|
|
||||||
<InputFile OnChange="HandleFileChanged" accept=".csv,text/csv" />
|
|
||||||
@if (!string.IsNullOrEmpty(_fileName))
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.caption">@_fileName</MudText>
|
|
||||||
}
|
|
||||||
<MudStack Row="true" Spacing="2">
|
|
||||||
<MudButton
|
|
||||||
Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
StartIcon="@Icons.Material.Filled.Article"
|
|
||||||
OnClick="HandleParse"
|
|
||||||
Disabled="@(_isParsing || _fileBytes is null)">
|
|
||||||
Parse
|
|
||||||
</MudButton>
|
|
||||||
<MudButton
|
|
||||||
Variant="Variant.Text"
|
|
||||||
OnClick="HandleClear"
|
|
||||||
Disabled="@_isParsing">
|
|
||||||
Clear
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</MudStack>
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
|
|
||||||
<MudItem xs="12" md="7">
|
|
||||||
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
|
|
||||||
|
|
||||||
@if (_isParsing)
|
|
||||||
{
|
|
||||||
<MudProgressLinear Indeterminate="true" Class="mb-4" />
|
|
||||||
<MudText>Parsing...</MudText>
|
|
||||||
}
|
|
||||||
else if (_parseResult == null)
|
|
||||||
{
|
|
||||||
<MudText Class="mud-text-secondary">Upload and parse a CSV to see results here</MudText>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
@foreach (var error in _parseResult.Errors)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Error" Dense="true">@error</MudAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@foreach (var warning in _parseResult.Warnings)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Warning" Dense="true">@warning</MudAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Success" Dense="true">
|
|
||||||
Matched @_parseResult.TotalParsed ranking(s) for @_parseResult.StudentsWithAcceptedRanks.Count student(s)
|
|
||||||
</MudAlert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (_parseResult.Issues.Count > 0)
|
|
||||||
{
|
|
||||||
<MudExpansionPanels Elevation="0">
|
|
||||||
<MudExpansionPanel Text="@($"Issues ({_parseResult.Issues.Count})")"
|
|
||||||
Icon="@Icons.Material.Filled.Warning">
|
|
||||||
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Row</MudTh>
|
|
||||||
<MudTh>Rank</MudTh>
|
|
||||||
<MudTh>Type</MudTh>
|
|
||||||
<MudTh>Student</MudTh>
|
|
||||||
<MudTh>Event text</MudTh>
|
|
||||||
<MudTh>Message</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Row">@context.RowNumber</MudTd>
|
|
||||||
<MudTd DataLabel="Rank">@(context.Rank > 0 ? context.Rank.ToString() : "-")</MudTd>
|
|
||||||
<MudTd DataLabel="Type">
|
|
||||||
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
|
|
||||||
@context.IssueType
|
|
||||||
</MudChip>
|
|
||||||
</MudTd>
|
|
||||||
<MudTd DataLabel="Student">@context.RawStudentName</MudTd>
|
|
||||||
<MudTd DataLabel="Event text">@context.RawEventName</MudTd>
|
|
||||||
<MudTd DataLabel="Message">@context.Message</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
</MudExpansionPanel>
|
|
||||||
</MudExpansionPanels>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (_parseResult.IsSuccess && _parseResult.Matches.Count > 0)
|
|
||||||
{
|
|
||||||
<MudText Typo="Typo.h6">Matched rankings</MudText>
|
|
||||||
<MudTable Items="@_parseResult.Matches" Dense="true" Hover="true" Striped="true">
|
|
||||||
<HeaderContent>
|
|
||||||
<MudTh>Student</MudTh>
|
|
||||||
<MudTh>Rank</MudTh>
|
|
||||||
<MudTh>CSV text</MudTh>
|
|
||||||
<MudTh>Matched event</MudTh>
|
|
||||||
<MudTh>Score</MudTh>
|
|
||||||
</HeaderContent>
|
|
||||||
<RowTemplate>
|
|
||||||
<MudTd DataLabel="Student">@context.Ranking.Student.FirstNameLastName</MudTd>
|
|
||||||
<MudTd DataLabel="Rank">@context.Ranking.Rank</MudTd>
|
|
||||||
<MudTd DataLabel="CSV text">@context.RawEventName</MudTd>
|
|
||||||
<MudTd DataLabel="Matched event">@context.Ranking.EventDefinition.Name</MudTd>
|
|
||||||
<MudTd DataLabel="Score">@context.EventScore</MudTd>
|
|
||||||
</RowTemplate>
|
|
||||||
</MudTable>
|
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
|
||||||
<MudButton
|
|
||||||
Variant="Variant.Filled"
|
|
||||||
Color="Color.Success"
|
|
||||||
StartIcon="@Icons.Material.Filled.Save"
|
|
||||||
OnClick="HandleSave"
|
|
||||||
Disabled="@_isSaving">
|
|
||||||
Save to Database
|
|
||||||
</MudButton>
|
|
||||||
<MudButton
|
|
||||||
Variant="Variant.Text"
|
|
||||||
OnClick="HandleClearResults">
|
|
||||||
Clear Results
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
</MudPaper>
|
|
||||||
</MudItem>
|
|
||||||
</MudGrid>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private byte[]? _fileBytes;
|
|
||||||
private string? _fileName;
|
|
||||||
private StudentEventRankingParseResult? _parseResult;
|
|
||||||
private bool _isParsing;
|
|
||||||
private bool _isSaving;
|
|
||||||
private CancellationTokenSource? _cancellationTokenSource;
|
|
||||||
private bool _isDisposed;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
_cancellationTokenSource = new CancellationTokenSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleFileChanged(InputFileChangeEventArgs args)
|
|
||||||
{
|
|
||||||
if (_isDisposed)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await using var stream = args.File.OpenReadStream(maxAllowedSize: 1024 * 1024);
|
|
||||||
await using var memory = new MemoryStream();
|
|
||||||
await stream.CopyToAsync(memory, _cancellationTokenSource?.Token ?? CancellationToken.None);
|
|
||||||
_fileBytes = memory.ToArray();
|
|
||||||
_fileName = args.File.Name;
|
|
||||||
_parseResult = null;
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error reading ranking CSV");
|
|
||||||
if (!_isDisposed)
|
|
||||||
Snackbar.Add($"Could not read file: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleParse()
|
|
||||||
{
|
|
||||||
if (_fileBytes is null)
|
|
||||||
{
|
|
||||||
Snackbar.Add("Please choose a CSV file first", Severity.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isParsing = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
||||||
var students = await Context.Students
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(s => s.LastName)
|
|
||||||
.ThenBy(s => s.FirstName)
|
|
||||||
.ToListAsync(token);
|
|
||||||
var events = await Context.Events
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(e => e.Name)
|
|
||||||
.ToListAsync(token);
|
|
||||||
|
|
||||||
await using var stream = new MemoryStream(_fileBytes, writable: false);
|
|
||||||
_parseResult = ImportService.Parse(stream, students, events);
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error parsing ranking CSV");
|
|
||||||
if (!_isDisposed)
|
|
||||||
{
|
|
||||||
Snackbar.Add($"Error parsing CSV: {ex.Message}", Severity.Error);
|
|
||||||
_parseResult = new StudentEventRankingParseResult
|
|
||||||
{
|
|
||||||
Errors = { $"Error: {ex.Message}" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isParsing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleClear()
|
|
||||||
{
|
|
||||||
_fileBytes = null;
|
|
||||||
_fileName = null;
|
|
||||||
_parseResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleClearResults()
|
|
||||||
{
|
|
||||||
_parseResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSave()
|
|
||||||
{
|
|
||||||
if (_parseResult is null || !_parseResult.IsSuccess || _parseResult.TotalParsed == 0)
|
|
||||||
{
|
|
||||||
Snackbar.Add("No valid rankings to save", Severity.Warning);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
|
||||||
var existing = await SaveService.GetStudentsWithExistingRankingsAsync(_parseResult, token);
|
|
||||||
if (existing.Count > 0)
|
|
||||||
{
|
|
||||||
var preview = string.Join(", ", existing.Take(8));
|
|
||||||
var remaining = existing.Count > 8 ? $" and {existing.Count - 8} more" : string.Empty;
|
|
||||||
var confirmed = await DialogService.ShowMessageBox(
|
|
||||||
"Replace existing rankings?",
|
|
||||||
$"This will replace current rankings for {existing.Count} student(s): {preview}{remaining}. Continue?",
|
|
||||||
yesText: "Replace rankings",
|
|
||||||
cancelText: "Cancel");
|
|
||||||
|
|
||||||
if (confirmed != true || _isDisposed)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isSaving = true;
|
|
||||||
var saveResult = await SaveService.SaveAsync(_parseResult, token);
|
|
||||||
if (_isDisposed)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Snackbar.Add(
|
|
||||||
$"Saved {saveResult.RankingsSaved} ranking(s) for {saveResult.StudentsUpdated} student(s)",
|
|
||||||
Severity.Success);
|
|
||||||
NavigationManager.NavigateTo("/students/event-ranking");
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "Error saving imported rankings");
|
|
||||||
if (!_isDisposed)
|
|
||||||
Snackbar.Add($"Error saving rankings: {ex.Message}", Severity.Error);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Color GetIssueTypeColor(StudentEventRankingIssueType issueType) =>
|
|
||||||
issueType switch
|
|
||||||
{
|
|
||||||
StudentEventRankingIssueType.UnmatchedStudent => Color.Error,
|
|
||||||
StudentEventRankingIssueType.UnmatchedEvent => Color.Warning,
|
|
||||||
StudentEventRankingIssueType.AmbiguousEvent => Color.Warning,
|
|
||||||
StudentEventRankingIssueType.DuplicateEvent => Color.Info,
|
|
||||||
StudentEventRankingIssueType.DuplicateRank => Color.Info,
|
|
||||||
StudentEventRankingIssueType.InvalidFormat => Color.Error,
|
|
||||||
_ => Color.Default
|
|
||||||
};
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (!_isDisposed)
|
|
||||||
{
|
|
||||||
_isDisposed = true;
|
|
||||||
_cancellationTokenSource?.Cancel();
|
|
||||||
_cancellationTokenSource?.Dispose();
|
|
||||||
_cancellationTokenSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
Description="Yearly theme: Unity Through Community" />
|
||||||
|
|
||||||
@if (_teams == null || _students == null)
|
@if (_teams == null || _students == null)
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@ else
|
|||||||
{
|
{
|
||||||
<MudItem xs="3">
|
<MudItem xs="3">
|
||||||
<MudText Class="d-flex py-1">
|
<MudText Class="d-flex py-1">
|
||||||
<i>Theme for @Configuration["ChapterSettings:CompetitionYear"]:</i>
|
<i>Theme for 2025-26:</i>
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="8">
|
<MudItem xs="8">
|
||||||
|
|||||||
@@ -19,9 +19,6 @@
|
|||||||
<MudTooltip Text="Handout">
|
<MudTooltip Text="Handout">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
<MudTooltip Text="State schedule handout (print)">
|
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
|
|
||||||
</MudTooltip>
|
|
||||||
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
|
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
|
||||||
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Teams {Configuration["ChapterSettings:CompetitionYear"]}")"
|
||||||
Description="@($"Yearly theme: {Configuration["ChapterSettings:YearlyTheme"]}")" />
|
Description="Yearly theme: Unity Through Community" />
|
||||||
|
|
||||||
<Legend></Legend>
|
<Legend></Legend>
|
||||||
@if (_teams == null)
|
@if (_teams == null)
|
||||||
@@ -41,8 +41,9 @@ else
|
|||||||
@{
|
@{
|
||||||
var students
|
var students
|
||||||
= context.Students
|
= context.Students
|
||||||
.OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
|
.OrderByDescending(s => s == context.Captain)
|
||||||
.ThenByDescending(e => e.Grade + e.TsaYear)
|
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue)
|
||||||
|
.ThenByDescending(e => e.Grade)
|
||||||
.ThenBy(e => e.FirstName)
|
.ThenBy(e => e.FirstName)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
@@ -232,7 +233,6 @@ else
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(e => e.Event)
|
.Include(e => e.Event)
|
||||||
.Include(e => e.Students)
|
.Include(e => e.Students)
|
||||||
.Include(e => e.Captain)
|
|
||||||
.OrderByEventFormatFirst()
|
.OrderByEventFormatFirst()
|
||||||
.ThenBy(e => e.Event.Name)
|
.ThenBy(e => e.Event.Name)
|
||||||
.ThenBy(e => e.Identifier ?? "")
|
.ThenBy(e => e.Identifier ?? "")
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
@using WebApp.Authentication
|
@using WebApp.Authentication
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@using WebApp.Services
|
@using System.Text.Json
|
||||||
@using Core.Models
|
@using Core.Models
|
||||||
|
@inject IWebHostEnvironment Environment
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject IChapterSettingsWriter ChapterSettingsWriter
|
|
||||||
|
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -44,12 +44,6 @@
|
|||||||
MaxLength="4"
|
MaxLength="4"
|
||||||
Required="true" />
|
Required="true" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="6">
|
|
||||||
<MudTextField @bind-Value="_settings.YearlyTheme"
|
|
||||||
Label="Yearly Theme"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
HelperText="National TSA yearly theme (e.g., Unity Through Community)" />
|
|
||||||
</MudItem>
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel"
|
<MudSelect T="SchoolLevel?" @bind-Value="_settings.SchoolLevel"
|
||||||
Label="School Level"
|
Label="School Level"
|
||||||
@@ -131,10 +125,19 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
// Load from IConfiguration
|
||||||
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
|
||||||
?? new Models.ChapterSettings();
|
?? new Models.ChapterSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetAppSettingsPath()
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
Environment.ContentRootPath,
|
||||||
|
"Data",
|
||||||
|
"appsettings.json");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveSettings()
|
private async Task SaveSettings()
|
||||||
{
|
{
|
||||||
if (_settings == null) return;
|
if (_settings == null) return;
|
||||||
@@ -144,7 +147,41 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ChapterSettingsWriter.WriteAsync(_settings);
|
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();
|
||||||
|
|
||||||
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
|
||||||
_statusSeverity = Severity.Success;
|
_statusSeverity = Severity.Success;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,651 +0,0 @@
|
|||||||
@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 & grades</MudStep>
|
|
||||||
<MudStep Title="Roster">Returning roster</MudStep>
|
|
||||||
<MudStep Title="Officers">Officers</MudStep>
|
|
||||||
<MudStep Title="Reset">Season reset</MudStep>
|
|
||||||
<MudStep Title="Apply">Preview & 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,7 +35,6 @@
|
|||||||
<AuthorizeView Roles="Administrator">
|
<AuthorizeView Roles="Administrator">
|
||||||
<MudDivider Class="my-2"/>
|
<MudDivider Class="my-2"/>
|
||||||
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
|
||||||
<MudNavLink Href="/settings/new-year" Icon="@Icons.Material.Filled.EventRepeat">New Year Rollover (locked)</MudNavLink>
|
|
||||||
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
|
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|||||||
@@ -37,16 +37,6 @@ public class ChapterSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string CompetitionYear { get; set; } = "2026";
|
public string CompetitionYear { get; set; } = "2026";
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// National TSA yearly theme (e.g., "Unity Through Community")
|
|
||||||
/// </summary>
|
|
||||||
public string YearlyTheme { get; set; } = "Unity Through Community";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
|
|
||||||
/// </summary>
|
|
||||||
public string StateAbbrev { get; set; } = "ST";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// School level for the chapter (null = import both MS and HS events)
|
/// School level for the chapter (null = import both MS and HS events)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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"
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+9
-17
@@ -11,7 +11,6 @@ using WebApp;
|
|||||||
using WebApp.Authentication;
|
using WebApp.Authentication;
|
||||||
using WebApp.Components;
|
using WebApp.Components;
|
||||||
using WebApp.Logging;
|
using WebApp.Logging;
|
||||||
using WebApp.Models;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -102,16 +101,17 @@ builder.Host.UseSerilog((context, configuration) =>
|
|||||||
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
|
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
|
// Configure authentication secrets for production (Docker, etc.)
|
||||||
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
|
|
||||||
if (File.Exists(authSecretsPath))
|
|
||||||
{
|
|
||||||
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
|
|
||||||
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (builder.Environment.IsProduction())
|
if (builder.Environment.IsProduction())
|
||||||
{
|
{
|
||||||
|
// Option 1: Load from volume-mounted secrets file in Data directory
|
||||||
|
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
|
||||||
|
if (File.Exists(secretsPath))
|
||||||
|
{
|
||||||
|
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 2: Environment variables with prefix
|
||||||
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
|
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,14 +201,6 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
|
|||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
|
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
|
||||||
builder.Services.AddScoped<WebApp.Services.IChapterSettingsWriter, WebApp.Services.ChapterSettingsWriter>();
|
|
||||||
builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Services.DatabaseBackupService>();
|
|
||||||
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
|
|
||||||
builder.Services.AddScoped<Core.Services.IStudentEventRankingImportService, Core.Services.StudentEventRankingImportService>();
|
|
||||||
builder.Services.AddScoped<WebApp.Services.IStudentEventRankingSaveService, WebApp.Services.StudentEventRankingSaveService>();
|
|
||||||
|
|
||||||
builder.Services.Configure<StateScheduleHandoutOptions>(
|
|
||||||
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
|
|
||||||
|
|
||||||
// State container for maintaining state per user connection (Blazor Server)
|
// State container for maintaining state per user connection (Blazor Server)
|
||||||
builder.Services.AddScoped<StateContainer>();
|
builder.Services.AddScoped<StateContainer>();
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,14 +39,13 @@ public class EventOccurrenceService : IEventOccurrenceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var teams = await _context.Teams
|
var teams = await _context.Teams
|
||||||
.Include(t => t.Event)
|
|
||||||
.Include(t => t.Students)
|
.Include(t => t.Students)
|
||||||
.Include(t => t.Captain)
|
.Include(t => t.Captain)
|
||||||
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
|
.Where(t => ids.Contains(t.Event.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return teams
|
return teams
|
||||||
.GroupBy(t => t.Event!.Id)
|
.GroupBy(t => t.Event.Id)
|
||||||
.ToDictionary(g => g.Key, g => g.ToList());
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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()];
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="BlazorSortableList" Version="2.1.0" />
|
||||||
<PackageReference Include="Heron.MudCalendar" Version="3.4.0" />
|
<PackageReference Include="Heron.MudCalendar" Version="3.4.0" />
|
||||||
<PackageReference Include="VisNetwork.Blazor" Version="3.0.0" />
|
<PackageReference Include="VisNetwork.Blazor" Version="3.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"NationalId": "2227",
|
"NationalId": "2227",
|
||||||
"StateId": "12227",
|
"StateId": "12227",
|
||||||
"RegionalId": "12227",
|
"RegionalId": "12227",
|
||||||
"CompetitionYear": "2026",
|
"CompetitionYear": "2026"
|
||||||
"YearlyTheme": "Unity Through Community"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-17
@@ -16,23 +16,7 @@
|
|||||||
"NationalId": "0000",
|
"NationalId": "0000",
|
||||||
"StateId": "00000",
|
"StateId": "00000",
|
||||||
"RegionalId": "00000",
|
"RegionalId": "00000",
|
||||||
"CompetitionYear": "2026",
|
"CompetitionYear": "2026"
|
||||||
"YearlyTheme": "Unity Through Community",
|
|
||||||
"StateAbbrev": "ST",
|
|
||||||
"StateScheduleHandout": {
|
|
||||||
"StudentSpecialEventTypes": [
|
|
||||||
"GeneralSchedule",
|
|
||||||
"SocialGathering"
|
|
||||||
],
|
|
||||||
"StudentExcludeOccurrenceNameSubstrings": [
|
|
||||||
"Store",
|
|
||||||
"TECHSPO",
|
|
||||||
"Tech Expo",
|
|
||||||
"Senior Social",
|
|
||||||
"Help Desk",
|
|
||||||
"Mandatory Advisor Meeting"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"ValidationSettings": {
|
"ValidationSettings": {
|
||||||
"MinRecommendedEvents": 2,
|
"MinRecommendedEvents": 2,
|
||||||
|
|||||||
@@ -48,76 +48,6 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-schedule-table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table th,
|
|
||||||
.state-schedule-table td,
|
|
||||||
.state-schedule-table table th,
|
|
||||||
.state-schedule-table table td {
|
|
||||||
vertical-align: top !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.combined-sub-event {
|
|
||||||
padding-left: 1.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.state-schedule-handout {
|
|
||||||
margin-left: -30pt !important;
|
|
||||||
margin-right: -12pt !important;
|
|
||||||
padding-left: 0 !important;
|
|
||||||
padding-right: 0 !important;
|
|
||||||
width: calc(100% + 42pt) !important;
|
|
||||||
max-width: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-handout .mud-paper,
|
|
||||||
.state-schedule-handout .mud-table-container,
|
|
||||||
.state-schedule-handout .mud-table {
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table,
|
|
||||||
.state-schedule-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table th,
|
|
||||||
.state-schedule-table td,
|
|
||||||
.state-schedule-table table th,
|
|
||||||
.state-schedule-table table td {
|
|
||||||
vertical-align: top;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-top: none !important;
|
|
||||||
border-left: none !important;
|
|
||||||
border-right: none !important;
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table thead th,
|
|
||||||
.state-schedule-table table thead th {
|
|
||||||
border-bottom: 2px solid #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table tbody td,
|
|
||||||
.state-schedule-table table tbody td {
|
|
||||||
border-bottom: 1px solid #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table tbody tr:last-child td,
|
|
||||||
.state-schedule-table table tbody tr:last-child td {
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-schedule-table .combined-sub-event,
|
|
||||||
.state-schedule-table table .combined-sub-event {
|
|
||||||
padding-left: 1.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -26,9 +26,8 @@ services:
|
|||||||
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
# Option 1: Volume-mounted secrets file (recommended for easy editing)
|
||||||
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
|
||||||
|
|
||||||
# Database persistence — must use capital D (/app/Data). Linux paths are
|
# Database persistence
|
||||||
# case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
|
- ./data:/app/data
|
||||||
- ./data:/app/Data
|
|
||||||
|
|
||||||
# HTTPS certificate (if needed)
|
# HTTPS certificate (if needed)
|
||||||
# - ./certs:/https:ro
|
# - ./certs:/https:ro
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user