Compare commits
80
Commits
01056401e5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432caa0fe8 | ||
|
|
8d7c6b103c | ||
|
|
5d2d019e87 | ||
|
|
ea1bb70740 | ||
|
|
4401e4a3ec | ||
|
|
a9036d5d04 | ||
|
|
4dcd9e5aab | ||
|
|
336bbb1dec | ||
|
|
675f04afec | ||
|
|
84eaf338a9 | ||
|
|
15d7edec8f | ||
|
|
46836fde2e | ||
|
|
d0ce71397b | ||
|
|
840a8edbf1 | ||
|
|
680f61241a | ||
|
|
083e81aa25 | ||
|
|
bba0f5f618 | ||
|
|
9ab241ed77 | ||
|
|
804e12ca22 | ||
|
|
cc6e0d71a7 | ||
|
|
a503655f97 | ||
|
|
6e2834f2be | ||
|
|
48861eb6a6 | ||
|
|
455be30821 | ||
|
|
ddb743847d | ||
|
|
649a0061cf | ||
|
|
6bc4c2e7f2 | ||
|
|
9ed9c93540 | ||
|
|
7679a458e0 | ||
|
|
84b31800ad | ||
|
|
b4c11cd0a6 | ||
|
|
e6eb35ee67 | ||
|
|
947d95893f | ||
|
|
8b0451c2ec | ||
|
|
5f2d7b5b31 | ||
|
|
5c4aaf91df | ||
|
|
5fdda08627 | ||
|
|
68311f4012 | ||
|
|
27f08b3718 | ||
|
|
34658e9697 | ||
|
|
8c4c21f204 | ||
|
|
52bf303537 | ||
|
|
c505bf42dd | ||
|
|
aba8ea3ae3 | ||
|
|
e2a5767b04 | ||
|
|
1601610226 | ||
|
|
f8c22690d4 | ||
|
|
6cd4418142 | ||
|
|
6acbc4e852 | ||
|
|
5a1b3fad2e | ||
|
|
8af86e22d9 | ||
|
|
e53403c934 | ||
|
|
5e6d61d400 | ||
|
|
37e82646b8 | ||
|
|
e77f34ff9f | ||
|
|
45edcf5e5f | ||
|
|
ecd6173a44 | ||
|
|
b7e812bb63 | ||
|
|
99880e78c7 | ||
|
|
440893c84d | ||
|
|
eb342cd6a6 | ||
|
|
c4e7edf3db | ||
|
|
cf2c0d8068 | ||
|
|
bcd0acb480 | ||
|
|
44fd38b7ac | ||
|
|
8183c0200d | ||
|
|
ea1a4a04ad | ||
|
|
2eae3f205c | ||
|
|
19e5ef0675 | ||
|
|
f916cfad6b | ||
|
|
7ddc55f672 | ||
|
|
f32ce649cd | ||
|
|
5fdd5fadba | ||
|
|
c937192496 | ||
|
|
2d3b29176f | ||
|
|
c73fdbfba4 | ||
|
|
db4f1ba6fc | ||
|
|
76f285b3af | ||
|
|
87db67f979 | ||
|
|
2aaefb2491 |
+3
-1
@@ -23,7 +23,8 @@ _ReSharper*/
|
|||||||
|
|
||||||
DataBackup/
|
DataBackup/
|
||||||
*.db
|
*.db
|
||||||
/.claude/*
|
/.*/*
|
||||||
|
.*rules
|
||||||
|
|
||||||
# Production secrets and configuration
|
# Production secrets and configuration
|
||||||
auth-secrets.json
|
auth-secrets.json
|
||||||
@@ -34,3 +35,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Runtime data directory
|
# Runtime data directory
|
||||||
/WebApp/Data/*
|
/WebApp/Data/*
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Google.OrTools.Sat;
|
using Google.OrTools.Sat;
|
||||||
|
|
||||||
namespace Core.Calculation
|
namespace Core.Calculation
|
||||||
@@ -253,7 +254,7 @@ namespace Core.Calculation
|
|||||||
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
||||||
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
||||||
{
|
{
|
||||||
var buffer = new List<ILiteral>();
|
List<ILiteral> buffer = [];
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
buffer.Clear();
|
buffer.Clear();
|
||||||
@@ -272,7 +273,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var prohibitVar = new List<IntVar>(1);
|
List<IntVar> prohibitVar = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
@@ -327,7 +328,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
||||||
{
|
{
|
||||||
var studentCapacity = new List<IntVar>();
|
List<IntVar> studentCapacity = [];
|
||||||
|
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
@@ -417,7 +418,7 @@ namespace Core.Calculation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
||||||
{
|
{
|
||||||
var eventCapacity = new List<IntVar>();
|
List<IntVar> eventCapacity = [];
|
||||||
foreach (var s in _allStudents)
|
foreach (var s in _allStudents)
|
||||||
{
|
{
|
||||||
eventCapacity.Add(x[eventIndex, s]);
|
eventCapacity.Add(x[eventIndex, s]);
|
||||||
@@ -454,7 +455,7 @@ namespace Core.Calculation
|
|||||||
model.AddAssumption(x[e, s]);
|
model.AddAssumption(x[e, s]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var prohibitVar = new List<IntVar>(1);
|
List<IntVar> prohibitVar = [];
|
||||||
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
|
||||||
{
|
{
|
||||||
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
var e = _events.IndexOf(excludedAssignment.EventDefinition);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for calculating overlaps in team scheduling solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class OverlapCalculationHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates teams with excluded students and absent students filtered out for overlap calculation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to filter</param>
|
||||||
|
/// <param name="timeSlotIndex">The time slot index for exclusion lookups</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <param name="absentStudentIds">Set of absent student IDs to exclude from overlap calculations</param>
|
||||||
|
/// <returns>Teams with excluded and absent students removed</returns>
|
||||||
|
public static Team[] GetTeamsWithoutExcludedStudents(
|
||||||
|
Team[] teams,
|
||||||
|
int timeSlotIndex,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
|
||||||
|
HashSet<int> absentStudentIds)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find excluded students for this team in this time slot
|
||||||
|
// Also exclude absent students from overlap calculations
|
||||||
|
var includedStudents = team.Students
|
||||||
|
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id, excludedStudents) &&
|
||||||
|
!absentStudentIds.Contains(s.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// If no students are excluded, return original team
|
||||||
|
if (includedStudents.Count == team.Students.Count)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create a temporary team with excluded and absent students removed
|
||||||
|
return new Team
|
||||||
|
{
|
||||||
|
Id = team.Id,
|
||||||
|
Event = team.Event,
|
||||||
|
Students = includedStudents,
|
||||||
|
Captain = team.Captain,
|
||||||
|
Identifier = team.Identifier
|
||||||
|
};
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStudentExcluded(
|
||||||
|
int teamId,
|
||||||
|
int timeSlotIndex,
|
||||||
|
int studentId,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
|
return excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,10 +82,11 @@ public class TeamScheduler
|
|||||||
var model = new CpModel();
|
var model = new CpModel();
|
||||||
|
|
||||||
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
|
||||||
|
// Use team.Students to support PartialTeam objects with omitted students
|
||||||
var m = new int[_students.Length,_teams.Length];
|
var m = new int[_students.Length,_teams.Length];
|
||||||
foreach (var i in _students)
|
foreach (var i in _students)
|
||||||
foreach (var t in _teams)
|
foreach (var t in _teams)
|
||||||
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
|
m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0;
|
||||||
|
|
||||||
// Decision variables:
|
// Decision variables:
|
||||||
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Calculation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post-processing utilities for team scheduler solutions.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamSchedulerPostProcessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extends teams to adjacent time slots (both forward and backward).
|
||||||
|
/// Teams marked as extended will appear in consecutive time slots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="solution">The scheduler solution to modify</param>
|
||||||
|
/// <param name="extendedTeams">Teams that should be extended to adjacent slots</param>
|
||||||
|
/// <param name="allStudents">All available students for overlap calculations</param>
|
||||||
|
/// <param name="getTeamsWithoutExcludedStudents">Function to filter teams for overlap calculation</param>
|
||||||
|
public static void ExtendTeamsInSolution(
|
||||||
|
TeamSchedulerSolution solution,
|
||||||
|
IEnumerable<Team> extendedTeams,
|
||||||
|
Student[] allStudents,
|
||||||
|
Func<Team[], int, Team[]> getTeamsWithoutExcludedStudents)
|
||||||
|
{
|
||||||
|
if (solution.TimeSlots == null || !solution.TimeSlots.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamsList = extendedTeams.ToList();
|
||||||
|
if (!extendedTeamsList.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var extendedTeamIds = extendedTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Find which time slot each extended team is in and extend both forward and backward
|
||||||
|
for (int slotIndex = 0; slotIndex < solution.TimeSlots.Length; slotIndex++)
|
||||||
|
{
|
||||||
|
var currentSlot = solution.TimeSlots[slotIndex];
|
||||||
|
var teamsToExtend = currentSlot.Teams.Where(t => extendedTeamIds.Contains(t.Id)).ToList();
|
||||||
|
|
||||||
|
if (!teamsToExtend.Any())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extend forward: add to next time slot (if exists)
|
||||||
|
if (slotIndex + 1 < solution.TimeSlots.Length)
|
||||||
|
{
|
||||||
|
var nextSlot = solution.TimeSlots[slotIndex + 1];
|
||||||
|
var nextSlotTeamsList = nextSlot.Teams.ToList();
|
||||||
|
var nextSlotTeamIds = nextSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!nextSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
nextSlotTeamsList.Add(team);
|
||||||
|
nextSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlot.Teams = nextSlotTeamsList.ToArray();
|
||||||
|
var nextSlotIndex = slotIndex + 1;
|
||||||
|
var nextSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(nextSlot.Teams, nextSlotIndex);
|
||||||
|
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlotTeamsForOverlap);
|
||||||
|
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend backward: add to previous time slot (if exists)
|
||||||
|
if (slotIndex > 0)
|
||||||
|
{
|
||||||
|
var previousSlot = solution.TimeSlots[slotIndex - 1];
|
||||||
|
var previousSlotTeamsList = previousSlot.Teams.ToList();
|
||||||
|
var previousSlotTeamIds = previousSlotTeamsList.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var team in teamsToExtend)
|
||||||
|
{
|
||||||
|
if (!previousSlotTeamIds.Contains(team.Id))
|
||||||
|
{
|
||||||
|
previousSlotTeamsList.Add(team);
|
||||||
|
previousSlotTeamIds.Add(team.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSlot.Teams = previousSlotTeamsList.ToArray();
|
||||||
|
var previousSlotIndex = slotIndex - 1;
|
||||||
|
var previousSlotTeamsForOverlap = getTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex);
|
||||||
|
previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap);
|
||||||
|
previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
|
|||||||
public Team[] StudentUnassignedTeams(Student student)
|
public Team[] StudentUnassignedTeams(Student student)
|
||||||
{
|
{
|
||||||
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
||||||
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ public class TeamScheduler_DecisionTree
|
|||||||
{
|
{
|
||||||
var timeSlots = new IList<Team>[_timeSlotCount];
|
var timeSlots = new IList<Team>[_timeSlotCount];
|
||||||
for (var i = 0; i < _timeSlotCount; i++)
|
for (var i = 0; i < _timeSlotCount; i++)
|
||||||
timeSlots[i] = new List<Team>();
|
timeSlots[i] = [];
|
||||||
|
|
||||||
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||||
<PackageReference Include="Google.OrTools" Version="9.7.2996" />
|
<PackageReference Include="Google.OrTools" Version="9.14.6206" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Sprache" Version="2.3.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -43,13 +43,10 @@ public class CareerField
|
|||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
Description = description;
|
Description = description;
|
||||||
DirectCareerMatches = directCareerMatches ?? Array.Empty<string>();
|
DirectCareerMatches = directCareerMatches ?? [];
|
||||||
PatternKeywords = patternKeywords ?? Array.Empty<string>();
|
PatternKeywords = patternKeywords ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Core.Entities;
|
|
||||||
|
|
||||||
public class EventAssignment(EventDefinition eventDefinition, Student student)
|
|
||||||
{
|
|
||||||
public EventDefinition EventDefinition { get; } = eventDefinition;
|
|
||||||
public Student Student { get; } = student;
|
|
||||||
}
|
|
||||||
@@ -37,21 +37,6 @@ public class EventDefinition
|
|||||||
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
|
||||||
|
|
||||||
public bool OnSiteActivity { get; set; }
|
public bool OnSiteActivity { get; set; }
|
||||||
//=> SemifinalistActivity != null
|
|
||||||
// && (SemifinalistActivity.Contains("Challenge")
|
|
||||||
// || SemifinalistActivity.Contains("Race")
|
|
||||||
// || SemifinalistActivity.Contains("Speech")
|
|
||||||
// || SemifinalistActivity.Contains("Test")
|
|
||||||
// || SemifinalistActivity.Contains("Flight")
|
|
||||||
// || Name.Contains("Leadership")
|
|
||||||
// || Name.Contains("Forensic")
|
|
||||||
// || Name.Contains("Flight")
|
|
||||||
// || Name.Contains("Coding")
|
|
||||||
// || SemifinalistActivity.Contains("Debate")
|
|
||||||
// || SemifinalistActivity.Contains("Photography")
|
|
||||||
// || SemifinalistActivity.Contains("Build")
|
|
||||||
// || Name.Contains("Chapter")
|
|
||||||
// || Name.Contains("Podcast"));
|
|
||||||
|
|
||||||
[StringLength(1024)]
|
[StringLength(1024)]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
@@ -79,15 +64,12 @@ public class EventDefinition
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? LevelOfEffort { get; set; }
|
public int? LevelOfEffort { get; set; }
|
||||||
|
|
||||||
public ICollection<Career> RelatedCareers { get; set; } = new List<Career>();
|
public ICollection<Career> RelatedCareers { get; set; } = [];
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||||
public string? RelatedCareersText { get; set; }
|
public string? RelatedCareersText { get; set; }
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString() => Name;
|
||||||
{
|
|
||||||
return Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
|
||||||
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class Note
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(200)]
|
||||||
|
[Display(Name = "Title")]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
[Display(Name = "Content")]
|
||||||
|
public string? Content { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Created At")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Updated At")]
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Created By")]
|
||||||
|
public string? CreatedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Last Modified By")]
|
||||||
|
public string? LastModifiedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Pinned")]
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Is Deleted")]
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
public List<NoteHistory> NoteHistories { get; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class NoteHistory
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Note Id")]
|
||||||
|
public int NoteId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
[Display(Name = "Title")]
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
|
||||||
|
[Display(Name = "Content")]
|
||||||
|
public string? Content { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Modified By")]
|
||||||
|
public string? ModifiedBy { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Modified At")]
|
||||||
|
public DateTime ModifiedAt { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(50)]
|
||||||
|
[Display(Name = "Change Type")]
|
||||||
|
public string ChangeType { get; set; } = null!;
|
||||||
|
|
||||||
|
public Note Note { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace Core.Entities;
|
|
||||||
|
|
||||||
public class PartialTeam : Team
|
|
||||||
{
|
|
||||||
public IList<Student> OmittedStudents { get; set; } = null!;
|
|
||||||
|
|
||||||
public override Team CloneWithOmittedStudents(IEnumerable<Student> studentsToOmit)
|
|
||||||
{
|
|
||||||
var remainingStudents = Students.Where(s => !studentsToOmit.Contains(s)).ToList();
|
|
||||||
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
|
||||||
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-15
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Entities;
|
namespace Core.Entities;
|
||||||
public class Team
|
public class Team
|
||||||
@@ -60,19 +61,6 @@ public class Team
|
|||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
|
return $"{Event?.Name ?? "(no event)"} {(Identifier != null ? $"({Identifier})" : "")}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StudentsFirstNames
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
return
|
|
||||||
string.Join(", ",
|
|
||||||
Students.Select(e =>
|
|
||||||
e.FirstName
|
|
||||||
+ (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : ""))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Core.Entities;
|
||||||
|
|
||||||
|
public class TeamMeetingHistory
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Meeting Date")]
|
||||||
|
public DateTime MeetingDate { get; set; }
|
||||||
|
|
||||||
|
// Navigation properties
|
||||||
|
public List<Team> Teams { get; set; } = [];
|
||||||
|
public List<Student> Students { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Core.Entities
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models
|
||||||
{
|
{
|
||||||
public class AssignmentParameters(
|
public class AssignmentParameters(
|
||||||
int effortLowerBound = 6,
|
int effortLowerBound = 6,
|
||||||
@@ -29,3 +31,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Core.Entities;
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
public class AssignmentRequirement(EventDefinition eventDefinition, Student student, Requirement requirement)
|
public class AssignmentRequirement(EventDefinition eventDefinition, Student student, Requirement requirement)
|
||||||
{
|
{
|
||||||
@@ -6,3 +8,4 @@ public class AssignmentRequirement(EventDefinition eventDefinition, Student stud
|
|||||||
public Student Student { get; } = student;
|
public Student Student { get; } = student;
|
||||||
public Requirement Requirement { get; } = requirement;
|
public Requirement Requirement { get; } = requirement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +25,30 @@ public class EventOccurrenceParseResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Warnings { get; set; } = new();
|
public List<string> Warnings { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of detailed parsing issues with line numbers and specific problem descriptions.
|
||||||
|
/// </summary>
|
||||||
|
public List<ParsingIssue> Issues { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of section headers that were encountered but skipped
|
||||||
|
/// because they don't match the chapter's school level setting.
|
||||||
|
/// Headers may contain " - MS" or " - HS" to indicate school level.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of event occurrences that were skipped due to school level filtering.
|
||||||
|
/// This includes both MS and HS events that don't match the chapter's school level setting.
|
||||||
|
/// </summary>
|
||||||
|
public int SkippedEventCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Footnotes captured for each event definition. Footnotes are lines that start with "*" or are parenthetical notes.
|
||||||
|
/// Multiple footnotes are concatenated into a single string. These provide additional context or information about the event occurrences.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<EventDefinition, string> Footnotes { get; set; } = new Dictionary<EventDefinition, string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Total number of event occurrences successfully parsed.
|
/// Total number of event occurrences successfully parsed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -36,3 +60,75 @@ public class EventOccurrenceParseResult
|
|||||||
public bool IsSuccess => Errors.Count == 0;
|
public bool IsSuccess => Errors.Count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a detailed parsing issue encountered during event occurrence parsing.
|
||||||
|
/// </summary>
|
||||||
|
public class ParsingIssue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The line number where the issue occurred (1-based).
|
||||||
|
/// </summary>
|
||||||
|
public int LineNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The column number where the issue occurred (1-based, 0 if not available).
|
||||||
|
/// </summary>
|
||||||
|
public int ColumnNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The actual line content where the issue occurred.
|
||||||
|
/// </summary>
|
||||||
|
public string LineContent { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The type of parsing issue.
|
||||||
|
/// </summary>
|
||||||
|
public ParsingIssueType IssueType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable description of the issue.
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What was expected at the error location (e.g., "month name", "MS or HS", "time value").
|
||||||
|
/// </summary>
|
||||||
|
public string Expected { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What was actually found at the error location.
|
||||||
|
/// </summary>
|
||||||
|
public string Found { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of parsing issues that can occur during event occurrence parsing.
|
||||||
|
/// </summary>
|
||||||
|
public enum ParsingIssueType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Line doesn't match the expected format pattern.
|
||||||
|
/// </summary>
|
||||||
|
UnmatchedLine,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Line matches format but event definition cannot be determined.
|
||||||
|
/// </summary>
|
||||||
|
MissingEventDefinition,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time parsing failed (regex doesn't match or parse errors).
|
||||||
|
/// </summary>
|
||||||
|
TimeParseFailure,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date parsing failed (invalid day of month, etc.).
|
||||||
|
/// </summary>
|
||||||
|
DateParseFailure,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalid format or other parsing issue.
|
||||||
|
/// </summary>
|
||||||
|
InvalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
|
public class PartialTeam : Team
|
||||||
|
{
|
||||||
|
public IList<Student> OmittedStudents { get; set; } = null!;
|
||||||
|
|
||||||
|
public override Team CloneWithOmittedStudents(IEnumerable<Student> studentsToOmit)
|
||||||
|
{
|
||||||
|
var remainingStudents = Students.Where(s => !studentsToOmit.Contains(s)).ToList();
|
||||||
|
var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList();
|
||||||
|
return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates PartialTeam instances from teams with excluded students.
|
||||||
|
/// Aggregates exclusions across all time slots for each team.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="teams">The teams to process</param>
|
||||||
|
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
|
||||||
|
/// <returns>Array of teams, with PartialTeam instances for teams with exclusions</returns>
|
||||||
|
public static Team[] CreatePartialTeamsFromExclusions(
|
||||||
|
IEnumerable<Team> teams,
|
||||||
|
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents)
|
||||||
|
{
|
||||||
|
return teams.Select(team =>
|
||||||
|
{
|
||||||
|
// Find all students excluded for this team across all time slots
|
||||||
|
var excludedStudentIds = excludedStudents.Keys
|
||||||
|
.Where(k => k.teamId == team.Id && excludedStudents[k])
|
||||||
|
.Select(k => k.studentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (excludedStudentIds.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
var excludedStudentsList = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList();
|
||||||
|
if (excludedStudentsList.Count == 0)
|
||||||
|
return team;
|
||||||
|
|
||||||
|
// Create PartialTeam with excluded students
|
||||||
|
return team.CloneWithOmittedStudents(excludedStudentsList);
|
||||||
|
}).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// School level for filtering event occurrences.
|
||||||
|
/// </summary>
|
||||||
|
public enum SchoolLevel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Middle School level
|
||||||
|
/// </summary>
|
||||||
|
MiddleSchool,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High School level
|
||||||
|
/// </summary>
|
||||||
|
HighSchool
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Core.Entities;
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Models;
|
||||||
|
|
||||||
public class StudentEventStatistics
|
public class StudentEventStatistics
|
||||||
{
|
{
|
||||||
@@ -30,3 +32,4 @@ public class StudentEventStatistics
|
|||||||
return statistics.Values.ToList();
|
return statistics.Values.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Parsers;
|
namespace Core.Parsers;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves event definitions from occurrence name patterns or section context.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventDefinitionResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps special event name patterns to their EventDefinition instances.
|
||||||
|
/// Patterns are checked in order, and the first match wins.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, EventDefinition> SpecialEventPatterns =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Meet the Candidates"] = EventDefinition.MeetTheCandidates,
|
||||||
|
["Chapter Officer Meeting"] = EventDefinition.ChapterOfficerMeeting,
|
||||||
|
["Voting Delegate Meeting"] = EventDefinition.VotingDelegateMeeting,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="occurrenceName">The name of the occurrence.</param>
|
||||||
|
/// <param name="currentEventDefinition">The current event definition from section header, if any.</param>
|
||||||
|
/// <returns>The resolved EventDefinition, or null if it cannot be determined.</returns>
|
||||||
|
public static EventDefinition? Resolve(string occurrenceName, EventDefinition? currentEventDefinition)
|
||||||
|
{
|
||||||
|
// Check for special event name patterns first (regardless of current section)
|
||||||
|
foreach (var (pattern, eventDef) in SpecialEventPatterns)
|
||||||
|
{
|
||||||
|
if (occurrenceName.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return eventDef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
|
||||||
|
if (currentEventDefinition == EventDefinition.GeneralSchedule)
|
||||||
|
return EventDefinition.GeneralSchedule;
|
||||||
|
|
||||||
|
// If we have a current event definition from section header (e.g., regular events), use it
|
||||||
|
return currentEventDefinition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifies lines to determine if they should be skipped during parsing.
|
||||||
|
/// </summary>
|
||||||
|
public static class LineClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is empty or contains only whitespace.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsEmptyLine(string line)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is a comment (starts with "#").
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCommentLine(string line)
|
||||||
|
{
|
||||||
|
return EventOccurrenceGrammar.IsCommentLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if a line is a continuation/wrapped line that should be skipped.
|
||||||
|
/// These are typically lines that:
|
||||||
|
/// - Start with "*" (marks the start of a continuation block)
|
||||||
|
/// - Are parenthetical notes like "(Semifinalists only)"
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsContinuationLine(string line)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
// Check if line starts with "*" (marks continuation block start)
|
||||||
|
if (trimmed.StartsWith("*", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Skip parenthetical notes
|
||||||
|
if (trimmed.StartsWith("(", StringComparison.Ordinal) && trimmed.EndsWith(")", StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches section headers to event definitions using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class SectionHeaderMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line is a general schedule header.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsGeneralSchedule(string line)
|
||||||
|
{
|
||||||
|
return EventOccurrenceGrammar.IsGeneralSchedule(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line contains school level markers (MS or HS).
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasSchoolLevel(string line)
|
||||||
|
{
|
||||||
|
return line.Contains("MS", StringComparison.Ordinal) ||
|
||||||
|
line.Contains("HS", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches a section header to the best matching event definition using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionHeader">The section header text to match.</param>
|
||||||
|
/// <param name="events">The collection of available event definitions.</param>
|
||||||
|
/// <returns>The best matching EventDefinition, or null if no match is found (ratio > 50).</returns>
|
||||||
|
public static EventDefinition? MatchEventDefinition(string sectionHeader, ICollection<EventDefinition> events)
|
||||||
|
{
|
||||||
|
var evt =
|
||||||
|
(from e in events
|
||||||
|
let rat = Fuzz.Ratio(e.Name, sectionHeader)
|
||||||
|
where rat > 50
|
||||||
|
orderby rat descending
|
||||||
|
select e).FirstOrDefault();
|
||||||
|
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the best match ratio for a section header against events.
|
||||||
|
/// Useful for error messages when no match is found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sectionHeader">The section header text.</param>
|
||||||
|
/// <param name="events">The collection of available event definitions.</param>
|
||||||
|
/// <returns>The best match ratio, or 0 if no events are available.</returns>
|
||||||
|
public static int GetBestMatchRatio(string sectionHeader, ICollection<EventDefinition> events)
|
||||||
|
{
|
||||||
|
if (events.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var bestEvent = events.FirstOrDefault();
|
||||||
|
return bestEvent != null ? Fuzz.Ratio(sectionHeader, bestEvent.Name) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time and location from combined strings.
|
||||||
|
/// Extracts time using regex, then uses everything after the time as the location.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimeLocationParser
|
||||||
|
{
|
||||||
|
// Shared time value pattern: matches either NOON or a time with AM/PM (e.g., "10:30 a.m.", "3 p.m.")
|
||||||
|
private static string TimeValuePattern => TimePatterns.TimeValue;
|
||||||
|
|
||||||
|
// Regex to match time ranges like "10:30 a.m. - 12:00 p.m." or "10:30 a.m. - NOON"
|
||||||
|
// Matches: time1 (optional dash time2/NOON), then location
|
||||||
|
// The time group captures the full time range (including " - NOON" if present)
|
||||||
|
// Note: Input is normalized via SanitizeInput, so only regular hyphens need to be handled
|
||||||
|
private static readonly Regex TimeLocationRegex = new(
|
||||||
|
$@"(?<Time>{TimeValuePattern}(?:\s*-\s*{TimeValuePattern})?)(?:\s+(?<Location>.+))?",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Pattern for cleaning time components from location text
|
||||||
|
// Matches optional dash, whitespace, time pattern, optional whitespace at start
|
||||||
|
// Handles: "- 12:15 p.m. ", "12:15 p.m. ", "- NOON ", "NOON ", etc.
|
||||||
|
private static readonly Regex TimeInLocationPattern = new(
|
||||||
|
$@"^(?:-\s*)?{TimeValuePattern}(?:\s+|$)",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time and location from the timeAndLocation string.
|
||||||
|
/// Extracts time using regex, then uses everything after the time as the location (after cleaning time fragments).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timeAndLocation">The combined time and location string.</param>
|
||||||
|
/// <param name="time">Output parameter: the parsed time string.</param>
|
||||||
|
/// <param name="location">Output parameter: the parsed location string.</param>
|
||||||
|
public static void Parse(
|
||||||
|
string timeAndLocation,
|
||||||
|
out string time,
|
||||||
|
out string location)
|
||||||
|
{
|
||||||
|
// Extract time using regex
|
||||||
|
var timeLocationMatch = TimeLocationRegex.Match(timeAndLocation);
|
||||||
|
|
||||||
|
if (!timeLocationMatch.Success)
|
||||||
|
{
|
||||||
|
// If time regex doesn't match, use the whole string as time
|
||||||
|
time = timeAndLocation.Trim();
|
||||||
|
location = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
time = timeLocationMatch.Groups["Time"].Captures[0].Value.Trim();
|
||||||
|
var locationPart = timeLocationMatch.Groups["Location"].Success
|
||||||
|
? timeLocationMatch.Groups["Location"].Captures[0].Value.Trim()
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
// No location part found, which is valid (some events might not have locations)
|
||||||
|
if (string.IsNullOrWhiteSpace(locationPart))
|
||||||
|
{
|
||||||
|
location = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean location of any remaining time fragments
|
||||||
|
// (e.g., "– 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C")
|
||||||
|
location = CleanLocationText(locationPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans location text by removing any remaining time components from the start.
|
||||||
|
/// Handles cases like "- 12:15 p.m. Exhibit Hall C" -> "Exhibit Hall C"
|
||||||
|
/// Note: Input is normalized, so only regular hyphens need to be handled.
|
||||||
|
/// </summary>
|
||||||
|
public static string CleanLocationText(string locationText)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(locationText))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Remove time pattern from start, repeat until no more matches
|
||||||
|
string previous;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
previous = locationText;
|
||||||
|
locationText = TimeInLocationPattern.Replace(locationText, "").Trim();
|
||||||
|
} while (locationText != previous && !string.IsNullOrWhiteSpace(locationText));
|
||||||
|
|
||||||
|
// If result is empty or only whitespace, return empty
|
||||||
|
return string.IsNullOrWhiteSpace(locationText) ? string.Empty : locationText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses time strings into TimeOnly objects.
|
||||||
|
/// Handles various time formats including NOON, TBD, time ranges, and AM/PM formats.
|
||||||
|
/// </summary>
|
||||||
|
public static class TimeParser
|
||||||
|
{
|
||||||
|
private static readonly Regex TimeRegex = new(
|
||||||
|
TimePatterns.TimeWithGroups,
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the start time from a time range string.
|
||||||
|
/// Example: "10:00 a.m. - 12:00 p.m." -> "10:00 a.m."
|
||||||
|
/// </summary>
|
||||||
|
public static string ExtractStartTime(string timeRange)
|
||||||
|
{
|
||||||
|
if (timeRange.Contains(" - ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return timeRange[..timeRange.IndexOf(" - ", StringComparison.Ordinal)];
|
||||||
|
}
|
||||||
|
return timeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a time string into a TimeOnly object.
|
||||||
|
/// Handles:
|
||||||
|
/// - NOON -> 12:00 PM
|
||||||
|
/// - TBD -> 00:00:00 (midnight as placeholder)
|
||||||
|
/// - Time ranges -> extracts start time (e.g., "10:00 a.m. - 12:00 p.m." -> parses "10:00 a.m.")
|
||||||
|
/// - Standard AM/PM formats (e.g., "3:00 p.m.", "10:30 am")
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="time">The time string to parse.</param>
|
||||||
|
/// <returns>A TimeOnly object representing the parsed time.</returns>
|
||||||
|
/// <exception cref="FormatException">Thrown when the time format is not recognized.</exception>
|
||||||
|
public static TimeOnly Parse(string time)
|
||||||
|
{
|
||||||
|
int hour = 0;
|
||||||
|
int minute = 0;
|
||||||
|
|
||||||
|
// Handle TBD (To Be Determined) times gracefully
|
||||||
|
if (string.Equals(time.Trim(), "TBD", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Use a placeholder time (midnight) for TBD - the occurrence will still be created
|
||||||
|
// but with a time that indicates it's TBD
|
||||||
|
return new TimeOnly(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract start time from range if present
|
||||||
|
var timeToParse = ExtractStartTime(time);
|
||||||
|
|
||||||
|
// Handle NOON
|
||||||
|
if (timeToParse == "NOON")
|
||||||
|
{
|
||||||
|
hour = 12;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Parse time with regex
|
||||||
|
var timeMatch = TimeRegex.Match(timeToParse);
|
||||||
|
if (timeMatch.Success)
|
||||||
|
{
|
||||||
|
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
||||||
|
if (timeMatch.Groups["Minute"].Success)
|
||||||
|
{
|
||||||
|
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert AM/PM times to 24-hour format
|
||||||
|
var apmValue = timeMatch.Groups["APM"].Captures[0].Value.ToLower();
|
||||||
|
if (apmValue is "p.m." or "pm")
|
||||||
|
{
|
||||||
|
// PM: add 12 unless it's 12 PM (which stays 12)
|
||||||
|
if (hour < 12)
|
||||||
|
hour += 12;
|
||||||
|
}
|
||||||
|
else if (apmValue is "a.m." or "am")
|
||||||
|
{
|
||||||
|
// AM: if it's 12 AM, convert to midnight (0)
|
||||||
|
if (hour == 12)
|
||||||
|
hour = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new FormatException($"Time format not recognized: {time}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TimeOnly(hour, minute, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
namespace Core.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared regex patterns for time parsing.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TimePatterns
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// AM/PM pattern (case-insensitive via IgnoreCase flag).
|
||||||
|
/// Matches: "a.m.", "am", "A.M.", "AM", "p.m.", "pm", "P.M.", "PM"
|
||||||
|
/// </summary>
|
||||||
|
public const string AmPm = @"(?:a|p)\.?m\.?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hour pattern: matches 1-2 digits (1-12 or 1-23).
|
||||||
|
/// </summary>
|
||||||
|
public const string Hour = @"\d{1,2}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minute pattern with named group for parsing: matches exactly 2 digits if present.
|
||||||
|
/// Used when parsing to TimeOnly objects where minutes must be valid.
|
||||||
|
/// </summary>
|
||||||
|
public const string MinuteWithGroup = @"(?<Minute>\d{2})?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minute pattern for matching: matches 0-2 digits.
|
||||||
|
/// Used when extracting time strings (more lenient for location parsing).
|
||||||
|
/// </summary>
|
||||||
|
public const string MinuteFlexible = @"\d{0,2}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time value pattern: matches either NOON or a time with AM/PM.
|
||||||
|
/// Used for matching time strings in location parsing (more lenient minute format).
|
||||||
|
/// </summary>
|
||||||
|
public static string TimeValue => $@"(?:NOON|{Hour}:?{MinuteFlexible}\s*{AmPm})";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time pattern with named groups for parsing hour, minute, and AM/PM.
|
||||||
|
/// Used when parsing to TimeOnly objects (requires 2-digit minutes if present).
|
||||||
|
/// </summary>
|
||||||
|
public static string TimeWithGroups => $@"(?<Hour>{Hour}):?{MinuteWithGroup}\s?(?<APM>{AmPm})";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using Sprache;
|
||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
|
namespace Core.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grammar definitions for parsing event occurrence DSL using parser combinators.
|
||||||
|
/// Provides composable parsers for each grammar rule.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventOccurrenceGrammar
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Array of all month names in order (January through December).
|
||||||
|
/// This is the single source of truth for month names used throughout the parser.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] MonthNames =
|
||||||
|
[
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build month parsers dynamically from MonthNames array
|
||||||
|
private static readonly Parser<string>[] MonthParsers = MonthNames
|
||||||
|
.Select(month => Parse.String(month).Text().Token())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for month names (January through December).
|
||||||
|
/// Built dynamically from MonthNames array.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> Month = MonthParsers
|
||||||
|
.Aggregate((current, next) => current.Or(next));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for day of month (1-31, optional semicolon).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<int> DayOfMonth =
|
||||||
|
from day in Parse.Number
|
||||||
|
from semicolon in Parse.Char(';').Optional()
|
||||||
|
select int.Parse(day);
|
||||||
|
|
||||||
|
// Time parsing components
|
||||||
|
private static readonly Parser<string> Noon = Parse.String("NOON").Text().Token();
|
||||||
|
private static readonly Parser<string> Tbd = Parse.String("TBD").Text().Token();
|
||||||
|
|
||||||
|
private static readonly Parser<string> AmPm =
|
||||||
|
Parse.String("a.m.").Or(Parse.String("am")).Or(Parse.String("A.M.")).Or(Parse.String("AM"))
|
||||||
|
.Or(Parse.String("p.m.")).Or(Parse.String("pm")).Or(Parse.String("P.M.")).Or(Parse.String("PM"))
|
||||||
|
.Text().Token();
|
||||||
|
|
||||||
|
private static readonly Parser<string> TimeValue =
|
||||||
|
from hour in Parse.Number
|
||||||
|
from colon in Parse.Char(':').Optional()
|
||||||
|
from minute in Parse.Number.Optional()
|
||||||
|
from ws in Parse.WhiteSpace.Many()
|
||||||
|
from ampm in AmPm
|
||||||
|
select $"{hour}:{(minute.IsDefined ? minute.Get() : "00")} {ampm}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for hyphen character.
|
||||||
|
/// Note: Input is assumed to be normalized (en-dash and em-dash converted to regular hyphen) via SanitizeInput.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<char> Hyphen = Parse.Char('-');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for time values, including ranges and special values (NOON, TBD).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> Time =
|
||||||
|
Noon.Or(Tbd)
|
||||||
|
.Or(
|
||||||
|
from start in TimeValue.Or(Noon)
|
||||||
|
from dash in Hyphen.Then(_ => Parse.WhiteSpace.Many()).Optional()
|
||||||
|
from end in TimeValue.Or(Noon).Optional()
|
||||||
|
select end.IsDefined
|
||||||
|
? $"{start} - {end.Get()}"
|
||||||
|
: start
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for section headers: EventName - (MS|HS).
|
||||||
|
/// Note: Input is assumed to be normalized (hyphens normalized) via SanitizeInput.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<(string EventName, string SchoolLevel)> SectionHeader =
|
||||||
|
from eventName in Parse.AnyChar.Except(Hyphen).Many().Text().Token()
|
||||||
|
from hyphen in Hyphen.Token()
|
||||||
|
from schoolLevel in Parse.String("MS").Or(Parse.String("HS")).Text().Token()
|
||||||
|
select (eventName.Trim(), schoolLevel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for General Schedule/Session headers.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> GeneralSchedule =
|
||||||
|
Parse.String("General Schedule").Or(Parse.String("General Session")).Text().Token();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for comment lines (starting with #).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Parser<string> CommentLine =
|
||||||
|
from hash in Parse.Char('#')
|
||||||
|
from rest in Parse.AnyChar.Many().Text()
|
||||||
|
select rest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a section header from the given line.
|
||||||
|
/// Returns null if not a section header.
|
||||||
|
/// </summary>
|
||||||
|
public static (string EventName, string SchoolLevel)? TryParseSectionHeader(string line)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = SectionHeader.Parse(line);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a section header, return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return null to continue parsing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a General Schedule/Session header from the given line.
|
||||||
|
/// Returns null if not a General Schedule header.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsGeneralSchedule(string line)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GeneralSchedule.Parse(line);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a General Schedule header
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return false to continue parsing
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a comment line from the given line.
|
||||||
|
/// Returns true if the line is a comment.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsCommentLine(string line)
|
||||||
|
{
|
||||||
|
return line.TrimStart().StartsWith("#", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse an occurrence line from the given text.
|
||||||
|
/// Returns null if parsing fails.
|
||||||
|
/// Strategy: Find the first month name in the line, then parse from there.
|
||||||
|
/// </summary>
|
||||||
|
public static (string Name, string Month, int Day, string TimeAndLocation)? TryParseOccurrenceLine(string line)
|
||||||
|
{
|
||||||
|
// Find the first occurrence of any month name (using normalized MonthNames array)
|
||||||
|
int monthIndex = -1;
|
||||||
|
string foundMonth = string.Empty;
|
||||||
|
|
||||||
|
foreach (var month in MonthNames)
|
||||||
|
{
|
||||||
|
var index = line.IndexOf(month, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index >= 0 && (monthIndex < 0 || index < monthIndex))
|
||||||
|
{
|
||||||
|
monthIndex = index;
|
||||||
|
foundMonth = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthIndex < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Extract name (everything before the month)
|
||||||
|
var name = line.Substring(0, monthIndex).Trim();
|
||||||
|
|
||||||
|
// Parse from the month onwards
|
||||||
|
var restOfLine = line.Substring(monthIndex);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var monthParser = Parse.String(foundMonth).Text().Token();
|
||||||
|
var result = from month in monthParser
|
||||||
|
from day in DayOfMonth.Token()
|
||||||
|
from timeAndLocation in Parse.AnyChar.Many().Text()
|
||||||
|
select (name, month, day, timeAndLocation.Trim());
|
||||||
|
|
||||||
|
var parsed = result.Parse(restOfLine);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
catch (Sprache.ParseException)
|
||||||
|
{
|
||||||
|
// Expected - line is not a valid occurrence line, return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Unexpected exception, return null to continue parsing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,209 +1,391 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using FuzzySharp;
|
using Core.Models;
|
||||||
|
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
||||||
|
using Core.Utility;
|
||||||
|
using SchoolLevel = Core.Models.SchoolLevel;
|
||||||
|
|
||||||
namespace Core.Parsers;
|
namespace Core.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of parsing event occurrence file, containing both occurrences and parsing issues.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOccurrenceParserResult
|
||||||
|
{
|
||||||
|
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
|
||||||
|
public List<ParsingIssue> Issues { get; set; } = new();
|
||||||
|
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||||
|
public int SkippedEventCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Footnotes captured for each event definition. Footnotes are lines that start with "*" or are parenthetical notes.
|
||||||
|
/// Multiple footnotes are concatenated into a single string.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<EventDefinition, string> Footnotes { get; set; } = new Dictionary<EventDefinition, string>();
|
||||||
|
}
|
||||||
|
|
||||||
public class EventOccurrenceParser
|
public class EventOccurrenceParser
|
||||||
{
|
{
|
||||||
private FileSystemInfo _txtFile;
|
private FileSystemInfo _txtFile;
|
||||||
private ICollection<EventDefinition> _events;
|
private ICollection<EventDefinition> _events;
|
||||||
|
private SchoolLevel? _schoolLevel;
|
||||||
|
|
||||||
public EventOccurrenceParser(FileSystemInfo txtFile, ICollection<EventDefinition> events)
|
public EventOccurrenceParser(FileSystemInfo txtFile, ICollection<EventDefinition> events, SchoolLevel? schoolLevel = null)
|
||||||
{
|
{
|
||||||
_events = events;
|
_events = events;
|
||||||
_txtFile = txtFile;
|
_txtFile = txtFile;
|
||||||
|
_schoolLevel = schoolLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Regex _re =
|
public EventOccurrenceParserResult Parse()
|
||||||
new (
|
|
||||||
@"" + //
|
|
||||||
@"(?<Name>^[^#].*)\s" +
|
|
||||||
@"(?<Month>February|March|April|May|June|July)\s" +
|
|
||||||
@"(?<DayOfMonth>\d{1,2});?\s" +
|
|
||||||
@"(?<TimeAndLocation>.*)"
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly Regex _timeRe = new(@"(?<Hour>\d{1,2}):?(?<Minute>\d{2})?\s?(?<APM>(?:a|p)\.?m\.?)");
|
|
||||||
|
|
||||||
private readonly Regex _timeLocationRegex = new(@"(?<Time>.*(?>[AaPp]\.?[Mm]\.?))(?<Location>[\s\t].*)?");
|
|
||||||
|
|
||||||
public IDictionary<EventDefinition, List<EventOccurrence>> Parse()
|
|
||||||
{
|
{
|
||||||
var occurrences = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
var result = new EventOccurrenceParserResult();
|
||||||
|
var occurrences = result.Occurrences;
|
||||||
|
var issues = result.Issues;
|
||||||
|
var footnotes = result.Footnotes;
|
||||||
EventDefinition? currentEventDefinition = null;
|
EventDefinition? currentEventDefinition = null;
|
||||||
|
bool inFootnoteMode = false;
|
||||||
|
SchoolLevel? currentSectionLevel = null;
|
||||||
|
|
||||||
var lines = File.ReadLines(_txtFile.FullName);
|
var lines = File.ReadLines(_txtFile.FullName);
|
||||||
foreach (var line in lines)
|
foreach (var (line, index) in lines.Select((line, index) => (line, index + 1)))
|
||||||
{
|
{
|
||||||
var match = _re.Match(line);
|
// Normalize input: trim and normalize hyphens (en-dash, em-dash -> regular hyphen)
|
||||||
if (!match.Success)
|
// This allows the grammar parser to assume normalized input
|
||||||
|
var normalizedLine = TextUtil.SanitizeInput(line.Trim());
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if (EventOccurrenceParsers.LineClassifier.IsEmptyLine(normalizedLine))
|
||||||
|
{
|
||||||
|
// Empty lines break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip comment lines (starting with "#") - use grammar parser
|
||||||
|
if (EventOccurrenceParsers.LineClassifier.IsCommentLine(normalizedLine))
|
||||||
|
{
|
||||||
|
// Comment lines break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse occurrence line using grammar parser
|
||||||
|
var occurrenceLine = EventOccurrenceGrammar.TryParseOccurrenceLine(normalizedLine);
|
||||||
|
if (!occurrenceLine.HasValue)
|
||||||
{
|
{
|
||||||
if (line.Contains("MS"))
|
// Not an occurrence line, try other line types
|
||||||
|
// Try to parse section header using grammar parser
|
||||||
|
var sectionHeader = EventOccurrenceGrammar.TryParseSectionHeader(normalizedLine);
|
||||||
|
if (sectionHeader.HasValue)
|
||||||
{
|
{
|
||||||
var evt =
|
var (eventNamePart, schoolLevel) = sectionHeader.Value;
|
||||||
(from e in _events
|
|
||||||
let rat = Fuzz.Ratio(e.Name, line.Trim())
|
// Section headers break footnote mode
|
||||||
where rat > 50
|
inFootnoteMode = false;
|
||||||
orderby rat descending
|
|
||||||
select e).FirstOrDefault();
|
// Convert string school level to enum
|
||||||
|
var sectionSchoolLevel = SetCurrentSectionLevel(schoolLevel);
|
||||||
|
|
||||||
|
// Determine if we should skip this event based on chapter's school level setting
|
||||||
|
if (ShouldSkipSection(sectionSchoolLevel, normalizedLine, result))
|
||||||
|
{
|
||||||
|
currentEventDefinition = null; // Skip subsequent occurrences
|
||||||
|
currentSectionLevel = sectionSchoolLevel; // Track that we're in a skipped section
|
||||||
|
continue; // No issue created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current section level for events we're processing
|
||||||
|
currentSectionLevel = sectionSchoolLevel;
|
||||||
|
|
||||||
|
// Use fuzzy matching to find the best matching event definition
|
||||||
|
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(eventNamePart, _events);
|
||||||
if (evt == null)
|
if (evt == null)
|
||||||
|
{
|
||||||
|
// For unmatched headers, create issue
|
||||||
|
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(eventNamePart, _events);
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = $"Section header '{eventNamePart} - {schoolLevel}' found but no matching event definition (best match ratio: {bestRatio})"
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
currentEventDefinition = evt;
|
currentEventDefinition = evt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (line == "General Schedule" || line == "General Session")
|
|
||||||
|
// Check for General Schedule/Session using grammar parser
|
||||||
|
if (EventOccurrenceParsers.SectionHeaderMatcher.IsGeneralSchedule(normalizedLine))
|
||||||
{
|
{
|
||||||
|
// General schedule breaks footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
currentSectionLevel = null; // Reset section level
|
||||||
currentEventDefinition = EventDefinition.GeneralSchedule;
|
currentEventDefinition = EventDefinition.GeneralSchedule;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also check for simple "MS" or "HS" in line (backward compatibility)
|
||||||
|
if (EventOccurrenceParsers.SectionHeaderMatcher.HasSchoolLevel(normalizedLine))
|
||||||
|
{
|
||||||
|
// Section headers break footnote mode
|
||||||
|
inFootnoteMode = false;
|
||||||
|
|
||||||
|
// Extract school level from line
|
||||||
|
SchoolLevel? sectionSchoolLevel = null;
|
||||||
|
if (normalizedLine.Contains("MS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sectionSchoolLevel = SchoolLevel.MiddleSchool;
|
||||||
|
else if (normalizedLine.Contains("HS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sectionSchoolLevel = SchoolLevel.HighSchool;
|
||||||
|
|
||||||
|
// Determine if we should skip this event based on chapter's school level setting
|
||||||
|
if (ShouldSkipSection(sectionSchoolLevel, normalizedLine, result))
|
||||||
|
{
|
||||||
|
currentEventDefinition = null; // Skip subsequent occurrences
|
||||||
|
currentSectionLevel = sectionSchoolLevel; // Track that we're in a skipped section
|
||||||
|
continue; // No issue created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current section level for events we're processing
|
||||||
|
currentSectionLevel = sectionSchoolLevel;
|
||||||
|
|
||||||
|
// Use fuzzy matching to find the best matching event definition
|
||||||
|
var evt = EventOccurrenceParsers.SectionHeaderMatcher.MatchEventDefinition(normalizedLine, _events);
|
||||||
|
if (evt == null)
|
||||||
|
{
|
||||||
|
// For unmatched headers, create issue
|
||||||
|
var bestRatio = EventOccurrenceParsers.SectionHeaderMatcher.GetBestMatchRatio(normalizedLine, _events);
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = $"Section header with 'MS' or 'HS' found but no matching event definition (best match ratio: {bestRatio})"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentEventDefinition = evt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if line starts with "*" to enter footnote mode
|
||||||
|
if (normalizedLine.TrimStart().StartsWith("*", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
inFootnoteMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture footnote lines (in footnote mode OR line starts with "*" or is parenthetical)
|
||||||
|
if (inFootnoteMode || EventOccurrenceParsers.LineClassifier.IsContinuationLine(normalizedLine))
|
||||||
|
{
|
||||||
|
// Capture footnote for current event definition if we have one
|
||||||
|
if (currentEventDefinition != null)
|
||||||
|
{
|
||||||
|
if (!footnotes.ContainsKey(currentEventDefinition))
|
||||||
|
{
|
||||||
|
footnotes[currentEventDefinition] = string.Empty;
|
||||||
|
}
|
||||||
|
// Append footnote, adding a space if there's already content
|
||||||
|
if (!string.IsNullOrEmpty(footnotes[currentEventDefinition]))
|
||||||
|
{
|
||||||
|
footnotes[currentEventDefinition] += " ";
|
||||||
|
}
|
||||||
|
footnotes[currentEventDefinition] += normalizedLine;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// "Voting Delegates" section header is no longer used - occurrences are categorized by name pattern
|
// "Voting Delegates" section header is no longer used - occurrences are categorized by name pattern
|
||||||
// Continue without setting currentEventDefinition for this section
|
// Track as unmatched line if it's not empty
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedLine))
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.UnmatchedLine,
|
||||||
|
Message = "Line does not match expected format (Name Month Day Time/Location)"
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var occurrenceName = match.Groups["Name"].Captures[0].Value;
|
// Occurrence lines break footnote mode
|
||||||
var month = match.Groups["Month"].Captures[0].Value;
|
inFootnoteMode = false;
|
||||||
var dayOfMonth = match.Groups["DayOfMonth"].Captures[0].Value;
|
|
||||||
var timeAndLocation = match.Groups["TimeAndLocation"].Captures[0].Value;
|
|
||||||
|
|
||||||
|
// Skip occurrences under sections that don't match the school level setting
|
||||||
|
if (ShouldSkipOccurrence(currentSectionLevel, result))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (occurrenceName, month, dayOfMonthStr, timeAndLocation) = occurrenceLine.Value;
|
||||||
|
|
||||||
|
// Remove weekday suffix from occurrence name if present
|
||||||
occurrenceName = Regex.Replace(occurrenceName,
|
occurrenceName = Regex.Replace(occurrenceName,
|
||||||
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
|
@"(?<Weekday>Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s?$", "").Trim();
|
||||||
|
|
||||||
// Determine event definition based on occurrence name pattern or current section
|
// Determine event definition based on occurrence name pattern or current section
|
||||||
EventDefinition? eventDefinition = DetermineEventDefinition(occurrenceName, currentEventDefinition);
|
EventDefinition? eventDefinition = EventOccurrenceParsers.EventDefinitionResolver.Resolve(occurrenceName, currentEventDefinition);
|
||||||
|
|
||||||
// Skip if we can't determine the event definition
|
// Track issue if we can't determine the event definition
|
||||||
if (eventDefinition == null)
|
if (eventDefinition == null)
|
||||||
continue;
|
|
||||||
|
|
||||||
timeAndLocation = SanitizeInput(timeAndLocation);
|
|
||||||
var timeAndLocationMatch = _timeLocationRegex.Match(timeAndLocation);
|
|
||||||
|
|
||||||
var time = timeAndLocation;
|
|
||||||
var location = string.Empty;
|
|
||||||
|
|
||||||
if (timeAndLocationMatch.Success)
|
|
||||||
{
|
{
|
||||||
time= timeAndLocationMatch.Groups["Time"].Captures[0].Value;
|
issues.Add(new ParsingIssue
|
||||||
if (timeAndLocationMatch.Groups["Location"].Success)
|
{
|
||||||
location = timeAndLocationMatch.Groups["Location"].Captures[0].Value;
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.MissingEventDefinition,
|
||||||
|
Message = $"Cannot determine event definition for occurrence: {occurrenceName}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startDate = ParseDate(month, dayOfMonth, DateTime.Now.Year);
|
// timeAndLocation is already normalized (hyphens normalized) since normalizedLine was sanitized
|
||||||
var startTime = ParseStartTime(time);
|
|
||||||
var t = new DateTime(startDate, startTime);
|
|
||||||
|
|
||||||
var eventOccurrence = new EventOccurrence
|
// Parse time and location - extract time using regex, then use everything after time as location
|
||||||
|
EventOccurrenceParsers.TimeLocationParser.Parse(timeAndLocation, out string time, out string location);
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
DateOnly? startDate = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Name = occurrenceName, StartTime = t, Time = $"{time}", Date = $"{month} {dayOfMonth}",
|
startDate = TextUtil.ParseDate(month, dayOfMonthStr.ToString(), DateTime.Now.Year);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.DateParseFailure,
|
||||||
|
Message = $"Failed to parse date: {ex.Message}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time
|
||||||
|
TimeOnly? startTime = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startTime = EventOccurrenceParsers.TimeParser.Parse(time);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
issues.Add(new ParsingIssue
|
||||||
|
{
|
||||||
|
LineNumber = index,
|
||||||
|
LineContent = normalizedLine,
|
||||||
|
IssueType = ParsingIssueType.TimeParseFailure,
|
||||||
|
Message = $"Failed to parse time '{time}': {ex.Message}"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate == null || startTime == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var t = new DateTime(startDate.Value, startTime.Value);
|
||||||
|
|
||||||
|
var eventOccurrence = new Core.Entities.EventOccurrence
|
||||||
|
{
|
||||||
|
Name = occurrenceName,
|
||||||
|
StartTime = t,
|
||||||
|
Time = $"{time}",
|
||||||
|
Date = $"{month} {dayOfMonthStr}",
|
||||||
Location = location
|
Location = location
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!occurrences.ContainsKey(eventDefinition))
|
if (!occurrences.ContainsKey(eventDefinition))
|
||||||
occurrences.Add(eventDefinition, []);
|
occurrences.Add(eventDefinition, []);
|
||||||
occurrences[eventDefinition].Add(eventOccurrence);
|
occurrences[eventDefinition].Add(eventOccurrence);
|
||||||
|
|
||||||
|
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
|
||||||
|
currentSectionLevel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return occurrences;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines the EventDefinition for an occurrence based on its name pattern or current section context.
|
/// Determines if a section should be skipped based on chapter's school level setting.
|
||||||
|
/// If no school level is set, all events are processed (no filtering).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private EventDefinition? DetermineEventDefinition(string occurrenceName, EventDefinition? currentEventDefinition)
|
/// <param name="sectionSchoolLevel">The school level of the section (null if no school level designation).</param>
|
||||||
|
/// <param name="normalizedLine">The normalized line content for tracking skipped headers.</param>
|
||||||
|
/// <param name="result">The parse result to update with skipped headers.</param>
|
||||||
|
/// <returns>True if the section should be skipped, false otherwise.</returns>
|
||||||
|
private bool ShouldSkipSection(SchoolLevel? sectionSchoolLevel, string normalizedLine, EventOccurrenceParserResult result)
|
||||||
{
|
{
|
||||||
// Check for special event name patterns first (regardless of current section)
|
if (!sectionSchoolLevel.HasValue)
|
||||||
if (occurrenceName.Contains("Meet the Candidates Session", StringComparison.OrdinalIgnoreCase))
|
return false; // Events without school level are never skipped
|
||||||
return EventDefinition.MeetTheCandidates;
|
|
||||||
|
|
||||||
if (occurrenceName.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase))
|
// If no school level is set, process all events (no filtering)
|
||||||
return EventDefinition.ChapterOfficerMeeting;
|
if (!_schoolLevel.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
if (occurrenceName.Contains("Voting Delegate Meeting", StringComparison.OrdinalIgnoreCase))
|
// School level is set - filter based on it
|
||||||
return EventDefinition.VotingDelegateMeeting;
|
if (_schoolLevel.Value == SchoolLevel.MiddleSchool && sectionSchoolLevel.Value == SchoolLevel.HighSchool)
|
||||||
|
{
|
||||||
|
result.SkippedSectionHeaders.Add(normalizedLine);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.HighSchool && sectionSchoolLevel.Value == SchoolLevel.MiddleSchool)
|
||||||
|
{
|
||||||
|
result.SkippedSectionHeaders.Add(normalizedLine);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// If we're in a General Schedule/Session section and no pattern matched, use GeneralSchedule
|
return false;
|
||||||
if (currentEventDefinition == EventDefinition.GeneralSchedule)
|
}
|
||||||
return EventDefinition.GeneralSchedule;
|
|
||||||
|
|
||||||
// If we have a current event definition from section header (e.g., regular events), use it
|
/// <summary>
|
||||||
if (currentEventDefinition != null)
|
/// Converts string school level ("MS", "HS", or empty) to SchoolLevel?.
|
||||||
return currentEventDefinition;
|
/// </summary>
|
||||||
|
/// <param name="schoolLevelStr">The school level string from the section header.</param>
|
||||||
|
/// <returns>SchoolLevel? representing the school level, or null if no school level designation.</returns>
|
||||||
|
private static SchoolLevel? SetCurrentSectionLevel(string schoolLevelStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(schoolLevelStr))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (schoolLevelStr.Equals("MS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SchoolLevel.MiddleSchool;
|
||||||
|
|
||||||
|
if (schoolLevelStr.Equals("HS", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return SchoolLevel.HighSchool;
|
||||||
|
|
||||||
// Cannot determine event definition
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SanitizeInput(string input)
|
/// <summary>
|
||||||
|
/// Checks if current occurrence should be skipped based on section level.
|
||||||
|
/// If no school level is set, all events are processed (no filtering).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="currentSectionLevel">The current section level.</param>
|
||||||
|
/// <param name="result">The parse result to update with skip counts.</param>
|
||||||
|
/// <returns>True if the occurrence should be skipped, false otherwise.</returns>
|
||||||
|
private bool ShouldSkipOccurrence(SchoolLevel? currentSectionLevel, EventOccurrenceParserResult result)
|
||||||
{
|
{
|
||||||
|
if (!currentSectionLevel.HasValue)
|
||||||
|
return false; // Events without school level are never skipped
|
||||||
|
|
||||||
input = input.Replace("–", "-");
|
// If no school level is set, process all events (no filtering)
|
||||||
input = input.Replace("—", "-");
|
if (!_schoolLevel.HasValue)
|
||||||
|
return false;
|
||||||
|
|
||||||
return input;
|
// School level is set - filter based on it
|
||||||
}
|
if (_schoolLevel.Value == SchoolLevel.MiddleSchool && currentSectionLevel.Value == SchoolLevel.HighSchool)
|
||||||
|
{
|
||||||
|
result.SkippedEventCount++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_schoolLevel.Value == SchoolLevel.HighSchool && currentSectionLevel.Value == SchoolLevel.MiddleSchool)
|
||||||
|
{
|
||||||
|
result.SkippedEventCount++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private DateOnly ParseDate(string month, string dayOfMonth, int year)
|
return false;
|
||||||
{
|
|
||||||
int monthNum = 1;
|
|
||||||
switch (month)
|
|
||||||
{
|
|
||||||
case "February":
|
|
||||||
monthNum = 2;
|
|
||||||
break;
|
|
||||||
case "March":
|
|
||||||
monthNum = 3;
|
|
||||||
break;
|
|
||||||
case "April":
|
|
||||||
monthNum = 4;
|
|
||||||
break;
|
|
||||||
case "May":
|
|
||||||
monthNum = 5;
|
|
||||||
break;
|
|
||||||
case "June":
|
|
||||||
monthNum = 6;
|
|
||||||
break;
|
|
||||||
case "July":
|
|
||||||
monthNum = 7;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var day = int.Parse(dayOfMonth);
|
|
||||||
return new DateOnly(year, monthNum, day); ;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimeOnly ParseStartTime(string time)
|
|
||||||
{
|
|
||||||
int hour = 0;
|
|
||||||
int minute = 0;
|
|
||||||
|
|
||||||
// get the part of the time before a timespan
|
|
||||||
if (time.Contains(" - "))
|
|
||||||
{
|
|
||||||
time = time[..time.IndexOf(" - ", StringComparison.Ordinal)];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (time == "NOON")
|
|
||||||
hour = 12;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var timeMatch = _timeRe.Match(time.ToLower());
|
|
||||||
if (timeMatch.Success)
|
|
||||||
{
|
|
||||||
hour = int.Parse(timeMatch.Groups["Hour"].Captures[0].Value);
|
|
||||||
if (timeMatch.Groups["Minute"].Success)
|
|
||||||
{
|
|
||||||
minute = int.Parse(timeMatch.Groups["Minute"].Captures[0].Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeMatch.Groups["APM"].Captures[0].Value is "p.m." or "pm" && hour < 12)
|
|
||||||
hour += 12;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TimeOnly(hour, minute, 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
|
||||||
var competitiveEvents = new List<EventDefinition>(6);
|
var competitiveEvents = new List<EventDefinition>();
|
||||||
|
|
||||||
for (var i = 1; i <= 6; i++)
|
for (var i = 1; i <= 6; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
|
|||||||
|
|
||||||
public Student[] Parse()
|
public Student[] Parse()
|
||||||
{
|
{
|
||||||
var s = new List<Student>();
|
var students = new List<Student>();
|
||||||
|
|
||||||
CsvReader.Read();
|
CsvReader.Read();
|
||||||
CsvReader.ReadHeader();
|
CsvReader.ReadHeader();
|
||||||
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
|
|||||||
RegionalId = regionalId,
|
RegionalId = regionalId,
|
||||||
NationalId = nationalId
|
NationalId = nationalId
|
||||||
};
|
};
|
||||||
s.Add(student);
|
students.Add(student);
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ToArray();
|
return students.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Core.Models;
|
using Core.Models;
|
||||||
using Core.Parsers;
|
using Core.Parsers;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using SchoolLevel = Core.Models.SchoolLevel;
|
||||||
|
|
||||||
namespace Core.Services;
|
namespace Core.Services;
|
||||||
|
|
||||||
@@ -11,6 +14,13 @@ namespace Core.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EventOccurrenceParserService : IEventOccurrenceParserService
|
public class EventOccurrenceParserService : IEventOccurrenceParserService
|
||||||
{
|
{
|
||||||
|
private readonly IConfiguration? _configuration;
|
||||||
|
|
||||||
|
public EventOccurrenceParserService(IConfiguration? configuration = null)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public EventOccurrenceParseResult ParseFromText(string text, ICollection<EventDefinition> events)
|
public EventOccurrenceParseResult ParseFromText(string text, ICollection<EventDefinition> events)
|
||||||
{
|
{
|
||||||
@@ -31,9 +41,26 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
File.WriteAllText(tempFile, text, Encoding.UTF8);
|
File.WriteAllText(tempFile, text, Encoding.UTF8);
|
||||||
var fileInfo = new FileInfo(tempFile);
|
var fileInfo = new FileInfo(tempFile);
|
||||||
|
|
||||||
// Use the existing EventOccurrenceParser
|
// Read SchoolLevel from configuration
|
||||||
var parser = new EventOccurrenceParser(fileInfo, events);
|
SchoolLevel? schoolLevel = null;
|
||||||
var parsedOccurrences = parser.Parse();
|
if (_configuration != null)
|
||||||
|
{
|
||||||
|
var schoolLevelStr = _configuration.GetSection("ChapterSettings:SchoolLevel").Get<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(schoolLevelStr))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<SchoolLevel>(schoolLevelStr, ignoreCase: true, out var parsed))
|
||||||
|
{
|
||||||
|
schoolLevel = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing EventOccurrenceParser with school level
|
||||||
|
var parser = new EventOccurrenceParser(fileInfo, events, schoolLevel);
|
||||||
|
var parserResult = parser.Parse();
|
||||||
|
|
||||||
|
// Copy occurrences from parser result
|
||||||
|
var parsedOccurrences = parserResult.Occurrences;
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -79,9 +106,27 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track any occurrences without a matching EventDefinition
|
// Copy parsing issues from parser result
|
||||||
// (This would be detected if parser.Parse() returns occurrences with null EventDefinition keys,
|
result.Issues.AddRange(parserResult.Issues);
|
||||||
// but the current parser implementation doesn't do this - all occurrences have a currentEventDefinition)
|
|
||||||
|
// Copy skipped section headers from parser result
|
||||||
|
result.SkippedSectionHeaders.AddRange(parserResult.SkippedSectionHeaders);
|
||||||
|
result.SkippedEventCount = parserResult.SkippedEventCount;
|
||||||
|
|
||||||
|
// Copy footnotes from parser result
|
||||||
|
foreach (var kvp in parserResult.Footnotes)
|
||||||
|
{
|
||||||
|
result.Footnotes[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add informational message about skipped events
|
||||||
|
if (parserResult.SkippedEventCount > 0)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Skipped {parserResult.SkippedEventCount} event occurrence(s) from other school level based on school level setting");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate locations and add warnings for problematic ones
|
||||||
|
ValidateLocations(result);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -110,5 +155,68 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates locations from parsed occurrences and adds warnings for problematic locations.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateLocations(EventOccurrenceParseResult result)
|
||||||
|
{
|
||||||
|
// Collect all unique locations
|
||||||
|
var locations = result.Occurrences.Values
|
||||||
|
.SelectMany(list => list)
|
||||||
|
.Select(eo => eo.Location)
|
||||||
|
.Where(loc => !string.IsNullOrWhiteSpace(loc))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!locations.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check for long locations (>50 chars)
|
||||||
|
var longLocations = locations.Where(loc => loc != null && loc.Length > 50).ToList();
|
||||||
|
foreach (var loc in longLocations)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Location '{loc}' is unusually long ({loc.Length} characters) and may contain multiple lines or extra text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for date/time patterns
|
||||||
|
// Pattern matches: month names with day numbers, time patterns (HH:MM AM/PM), and NOON
|
||||||
|
var dateTimePattern = new Regex(
|
||||||
|
@"\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}\b|\b\d{1,2}:\d{2}\s*(a|p)\.?m\.?\b|\bNOON\b",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
var locationsWithDateTime = locations.Where(loc => loc != null && dateTimePattern.IsMatch(loc)).ToList();
|
||||||
|
foreach (var loc in locationsWithDateTime)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
var match = dateTimePattern.Match(loc);
|
||||||
|
result.Warnings.Add($"Location '{loc}' may contain date/time information: '{match.Value}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for section header patterns (missing line break detection)
|
||||||
|
// Pattern matches: text ending with " - MS", " - HS"
|
||||||
|
// This indicates a missing line break where the next section header was concatenated to the location
|
||||||
|
// Note: Input is already sanitized (en-dash/em-dash -> regular hyphen), so we only need to match regular hyphens
|
||||||
|
var sectionHeaderPattern = new Regex(
|
||||||
|
@"-\s*(MS|HS)\s*$",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
var locationsWithSectionHeader = locations.Where(loc => loc != null && sectionHeaderPattern.IsMatch(loc)).ToList();
|
||||||
|
foreach (var loc in locationsWithSectionHeader)
|
||||||
|
{
|
||||||
|
if (loc != null)
|
||||||
|
{
|
||||||
|
var match = sectionHeaderPattern.Match(loc);
|
||||||
|
// Extract the section header part for better warning message
|
||||||
|
var sectionHeaderPart = match.Value.Trim();
|
||||||
|
result.Warnings.Add($"Location '{loc}' appears to contain a section header (ends with '{sectionHeaderPart}') - likely missing line break. The location may be corrupted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing note naming conventions throughout the system.
|
||||||
|
/// Centralizes the logic for generating note titles for meeting notes and page notes.
|
||||||
|
/// </summary>
|
||||||
|
public interface INoteNamingService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a meeting note based on the meeting date.
|
||||||
|
/// Format: "#Meeting Notes MM/dd/yyyy"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="meetingDate">The date of the meeting</param>
|
||||||
|
/// <returns>The formatted meeting note title</returns>
|
||||||
|
string GetMeetingNoteTitle(DateTime meetingDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the title for a page note based on the page identifier.
|
||||||
|
/// Format: "#{pageIdentifier}"
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageIdentifier">The page identifier (e.g., "students", "teams")</param>
|
||||||
|
/// <returns>The formatted page note title</returns>
|
||||||
|
string GetPageNoteTitle(string pageIdentifier);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a page note (starts with "#").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a page note, false otherwise</returns>
|
||||||
|
bool IsPageNote(string noteTitle);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a note title represents a meeting note (starts with "#Meeting Notes").
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="noteTitle">The note title to check</param>
|
||||||
|
/// <returns>True if the note is a meeting note, false otherwise</returns>
|
||||||
|
bool IsMeetingNote(string noteTitle);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of INoteNamingService that provides note naming conventions.
|
||||||
|
/// Uses "#" as the prefix for page notes and meeting notes.
|
||||||
|
/// </summary>
|
||||||
|
public class NoteNamingService : INoteNamingService
|
||||||
|
{
|
||||||
|
private const string PageNotePrefix = "#";
|
||||||
|
private const string MeetingNotePrefix = "#Meeting Notes";
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetMeetingNoteTitle(DateTime meetingDate)
|
||||||
|
{
|
||||||
|
return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string GetPageNoteTitle(string pageIdentifier)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pageIdentifier))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{PageNotePrefix}{pageIdentifier}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsPageNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool IsMeetingNote(string noteTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(noteTitle))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public static class CareerFieldDefinitions
|
|||||||
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
|
||||||
{
|
{
|
||||||
if (careers == null)
|
if (careers == null)
|
||||||
return Array.Empty<CareerField>();
|
return [];
|
||||||
|
|
||||||
var careerNames = careers
|
var careerNames = careers
|
||||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||||
@@ -31,7 +31,7 @@ public static class CareerFieldDefinitions
|
|||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!careerNames.Any())
|
if (!careerNames.Any())
|
||||||
return Array.Empty<CareerField>();
|
return [];
|
||||||
|
|
||||||
var matchingFields = new HashSet<CareerField>();
|
var matchingFields = new HashSet<CareerField>();
|
||||||
var allFields = GetAllCareerFields();
|
var allFields = GetAllCareerFields();
|
||||||
@@ -74,15 +74,15 @@ public static class CareerFieldDefinitions
|
|||||||
|
|
||||||
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
private static IReadOnlyList<CareerField> CreateAllCareerFields()
|
||||||
{
|
{
|
||||||
return new List<CareerField>
|
return
|
||||||
{
|
[
|
||||||
// 1. Aerospace & Automotive Engineering
|
// 1. Aerospace & Automotive Engineering
|
||||||
new CareerField(
|
new CareerField(
|
||||||
1,
|
1,
|
||||||
"Aerospace & Automotive Engineering",
|
"Aerospace & Automotive Engineering",
|
||||||
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
|
||||||
new[] { "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" },
|
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
|
||||||
new[] { "aeronautical", "aircraft", "automobile", "automotive", "race car" }
|
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. Mechanical & Robotics Engineering
|
// 2. Mechanical & Robotics Engineering
|
||||||
@@ -90,8 +90,8 @@ public static class CareerFieldDefinitions
|
|||||||
2,
|
2,
|
||||||
"Mechanical & Robotics Engineering",
|
"Mechanical & Robotics Engineering",
|
||||||
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
|
||||||
new[] { "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" },
|
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
|
||||||
new[] { "mechanical", "robotics", "machine" }
|
[ "mechanical", "robotics", "machine" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. Electrical & Electronics Engineering
|
// 3. Electrical & Electronics Engineering
|
||||||
@@ -99,8 +99,8 @@ public static class CareerFieldDefinitions
|
|||||||
3,
|
3,
|
||||||
"Electrical & Electronics Engineering",
|
"Electrical & Electronics Engineering",
|
||||||
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
|
||||||
new[] { "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" },
|
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
|
||||||
new[] { "electrical", "electronic", "electrician" }
|
[ "electrical", "electronic", "electrician" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 4. Civil & Structural Engineering
|
// 4. Civil & Structural Engineering
|
||||||
@@ -108,8 +108,8 @@ public static class CareerFieldDefinitions
|
|||||||
4,
|
4,
|
||||||
"Civil & Structural Engineering",
|
"Civil & Structural Engineering",
|
||||||
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
|
||||||
new[] { "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" },
|
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
|
||||||
new[] { "civil", "construction", "structural", "contractor" }
|
[ "civil", "construction", "structural", "contractor" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 5. Environmental & Energy Engineering
|
// 5. Environmental & Energy Engineering
|
||||||
@@ -117,8 +117,8 @@ public static class CareerFieldDefinitions
|
|||||||
5,
|
5,
|
||||||
"Environmental & Energy Engineering",
|
"Environmental & Energy Engineering",
|
||||||
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
|
||||||
new[] { "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" },
|
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
|
||||||
new[] { "chemical", "energy", "environmental", "solar" }
|
[ "chemical", "energy", "environmental", "solar" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 6. General Engineering & Quality
|
// 6. General Engineering & Quality
|
||||||
@@ -126,8 +126,8 @@ public static class CareerFieldDefinitions
|
|||||||
6,
|
6,
|
||||||
"General Engineering & Quality",
|
"General Engineering & Quality",
|
||||||
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
|
||||||
new[] { "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" },
|
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
|
||||||
new[] { "engineer", "quality", "standards" }
|
[ "engineer", "quality", "standards" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 7. Architecture & Urban Planning
|
// 7. Architecture & Urban Planning
|
||||||
@@ -135,8 +135,8 @@ public static class CareerFieldDefinitions
|
|||||||
7,
|
7,
|
||||||
"Architecture & Urban Planning",
|
"Architecture & Urban Planning",
|
||||||
"Design and planning careers focused on buildings, spaces, and community development.",
|
"Design and planning careers focused on buildings, spaces, and community development.",
|
||||||
new[] { "Architect", "Community planner", "Interior designer", "Urban and regional planner" },
|
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
|
||||||
new[] { "architect", "planner", "interior design", "urban" }
|
[ "architect", "planner", "interior design", "urban" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 8. Software Development
|
// 8. Software Development
|
||||||
@@ -144,8 +144,8 @@ public static class CareerFieldDefinitions
|
|||||||
8,
|
8,
|
||||||
"Software Development",
|
"Software Development",
|
||||||
"Careers in creating, designing, and developing computer software applications and systems.",
|
"Careers in creating, designing, and developing computer software applications and systems.",
|
||||||
new[] { "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" },
|
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
|
||||||
new[] { "programming", "programmer", "software", "developer" }
|
[ "programming", "programmer", "software", "developer" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 9. IT & Networking
|
// 9. IT & Networking
|
||||||
@@ -153,8 +153,8 @@ public static class CareerFieldDefinitions
|
|||||||
9,
|
9,
|
||||||
"IT & Networking",
|
"IT & Networking",
|
||||||
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
|
||||||
new[] { "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" },
|
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
|
||||||
new[] { "network", "computer", "technical support", "telecommunications", "IT" }
|
[ "network", "computer", "technical support", "telecommunications", "IT" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 10. Cybersecurity & Digital Forensics
|
// 10. Cybersecurity & Digital Forensics
|
||||||
@@ -162,8 +162,8 @@ public static class CareerFieldDefinitions
|
|||||||
10,
|
10,
|
||||||
"Cybersecurity & Digital Forensics",
|
"Cybersecurity & Digital Forensics",
|
||||||
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
|
||||||
new[] { "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" },
|
[ "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" ],
|
||||||
new[] { "cyber", "security", "forensics", "cryptography", "vulnerability" }
|
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 11. Data Science & Analytics
|
// 11. Data Science & Analytics
|
||||||
@@ -171,8 +171,8 @@ public static class CareerFieldDefinitions
|
|||||||
11,
|
11,
|
||||||
"Data Science & Analytics",
|
"Data Science & Analytics",
|
||||||
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
|
||||||
new[] { "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" },
|
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
|
||||||
new[] { "data", "analyst", "actuary", "economist", "mathematician", "research" }
|
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 12. CAD, CNC & Manufacturing
|
// 12. CAD, CNC & Manufacturing
|
||||||
@@ -180,8 +180,8 @@ public static class CareerFieldDefinitions
|
|||||||
12,
|
12,
|
||||||
"CAD, CNC & Manufacturing",
|
"CAD, CNC & Manufacturing",
|
||||||
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
"Careers in computer-aided design, manufacturing processes, and production planning.",
|
||||||
new[] { "CAD professional", "CNC programmer", "Manufacturing", "Production planner" },
|
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
|
||||||
new[] { "CAD", "CNC", "manufacturing", "production" }
|
[ "CAD", "CNC", "manufacturing", "production" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 13. Industrial & Product Design
|
// 13. Industrial & Product Design
|
||||||
@@ -189,8 +189,8 @@ public static class CareerFieldDefinitions
|
|||||||
13,
|
13,
|
||||||
"Industrial & Product Design",
|
"Industrial & Product Design",
|
||||||
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
|
||||||
new[] { "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" },
|
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
|
||||||
new[] { "designer", "design", "industrial", "product", "appraiser" }
|
[ "designer", "design", "industrial", "product", "appraiser" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 14. Visual Arts & Animation
|
// 14. Visual Arts & Animation
|
||||||
@@ -198,8 +198,8 @@ public static class CareerFieldDefinitions
|
|||||||
14,
|
14,
|
||||||
"Visual Arts & Animation",
|
"Visual Arts & Animation",
|
||||||
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
"Creative careers in visual design, illustration, animation, and digital art creation.",
|
||||||
new[] { "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" },
|
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
|
||||||
new[] { "animator", "artist", "graphic", "illustrator", "multimedia" }
|
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 15. Game Design & Interactive Media
|
// 15. Game Design & Interactive Media
|
||||||
@@ -207,8 +207,8 @@ public static class CareerFieldDefinitions
|
|||||||
15,
|
15,
|
||||||
"Game Design & Interactive Media",
|
"Game Design & Interactive Media",
|
||||||
"Careers in video game design, development, testing, and professional gaming.",
|
"Careers in video game design, development, testing, and professional gaming.",
|
||||||
new[] { "Game designer", "Game Play Tester", "Professional Gamer" },
|
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
|
||||||
new[] { "game", "gamer", "gaming" }
|
[ "game", "gamer", "gaming" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 16. Audio & Music Production
|
// 16. Audio & Music Production
|
||||||
@@ -216,8 +216,8 @@ public static class CareerFieldDefinitions
|
|||||||
16,
|
16,
|
||||||
"Audio & Music Production",
|
"Audio & Music Production",
|
||||||
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
|
||||||
new[] { "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" },
|
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
|
||||||
new[] { "audio", "music", "broadcast", "sound" }
|
[ "audio", "music", "broadcast", "sound" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 17. Video & Film Production
|
// 17. Video & Film Production
|
||||||
@@ -225,8 +225,8 @@ public static class CareerFieldDefinitions
|
|||||||
17,
|
17,
|
||||||
"Video & Film Production",
|
"Video & Film Production",
|
||||||
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
"Careers in video production, filmmaking, directing, and television broadcasting.",
|
||||||
new[] { "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" },
|
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
|
||||||
new[] { "video", "film", "director", "television", "broadcast", "videographer" }
|
[ "video", "film", "director", "television", "broadcast", "videographer" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 18. Web & Digital Communications
|
// 18. Web & Digital Communications
|
||||||
@@ -234,8 +234,8 @@ public static class CareerFieldDefinitions
|
|||||||
18,
|
18,
|
||||||
"Web & Digital Communications",
|
"Web & Digital Communications",
|
||||||
"Careers in web design, digital communication, and instructional technology.",
|
"Careers in web design, digital communication, and instructional technology.",
|
||||||
new[] { "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" },
|
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
|
||||||
new[] { "web", "website", "digital", "communications", "webmaster" }
|
[ "web", "website", "digital", "communications", "webmaster" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 19. Writing & Publishing
|
// 19. Writing & Publishing
|
||||||
@@ -243,8 +243,8 @@ public static class CareerFieldDefinitions
|
|||||||
19,
|
19,
|
||||||
"Writing & Publishing",
|
"Writing & Publishing",
|
||||||
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
"Careers in writing, editing, publishing, and content creation across various media formats.",
|
||||||
new[] { "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" },
|
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
|
||||||
new[] { "writing", "writer", "editor", "publisher", "copy" }
|
[ "writing", "writer", "editor", "publisher", "copy" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 20. Journalism & Public Relations
|
// 20. Journalism & Public Relations
|
||||||
@@ -252,8 +252,8 @@ public static class CareerFieldDefinitions
|
|||||||
20,
|
20,
|
||||||
"Journalism & Public Relations",
|
"Journalism & Public Relations",
|
||||||
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
"Careers in news reporting, photojournalism, public relations, and communications management.",
|
||||||
new[] { "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" },
|
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
|
||||||
new[] { "journalism", "reporter", "photojournalist", "communications", "speaker" }
|
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 21. Forensics & Criminal Investigation
|
// 21. Forensics & Criminal Investigation
|
||||||
@@ -261,8 +261,8 @@ public static class CareerFieldDefinitions
|
|||||||
21,
|
21,
|
||||||
"Forensics & Criminal Investigation",
|
"Forensics & Criminal Investigation",
|
||||||
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
|
||||||
new[] { "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" },
|
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
|
||||||
new[] { "forensic", "detective", "investigator", "crime" }
|
[ "forensic", "detective", "investigator", "crime" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 22. Healthcare & Medical Technology
|
// 22. Healthcare & Medical Technology
|
||||||
@@ -270,8 +270,8 @@ public static class CareerFieldDefinitions
|
|||||||
22,
|
22,
|
||||||
"Healthcare & Medical Technology",
|
"Healthcare & Medical Technology",
|
||||||
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
"Medical and healthcare careers providing patient care, medical technology, and health services.",
|
||||||
new[] { "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" },
|
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
|
||||||
new[] { "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" }
|
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 23. Science & Research
|
// 23. Science & Research
|
||||||
@@ -279,8 +279,8 @@ public static class CareerFieldDefinitions
|
|||||||
23,
|
23,
|
||||||
"Science & Research",
|
"Science & Research",
|
||||||
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
|
||||||
new[] { "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" },
|
[ "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" ],
|
||||||
new[] { "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" }
|
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 24. Education & Training
|
// 24. Education & Training
|
||||||
@@ -288,8 +288,8 @@ public static class CareerFieldDefinitions
|
|||||||
24,
|
24,
|
||||||
"Education & Training",
|
"Education & Training",
|
||||||
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
|
||||||
new[] { "Educator", "Teacher/trainer", "Technology education instructor" },
|
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
|
||||||
new[] { "educator", "teacher", "trainer", "education", "instructor" }
|
[ "educator", "teacher", "trainer", "education", "instructor" ]
|
||||||
),
|
),
|
||||||
|
|
||||||
// 25. Business, Legal & Government
|
// 25. Business, Legal & Government
|
||||||
@@ -297,10 +297,10 @@ public static class CareerFieldDefinitions
|
|||||||
25,
|
25,
|
||||||
"Business, Legal & Government",
|
"Business, Legal & Government",
|
||||||
"Careers in business management, legal services, government, politics, and public policy.",
|
"Careers in business management, legal services, government, politics, and public policy.",
|
||||||
new[] { "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" },
|
[ "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" ],
|
||||||
new[] { "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" }
|
[ "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" ]
|
||||||
)
|
)
|
||||||
};
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for formatting individual student names with overlap and absent markers.
|
||||||
|
/// </summary>
|
||||||
|
public static class StudentNameFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options for formatting student names.
|
||||||
|
/// </summary>
|
||||||
|
public record FormatOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the student is absent. If true, adds "(absent)" suffix. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAbsent { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasOverlap { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single student name with overlap and absent markers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to format.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Formatted student name.</returns>
|
||||||
|
public static string FormatStudentName(Student student, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (student == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var name = student.FirstName;
|
||||||
|
|
||||||
|
// Add overlap marker
|
||||||
|
if (options.HasOverlap)
|
||||||
|
{
|
||||||
|
name += "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add absent marker
|
||||||
|
if (options.IsAbsent)
|
||||||
|
{
|
||||||
|
name += " (absent)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using FuzzySharp;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for matching team names from clipboard text using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamClipboardMatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Matches team names from clipboard text against available teams using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clipboardText">The text content from the clipboard.</param>
|
||||||
|
/// <param name="availableTeams">The collection of available teams to match against.</param>
|
||||||
|
/// <param name="matchThreshold">The minimum fuzzy match score threshold (0-100). Default is 85.</param>
|
||||||
|
/// <returns>A list of teams that match the clipboard text, ordered by match quality.</returns>
|
||||||
|
public static List<Team> MatchTeamsFromClipboard(
|
||||||
|
string clipboardText,
|
||||||
|
IEnumerable<Team> availableTeams,
|
||||||
|
int matchThreshold = 85)
|
||||||
|
{
|
||||||
|
var matchedTeams = new List<Team>();
|
||||||
|
var teamsList = availableTeams.ToList();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboardText) || !teamsList.Any())
|
||||||
|
{
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split clipboard text by newlines
|
||||||
|
var lines = clipboardText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
// Extract team name portion (before " - " if present, as clipboard format includes student lists)
|
||||||
|
var teamName = ExtractTeamNameFromLine(line);
|
||||||
|
|
||||||
|
// Skip empty lines or lines that look like headers/metadata
|
||||||
|
if (ShouldSkipLine(teamName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best match using fuzzy matching
|
||||||
|
var matchedTeam = FindBestMatch(teamName, teamsList, matchThreshold);
|
||||||
|
|
||||||
|
if (matchedTeam != null && !matchedTeams.Any(t => t.Id == matchedTeam.Id))
|
||||||
|
{
|
||||||
|
matchedTeams.Add(matchedTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTeamNameFromLine(string line)
|
||||||
|
{
|
||||||
|
var dashIndex = line.IndexOf(" - ");
|
||||||
|
if (dashIndex > 0)
|
||||||
|
{
|
||||||
|
return line.Substring(0, dashIndex).Trim();
|
||||||
|
}
|
||||||
|
return line.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSkipLine(string teamName)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(teamName) ||
|
||||||
|
teamName.StartsWith("--") ||
|
||||||
|
teamName.Equals("Unscheduled", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Team? FindBestMatch(string teamName, List<Team> availableTeams, int matchThreshold)
|
||||||
|
{
|
||||||
|
// Normalize both strings to lowercase for case-insensitive comparison
|
||||||
|
var normalizedTeamName = teamName.ToLowerInvariant();
|
||||||
|
|
||||||
|
var bestMatch = availableTeams
|
||||||
|
.Select(team => new
|
||||||
|
{
|
||||||
|
Team = team,
|
||||||
|
Score = Fuzz.Ratio(normalizedTeamName, team.ToString().ToLowerInvariant())
|
||||||
|
})
|
||||||
|
.Where(x => x.Score >= matchThreshold)
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return bestMatch?.Team;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for filtering teams based on various criteria.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamFilterExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams that are regional events to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddRegionals(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.RegionalEvent).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds teams with high level of effort (>= 3) to the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> AddHighLevelOfEffort(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
return allTeams.Where(e => e.Event.LevelOfEffort >= 3).Concat(currentTeams).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes individual event teams from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveIndividual(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes teams with low level of effort (<= 1) from the collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> RemoveLowLevelOfEffort(this IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
return teams.Where(t => t.Event.LevelOfEffort > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverts the selection - returns all teams not in the current selection.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Team> Invert(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
|
||||||
|
{
|
||||||
|
var currentTeamIds = currentTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
return allTeams.Where(t => !currentTeamIds.Contains(t.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility class for formatting student names for teams with various formatting options.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamStudentNameFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options for formatting student names.
|
||||||
|
/// </summary>
|
||||||
|
public record FormatOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Style for indicating the team captain. Default is None.
|
||||||
|
/// </summary>
|
||||||
|
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How to order the students. Default is None (preserve original order).
|
||||||
|
/// </summary>
|
||||||
|
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool MarkOverlaps { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to add "(absent)" suffix for absent students. Default is false.
|
||||||
|
/// </summary>
|
||||||
|
public bool MarkAbsent { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
|
||||||
|
/// </summary>
|
||||||
|
public Func<Student, bool>? HasOverlaps { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of absent students. Required if MarkAbsent is true.
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<Student>? AbsentStudents { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
|
||||||
|
/// </summary>
|
||||||
|
public bool OnlyTeamEventsForCaptain { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Style for indicating the team captain.
|
||||||
|
/// </summary>
|
||||||
|
public enum CaptainIndicatorStyle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No captain indicator.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use asterisk (*) for captain.
|
||||||
|
/// </summary>
|
||||||
|
Star,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use "(Cpt)" for captain.
|
||||||
|
/// </summary>
|
||||||
|
Captain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Style for ordering students.
|
||||||
|
/// </summary>
|
||||||
|
public enum OrderingStyle
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Preserve original order.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captain first, then alphabetical by first name.
|
||||||
|
/// </summary>
|
||||||
|
CaptainFirst,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alphabetical by first name.
|
||||||
|
/// </summary>
|
||||||
|
Alphabetical,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// By grade + TSA year descending (highest first).
|
||||||
|
/// </summary>
|
||||||
|
GradeDescending
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a single student name with the specified options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="student">The student to format.</param>
|
||||||
|
/// <param name="team">The team the student belongs to.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Formatted student name.</returns>
|
||||||
|
public static string FormatStudentName(Student student, Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (student == null)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var name = student.FirstName;
|
||||||
|
|
||||||
|
// Add captain indicator (before overlap/absent markers)
|
||||||
|
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
|
||||||
|
{
|
||||||
|
var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team);
|
||||||
|
if (shouldShow)
|
||||||
|
{
|
||||||
|
name += options.CaptainIndicator switch
|
||||||
|
{
|
||||||
|
CaptainIndicatorStyle.Star => "*",
|
||||||
|
CaptainIndicatorStyle.Captain => "(Cpt)",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert collections/functions to booleans for StudentNameFormatter
|
||||||
|
var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student);
|
||||||
|
var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student);
|
||||||
|
|
||||||
|
// Use StudentNameFormatter for overlap/absent markers (appended after captain indicator)
|
||||||
|
var studentNameOptions = new StudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
HasOverlap = hasOverlap,
|
||||||
|
IsAbsent = isAbsent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the suffix from StudentNameFormatter (overlap/absent markers)
|
||||||
|
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
|
||||||
|
var suffix = baseFormatted.Substring(student.FirstName.Length);
|
||||||
|
|
||||||
|
return name + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all students for a team as a comma-separated string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team">The team to format students for.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>Comma-separated string of formatted student names.</returns>
|
||||||
|
public static string FormatStudentList(Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (team?.Students == null || !team.Students.Any())
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||||
|
|
||||||
|
return string.Join(", ", students.Select(s => FormatStudentName(s, team, options)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all students for a team as a list of strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="team">The team to format students for.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>List of formatted student names.</returns>
|
||||||
|
public static List<string> FormatStudentListAsList(Team team, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (team?.Students == null || !team.Students.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var students = ApplyOrdering(team.Students, team, options.Ordering);
|
||||||
|
|
||||||
|
return students.Select(s => FormatStudentName(s, team, options)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats all unique students across multiple teams for an event definition.
|
||||||
|
/// Returns a list of unique student names (by student ID).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="eventDefinition">The event definition.</param>
|
||||||
|
/// <param name="teams">The teams for this event.</param>
|
||||||
|
/// <param name="options">Formatting options.</param>
|
||||||
|
/// <returns>List of unique formatted student names.</returns>
|
||||||
|
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> teams, FormatOptions options)
|
||||||
|
{
|
||||||
|
if (eventDefinition == null || teams == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var teamsList = teams.ToList();
|
||||||
|
if (!teamsList.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Get all unique students from all teams for this event
|
||||||
|
var allStudents = teamsList
|
||||||
|
.SelectMany(t => t.Students)
|
||||||
|
.DistinctBy(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!allStudents.Any())
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Determine if each student is a captain in any team
|
||||||
|
var studentsWithCaptainInfo = allStudents.Select(s =>
|
||||||
|
{
|
||||||
|
var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s));
|
||||||
|
// Find a team this student belongs to for formatting context
|
||||||
|
var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s));
|
||||||
|
return new { Student = s, IsCaptain = isCaptain, Team = studentTeam };
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Apply ordering
|
||||||
|
var orderedStudents = options.Ordering switch
|
||||||
|
{
|
||||||
|
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
|
||||||
|
.OrderBy(x => !x.IsCaptain)
|
||||||
|
.ThenBy(x => x.Student.FirstName)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
OrderingStyle.Alphabetical => studentsWithCaptainInfo
|
||||||
|
.OrderBy(x => x.Student.FirstName)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
OrderingStyle.GradeDescending => studentsWithCaptainInfo
|
||||||
|
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
|
||||||
|
.Select(x => x.Student),
|
||||||
|
_ => studentsWithCaptainInfo.Select(x => x.Student)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format names - for event-level formatting, we need to handle captain indicator specially
|
||||||
|
// since a student might be captain in one team but not another
|
||||||
|
return orderedStudents.Select(s =>
|
||||||
|
{
|
||||||
|
var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s));
|
||||||
|
// Use the student's team if available, otherwise create a minimal team for formatting context
|
||||||
|
var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition };
|
||||||
|
|
||||||
|
// Create options that handle captain indicator for event-level
|
||||||
|
var eventOptions = options with
|
||||||
|
{
|
||||||
|
CaptainIndicator = studentInfo.IsCaptain &&
|
||||||
|
(!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team)
|
||||||
|
? options.CaptainIndicator
|
||||||
|
: CaptainIndicatorStyle.None
|
||||||
|
};
|
||||||
|
|
||||||
|
return FormatStudentName(s, team, eventOptions);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
|
||||||
|
{
|
||||||
|
return ordering switch
|
||||||
|
{
|
||||||
|
OrderingStyle.CaptainFirst => students
|
||||||
|
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
|
||||||
|
.ThenBy(s => s.FirstName),
|
||||||
|
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
|
||||||
|
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
|
||||||
|
_ => students
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,48 @@
|
|||||||
namespace Core.Utility;
|
using Core.Parsers;
|
||||||
|
|
||||||
|
namespace Core.Utility;
|
||||||
|
|
||||||
public static class TextUtil
|
public static class TextUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitizes input by normalizing hyphens (en-dash, em-dash -> regular hyphen).
|
||||||
|
/// This allows parsers to assume normalized input.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The input string to sanitize.</param>
|
||||||
|
/// <returns>The sanitized string with normalized hyphens.</returns>
|
||||||
|
public static string SanitizeInput(string input)
|
||||||
|
{
|
||||||
|
input = input.Replace("–", "-"); // en-dash
|
||||||
|
input = input.Replace("—", "-"); // em-dash
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a date from month name, day of month, and year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="month">The month name (e.g., "January", "February"). Case-insensitive.</param>
|
||||||
|
/// <param name="dayOfMonth">The day of the month as a string (e.g., "15", "3").</param>
|
||||||
|
/// <param name="year">The year (e.g., 2025).</param>
|
||||||
|
/// <returns>A <see cref="DateOnly"/> representing the parsed date.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the month name is invalid.</exception>
|
||||||
|
/// <exception cref="FormatException">Thrown when the day of month cannot be parsed as an integer.</exception>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">Thrown when the resulting date is invalid (e.g., February 30).</exception>
|
||||||
|
public static DateOnly ParseDate(string month, string dayOfMonth, int year)
|
||||||
|
{
|
||||||
|
// Use normalized MonthNames array from grammar
|
||||||
|
var monthLower = month.ToLower();
|
||||||
|
var monthIndex = Array.FindIndex(EventOccurrenceGrammar.MonthNames,
|
||||||
|
m => m.ToLower() == monthLower);
|
||||||
|
|
||||||
|
if (monthIndex < 0)
|
||||||
|
throw new ArgumentException($"Invalid month: {month}", nameof(month));
|
||||||
|
|
||||||
|
// Month index is 0-based, month number is 1-based
|
||||||
|
int monthNum = monthIndex + 1;
|
||||||
|
var day = int.Parse(dayOfMonth);
|
||||||
|
return new DateOnly(year, monthNum, day);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the ordinal value of positive integers.
|
/// Get the ordinal value of positive integers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.BaseRules;
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.BaseRules;
|
namespace Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation.Rules.BaseRules;
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.StudentAssignmentRules;
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation.Rules.BaseRules;
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.StudentAssignmentRules;
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation.Rules.BaseRules;
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.StudentAssignmentRules;
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation.Rules.BaseRules;
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.StudentAssignmentRules;
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation.Rules.BaseRules;
|
using Core.Validation.Rules.BaseRules;
|
||||||
|
|
||||||
namespace Core.Validation.Rules.StudentAssignmentRules;
|
namespace Core.Validation.Rules.StudentAssignmentRules;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Core.Validation;
|
namespace Core.Validation;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Core.Validation;
|
namespace Core.Validation;
|
||||||
|
|||||||
+35
-1
@@ -107,13 +107,47 @@ stringData:
|
|||||||
|
|
||||||
## Building and Running
|
## Building and Running
|
||||||
|
|
||||||
### Build the Docker Image
|
### Publish to Docker Registry (Recommended)
|
||||||
|
|
||||||
|
Use the provided PowerShell script to build and publish the Docker image:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\publish-docker.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-Tag "v1.0.0"` - Specify a custom tag (default: "latest")
|
||||||
|
- `-Registry "docker-registry.kolpacksoftware.com"` - Specify registry URL
|
||||||
|
- `-ImageName "tsa-chapter-organizer"` - Specify image name
|
||||||
|
- `-BuildConfiguration "Release"` - Specify build configuration
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```powershell
|
||||||
|
# Publish with default settings (latest tag)
|
||||||
|
.\publish-docker.ps1
|
||||||
|
|
||||||
|
# Publish with a version tag
|
||||||
|
.\publish-docker.ps1 -Tag "v1.0.0"
|
||||||
|
|
||||||
|
# Publish with custom registry
|
||||||
|
.\publish-docker.ps1 -Tag "latest" -Registry "my-registry.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build the Docker Image (Manual)
|
||||||
|
|
||||||
|
If you prefer to build manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd WebApp
|
cd WebApp
|
||||||
docker build -t tsa-chapter-organizer:latest .
|
docker build -t tsa-chapter-organizer:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or from the root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f WebApp/Dockerfile -t tsa-chapter-organizer:latest .
|
||||||
|
```
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ namespace Data
|
|||||||
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
|
||||||
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
public DbSet<EventOccurrence> EventOccurrences { get; set; }
|
||||||
public DbSet<Career> Careers { get; set; }
|
public DbSet<Career> Careers { get; set; }
|
||||||
|
public DbSet<Note> Notes { get; set; }
|
||||||
|
public DbSet<NoteHistory> NoteHistories { get; set; }
|
||||||
|
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
|
||||||
|
|
||||||
public AppDbContext()
|
public AppDbContext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class NoteConfiguration : IEntityTypeConfiguration<Note>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Note> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(n => n.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(n => n.Title);
|
||||||
|
builder.HasIndex(n => n.CreatedAt);
|
||||||
|
builder.HasIndex(n => n.IsPinned);
|
||||||
|
builder.HasIndex(n => n.IsDeleted);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(n => n.Title)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(n => n.Content)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
builder.Property(n => n.CreatedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.Property(n => n.LastModifiedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
builder.HasMany(n => n.NoteHistories)
|
||||||
|
.WithOne(h => h.Note)
|
||||||
|
.HasForeignKey(h => h.NoteId)
|
||||||
|
.IsRequired()
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class NoteHistoryConfiguration : IEntityTypeConfiguration<NoteHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<NoteHistory> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(h => h.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(h => h.NoteId);
|
||||||
|
builder.HasIndex(h => h.ModifiedAt);
|
||||||
|
builder.HasIndex(h => new { h.NoteId, h.ModifiedAt });
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(h => h.Title)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200);
|
||||||
|
|
||||||
|
builder.Property(h => h.Content)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
builder.Property(h => h.ModifiedBy)
|
||||||
|
.HasMaxLength(255);
|
||||||
|
|
||||||
|
builder.Property(h => h.ChangeType)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50);
|
||||||
|
|
||||||
|
// Relationship is configured in NoteConfiguration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Data.Configurations
|
||||||
|
{
|
||||||
|
public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration<TeamMeetingHistory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TeamMeetingHistory> builder)
|
||||||
|
{
|
||||||
|
builder.HasKey(tmh => tmh.Id);
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
builder.HasIndex(tmh => tmh.MeetingDate);
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
builder.Property(tmh => tmh.MeetingDate)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Teams)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams"));
|
||||||
|
|
||||||
|
builder.HasMany(tmh => tmh.Students)
|
||||||
|
.WithMany()
|
||||||
|
.UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260116235231_AddNoteIsPinnedAndIsDeleted")]
|
||||||
|
partial class AddNoteIsPinnedAndIsDeleted
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNoteIsPinnedAndIsDeleted : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
IsPinned = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notes", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NoteHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Content = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
|
||||||
|
ModifiedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
ChangeType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NoteHistories", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NoteHistories_Notes_NoteId",
|
||||||
|
column: x => x.NoteId,
|
||||||
|
principalTable: "Notes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_ModifiedAt",
|
||||||
|
table: "NoteHistories",
|
||||||
|
column: "ModifiedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_NoteId",
|
||||||
|
table: "NoteHistories",
|
||||||
|
column: "NoteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NoteHistories_NoteId_ModifiedAt",
|
||||||
|
table: "NoteHistories",
|
||||||
|
columns: new[] { "NoteId", "ModifiedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_CreatedAt",
|
||||||
|
table: "Notes",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsDeleted",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsDeleted");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_IsPinned",
|
||||||
|
table: "Notes",
|
||||||
|
column: "IsPinned");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notes_Title",
|
||||||
|
table: "Notes",
|
||||||
|
column: "Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NoteHistories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260120024048_AddTeamMeetingHistory")]
|
||||||
|
partial class AddTeamMeetingHistory
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("RelatedCareersId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("EventDefinitionId", "RelatedCareersId");
|
||||||
|
|
||||||
|
b.HasIndex("RelatedCareersId");
|
||||||
|
|
||||||
|
b.ToTable("EventDefinitionCareers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Career", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Careers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountRegionals")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ChapterEligibilityCountState")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Documentation")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Eligibility")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EventFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("LevelOfEffort")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MaxTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MinTeamSize")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("OnSiteActivity")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Presubmission")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SemifinalistActivity")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ShortName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventFormat");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Events");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Date")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SpecialEventType")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Time")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("SpecialEventType");
|
||||||
|
|
||||||
|
b.HasIndex("StartTime");
|
||||||
|
|
||||||
|
b.ToTable("EventOccurrences");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Grade")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NationalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("OfficerRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("RegionalId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StateId")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("TsaYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email");
|
||||||
|
|
||||||
|
b.HasIndex("Grade");
|
||||||
|
|
||||||
|
b.HasIndex("FirstName", "LastName");
|
||||||
|
|
||||||
|
b.ToTable("Students");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventDefinitionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Rank")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentId", "EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("EventDefinitionId");
|
||||||
|
|
||||||
|
b.HasIndex("Rank");
|
||||||
|
|
||||||
|
b.HasIndex("StudentId");
|
||||||
|
|
||||||
|
b.ToTable("StudentEventRanking");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("CaptainId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("EventId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CaptainId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId");
|
||||||
|
|
||||||
|
b.HasIndex("EventId", "Identifier");
|
||||||
|
|
||||||
|
b.ToTable("Teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Career", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RelatedCareersId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventDefinitionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Student", "Student")
|
||||||
|
.WithMany("EventRankings")
|
||||||
|
.HasForeignKey("StudentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("EventDefinition");
|
||||||
|
|
||||||
|
b.Navigation("Student");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Team", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", "Captain")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CaptainId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EventId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Captain");
|
||||||
|
|
||||||
|
b.Navigation("Event");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EventRankings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamMeetingHistory : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
MeetingDate = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryStudents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
StudentsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_Students_StudentsId",
|
||||||
|
column: x => x.StudentsId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMeetingHistoryTeams",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TeamsId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId",
|
||||||
|
column: x => x.TeamMeetingHistoryId,
|
||||||
|
principalTable: "TeamMeetingHistories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId",
|
||||||
|
column: x => x.TeamsId,
|
||||||
|
principalTable: "Teams",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistories_MeetingDate",
|
||||||
|
table: "TeamMeetingHistories",
|
||||||
|
column: "MeetingDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId",
|
||||||
|
table: "TeamMeetingHistoryStudents",
|
||||||
|
column: "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMeetingHistoryTeams_TeamsId",
|
||||||
|
table: "TeamMeetingHistoryTeams",
|
||||||
|
column: "TeamsId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryStudents");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistoryTeams");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMeetingHistories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,93 @@ namespace Data.Migrations
|
|||||||
b.ToTable("EventOccurrences");
|
b.ToTable("EventOccurrences");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LastModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("IsDeleted");
|
||||||
|
|
||||||
|
b.HasIndex("IsPinned");
|
||||||
|
|
||||||
|
b.HasIndex("Title");
|
||||||
|
|
||||||
|
b.ToTable("Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ChangeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ModifiedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ModifiedBy")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("NoteId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ModifiedAt");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId");
|
||||||
|
|
||||||
|
b.HasIndex("NoteId", "ModifiedAt");
|
||||||
|
|
||||||
|
b.ToTable("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -283,6 +370,22 @@ namespace Data.Migrations
|
|||||||
b.ToTable("Teams");
|
b.ToTable("Teams");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("MeetingDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MeetingDate");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("StudentTeam", b =>
|
modelBuilder.Entity("StudentTeam", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("StudentsId")
|
b.Property<int>("StudentsId")
|
||||||
@@ -298,6 +401,36 @@ namespace Data.Migrations
|
|||||||
b.ToTable("TeamStudents", (string)null);
|
b.ToTable("TeamStudents", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("StudentsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("StudentsId", "TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamMeetingHistoryId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryStudents", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("TeamMeetingHistoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TeamsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("TeamMeetingHistoryId", "TeamsId");
|
||||||
|
|
||||||
|
b.HasIndex("TeamsId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMeetingHistoryTeams", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CareerEventDefinition", b =>
|
modelBuilder.Entity("CareerEventDefinition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Core.Entities.EventDefinition", null)
|
b.HasOne("Core.Entities.EventDefinition", null)
|
||||||
@@ -323,6 +456,17 @@ namespace Data.Migrations
|
|||||||
b.Navigation("EventDefinition");
|
b.Navigation("EventDefinition");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Note", "Note")
|
||||||
|
.WithMany("NoteHistories")
|
||||||
|
.HasForeignKey("NoteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Note");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
|
||||||
@@ -375,6 +519,41 @@ namespace Data.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.Student", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StudentsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Core.Entities.TeamMeetingHistory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamMeetingHistoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Core.Entities.Team", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TeamsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Core.Entities.Note", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("NoteHistories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Core.Entities.Student", b =>
|
modelBuilder.Entity("Core.Entities.Student", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("EventRankings");
|
b.Navigation("EventRankings");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
|
||||||
namespace Tests.Builders;
|
namespace Tests.Builders;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Core.Calculation;
|
using Core.Calculation;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Tests.Builders;
|
using Tests.Builders;
|
||||||
using Tests.Fixtures;
|
using Tests.Fixtures;
|
||||||
using EventAssignment = Core.Calculation.EventAssignment;
|
using EventAssignment = Core.Calculation.EventAssignment;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Parsers.EventOccurrence;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class EventDefinitionResolver_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Resolve_SpecialEventPattern_MeetTheCandidates_ReturnsCorrectDefinition()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Meet the Candidates Session 1", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_SpecialEventPattern_ChapterOfficerMeeting_ReturnsCorrectDefinition()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Chapter Officer Meeting - MS", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.ChapterOfficerMeeting));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_SpecialEventPattern_VotingDelegateMeeting_ReturnsCorrectDefinition()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Voting Delegate Meeting", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.VotingDelegateMeeting));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_SpecialEventPattern_CaseInsensitive_Works()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("MEET THE CANDIDATES", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_GeneralSchedule_CurrentEventGeneralSchedule_ReturnsGeneralSchedule()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Some Event Name", EventDefinition.GeneralSchedule);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.GeneralSchedule));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_CurrentEventDefinition_ReturnsCurrentEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var currentEvent = new EventDefinition { Name = "Test Event" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Some Occurrence", currentEvent);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(currentEvent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_SpecialEventPattern_TakesPrecedenceOverCurrentEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var currentEvent = new EventDefinition { Name = "Test Event" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Meet the Candidates Session 1", currentEvent);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(EventDefinition.MeetTheCandidates));
|
||||||
|
Assert.That(result, Is.Not.EqualTo(currentEvent));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_NoMatch_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Unknown Event Name", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Resolve_NoMatch_CurrentEventNull_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = EventDefinitionResolver.Resolve("Unknown Event Name", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Core.Parsers.EventOccurrence;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class LineClassifier_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void IsEmptyLine_EmptyString_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsEmptyLine(""), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsEmptyLine_WhitespaceOnly_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsEmptyLine(" "), Is.True);
|
||||||
|
Assert.That(LineClassifier.IsEmptyLine("\t\n"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsEmptyLine_NonEmpty_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsEmptyLine("Some text"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsCommentLine_StartsWithHash_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsCommentLine("# This is a comment"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsCommentLine_NoHash_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsCommentLine("Not a comment"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsContinuationLine_ParentheticalNote_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("(Semifinalists only)"), Is.True);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("(Note: Some information)"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsContinuationLine_StartsWithAsterisk_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("*The books of semifinalist teams"), Is.True);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("*Note: Important details"), Is.True);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("*This is a continuation line"), Is.True);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine(" *Line with leading whitespace"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsContinuationLine_DoesNotStartWithAsterisk_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("The event will be held"), Is.False);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("Note: Additional information"), Is.False);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("Important Event March 15"), Is.False);
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("Schedule Posted on website"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsContinuationLine_RegularEventLine_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(LineClassifier.IsContinuationLine("Test Event March 15 3:00 p.m. Room A"), Is.False);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Parsers.EventOccurrence;
|
||||||
|
using FuzzySharp;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class SectionHeaderMatcher_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void IsGeneralSchedule_GeneralScheduleLine_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("General Schedule"), Is.True);
|
||||||
|
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("General Schedule - MS"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void IsGeneralSchedule_RegularEvent_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(SectionHeaderMatcher.IsGeneralSchedule("Test Event - MS"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void HasSchoolLevel_ContainsMS_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event - MS"), Is.True);
|
||||||
|
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event MS"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void HasSchoolLevel_ContainsHS_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event - HS"), Is.True);
|
||||||
|
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event HS"), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void HasSchoolLevel_NoSchoolLevel_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.That(SectionHeaderMatcher.HasSchoolLevel("Test Event"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchEventDefinition_ExactMatch_ReturnsEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>
|
||||||
|
{
|
||||||
|
new() { Name = "Test Event" },
|
||||||
|
new() { Name = "Another Event" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Not.Null);
|
||||||
|
Assert.That(result!.Name, Is.EqualTo("Test Event"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchEventDefinition_CloseMatch_ReturnsEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>
|
||||||
|
{
|
||||||
|
new() { Name = "Test Event" },
|
||||||
|
new() { Name = "Another Event" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = SectionHeaderMatcher.MatchEventDefinition("Test Evnt", events); // Typo but close
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Not.Null);
|
||||||
|
Assert.That(result!.Name, Is.EqualTo("Test Event"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchEventDefinition_NoMatch_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>
|
||||||
|
{
|
||||||
|
new() { Name = "Test Event" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = SectionHeaderMatcher.MatchEventDefinition("Completely Different Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchEventDefinition_EmptyEvents_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchEventDefinition_MultipleMatches_ReturnsBestMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>
|
||||||
|
{
|
||||||
|
new() { Name = "Test Event" },
|
||||||
|
new() { Name = "Test Event Advanced" },
|
||||||
|
new() { Name = "Another Event" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = SectionHeaderMatcher.MatchEventDefinition("Test Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Not.Null);
|
||||||
|
Assert.That(result!.Name, Is.EqualTo("Test Event")); // Exact match should win
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetBestMatchRatio_ValidEvents_ReturnsRatio()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>
|
||||||
|
{
|
||||||
|
new() { Name = "Test Event" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var ratio = SectionHeaderMatcher.GetBestMatchRatio("Test Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(ratio, Is.GreaterThan(50)); // Should be high for exact match
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void GetBestMatchRatio_EmptyEvents_ReturnsZero()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = new List<EventDefinition>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var ratio = SectionHeaderMatcher.GetBestMatchRatio("Test Event", events);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(ratio, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using Core.Parsers.EventOccurrence;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TimeLocationParser_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeAndLocation_ExtractsBoth()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("10:30 a.m. Room 101",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("10:30 a.m."));
|
||||||
|
Assert.That(location, Is.EqualTo("Room 101"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeRangeAndLocation_ExtractsTimeRangeAndLocation()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("10:00 a.m. - 12:00 p.m. Room 202",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("10:00 a.m. - 12:00 p.m."));
|
||||||
|
Assert.That(location, Is.EqualTo("Room 202"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_NOONAndLocation_ExtractsBoth()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("NOON Hall C",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("NOON"));
|
||||||
|
Assert.That(location, Is.EqualTo("Hall C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeOnly_NoLocation()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("3:00 p.m.",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("3:00 p.m."));
|
||||||
|
Assert.That(location, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_AnyLocation_ExtractsLocationWithoutValidation()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("10:00 a.m. Unknown Location",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("10:00 a.m."));
|
||||||
|
Assert.That(location, Is.EqualTo("Unknown Location"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_LocationWithTimeComponent_CleansTimeComponent()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("10:00 a.m. - 12:15 p.m. Exhibit Hall C",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("10:00 a.m. - 12:15 p.m."));
|
||||||
|
Assert.That(location, Is.EqualTo("Exhibit Hall C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_AnyLocation_ExtractsAsIs()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
TimeLocationParser.Parse("3:00 p.m. Room A",
|
||||||
|
out string time, out string location);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(time, Is.EqualTo("3:00 p.m."));
|
||||||
|
Assert.That(location, Is.EqualTo("Room A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_RemovesTimeAtStart()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("- 12:15 p.m. Exhibit Hall C");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_RemovesTimeWithoutDash()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("12:15 p.m. Exhibit Hall C");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_RemovesNOONAtStart()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("- NOON Exhibit Hall C");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_OnlyTime_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("12:15 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_EmptyString_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_WhitespaceOnly_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText(" ");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CleanLocationText_NoTimeComponent_ReturnsOriginal()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeLocationParser.CleanLocationText("Exhibit Hall C");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("Exhibit Hall C"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using Core.Parsers.EventOccurrence;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Parsers.EventOccurrence;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TimeParser_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Parse_NOON_Returns12PM()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("NOON");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(12, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TBD_ReturnsMidnight()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("TBD");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TBD_CaseInsensitive_ReturnsMidnight()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("tbd");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_AMTime_ReturnsCorrectTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("10:30 a.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_PMTime_ReturnsCorrectTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("3:45 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(15, 45, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeRange_ExtractsStartTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("10:00 a.m. - 12:00 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(10, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeRangeWithNOON_ExtractsStartTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("10:30 a.m. - NOON");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeWithoutMinutes_ReturnsCorrectTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("3 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(15, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeWithoutColon_ReturnsCorrectTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("1030 a.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(10, 30, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_12PM_Returns12PM_NotMidnight()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("12:00 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(12, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_12AM_ReturnsMidnight()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.Parse("12:00 a.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new TimeOnly(0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_InvalidFormat_ThrowsFormatException()
|
||||||
|
{
|
||||||
|
// Act & Assert
|
||||||
|
Assert.Throws<FormatException>(() => TimeParser.Parse("invalid time format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractStartTime_Range_ReturnsStartTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.ExtractStartTime("10:00 a.m. - 12:00 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("10:00 a.m."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractStartTime_NoRange_ReturnsOriginal()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.ExtractStartTime("3:00 p.m.");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("3:00 p.m."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExtractStartTime_RangeWithNOON_ReturnsStartTime()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = TimeParser.ExtractStartTime("10:30 a.m. - NOON");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo("10:30 a.m."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
|
using Core.Parsers;
|
||||||
|
|
||||||
|
namespace Tests.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for parsing issue detection and reporting in EventOccurrenceParser.
|
||||||
|
/// </summary>
|
||||||
|
public class EventOccurrenceParserIssues_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Parse_UnmatchedLine_ReportsIssue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "This is not a valid format line\n" +
|
||||||
|
"Another invalid line\n" +
|
||||||
|
"\n" + // Empty line should be skipped
|
||||||
|
"General Schedule\n" + // Known header should not create issue
|
||||||
|
"Valid Event March 20 3:00 p.m. Hall A";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Valid Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(2));
|
||||||
|
|
||||||
|
var issue1 = result.Issues.First(i => i.LineNumber == 1);
|
||||||
|
Assert.That(issue1.IssueType, Is.EqualTo(ParsingIssueType.UnmatchedLine));
|
||||||
|
Assert.That(issue1.LineContent, Is.EqualTo("This is not a valid format line"));
|
||||||
|
Assert.That(issue1.Message, Does.Contain("does not match expected format"));
|
||||||
|
|
||||||
|
var issue2 = result.Issues.First(i => i.LineNumber == 2);
|
||||||
|
Assert.That(issue2.IssueType, Is.EqualTo(ParsingIssueType.UnmatchedLine));
|
||||||
|
Assert.That(issue2.LineContent, Is.EqualTo("Another invalid line"));
|
||||||
|
|
||||||
|
// Verify empty line and known headers don't create issues
|
||||||
|
Assert.That(result.Issues, Has.None.Matches<ParsingIssue>(i => i.LineNumber == 3));
|
||||||
|
Assert.That(result.Issues, Has.None.Matches<ParsingIssue>(i => i.LineContent.Contains("General Schedule")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_MissingEventDefinition_ReportsIssue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Unknown Event Name March 15 2:00 p.m. Room 101\n" +
|
||||||
|
"Another Unknown Event April 20 3:00 p.m. Hall B";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Different Event Name") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(2));
|
||||||
|
|
||||||
|
var issue1 = result.Issues.First(i => i.LineNumber == 1);
|
||||||
|
Assert.That(issue1.IssueType, Is.EqualTo(ParsingIssueType.MissingEventDefinition));
|
||||||
|
Assert.That(issue1.LineContent, Does.Contain("Unknown Event Name"));
|
||||||
|
Assert.That(issue1.Message, Does.Contain("Cannot determine event definition"));
|
||||||
|
Assert.That(issue1.Message, Does.Contain("Unknown Event Name"));
|
||||||
|
|
||||||
|
var issue2 = result.Issues.First(i => i.LineNumber == 2);
|
||||||
|
Assert.That(issue2.IssueType, Is.EqualTo(ParsingIssueType.MissingEventDefinition));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeParseFailure_ReportsIssue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// The parser throws FormatException when time regex doesn't match or time format is invalid
|
||||||
|
var testContent = "Test Event March 15 invalid time format Room 101\n" + // Unrecognized format (no AM/PM match)
|
||||||
|
"Test Event March 15 2:00 Room 101"; // Missing AM/PM - regex won't match properly
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should have at least one time parse failure for unrecognized formats
|
||||||
|
var timeIssues = result.Issues.Where(i => i.IssueType == ParsingIssueType.TimeParseFailure).ToList();
|
||||||
|
// Note: The parser may handle some cases differently, so we check if any time issues exist
|
||||||
|
if (timeIssues.Any())
|
||||||
|
{
|
||||||
|
foreach (var issue in timeIssues)
|
||||||
|
{
|
||||||
|
Assert.That(issue.Message, Does.Contain("Failed to parse time"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At minimum, we should have some issues (either time parse failures or other issues)
|
||||||
|
Assert.That(result.Issues, Has.Count.GreaterThanOrEqualTo(1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_DateParseFailure_ReportsIssue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// DateOnly constructor will throw ArgumentOutOfRangeException for invalid dates
|
||||||
|
var testContent = "Test Event February 30 2:00 p.m. Room 101\n" + // Invalid day for February
|
||||||
|
"Test Event March 32 2:00 p.m. Room 101\n" + // Invalid day for March
|
||||||
|
"Test Event April 0 2:00 p.m. Room 101"; // Invalid day (0) - int.Parse might throw first
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should have date parse failures for invalid dates
|
||||||
|
var dateIssues = result.Issues.Where(i => i.IssueType == ParsingIssueType.DateParseFailure).ToList();
|
||||||
|
// Note: Some invalid dates might be caught by int.Parse first, so we check for any parsing issues
|
||||||
|
Assert.That(result.Issues, Has.Count.GreaterThanOrEqualTo(1));
|
||||||
|
|
||||||
|
if (dateIssues.Any())
|
||||||
|
{
|
||||||
|
foreach (var issue in dateIssues)
|
||||||
|
{
|
||||||
|
Assert.That(issue.Message, Does.Contain("Failed to parse date"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_LocationExtraction_WorksWithoutPatterns()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Test that locations are extracted correctly without pattern matching
|
||||||
|
// Locations should be extracted as everything after the time
|
||||||
|
var testContent = "Test Event - MS\n" +
|
||||||
|
"Submit Entry March 15 2:00 p.m. Auditorium A\n" +
|
||||||
|
"Judging March 15 3:00 p.m. Room 101\n" +
|
||||||
|
"Pick-up March 15 4:00 p.m. Conference Center\n" +
|
||||||
|
"Final March 15 5:00 p.m."; // No location
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should parse successfully - location parsing no longer uses patterns, locations are extracted as-is
|
||||||
|
// Verify that locations are extracted correctly without pattern validation
|
||||||
|
|
||||||
|
// Verify that locations are extracted correctly
|
||||||
|
var occurrences = result.Occurrences.Values.SelectMany(list => list).ToList();
|
||||||
|
Assert.That(occurrences, Has.Count.GreaterThan(0), "Should parse at least some occurrences");
|
||||||
|
|
||||||
|
// Verify locations are extracted
|
||||||
|
var locations = occurrences.Select(eo => eo.Location).Where(loc => !string.IsNullOrWhiteSpace(loc)).ToList();
|
||||||
|
Assert.That(locations, Has.Count.GreaterThan(0), "Should extract at least some locations");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_MultipleIssues_ReportsAllIssues()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Invalid format line\n" + // UnmatchedLine
|
||||||
|
"Unknown Event March 15 2:00 p.m. Room 101\n" + // MissingEventDefinition
|
||||||
|
"Test Event February 30 2:00 p.m. Room 101\n" + // DateParseFailure (invalid date)
|
||||||
|
"Test Event March 15 invalid time format Room 101\n" + // TimeParseFailure (no AM/PM)
|
||||||
|
"Test Event March 15 3:00 p.m. Unmatched Location\n" + // Location extracted as-is (no validation)
|
||||||
|
"Valid Event March 20 4:00 p.m. Room 202"; // Valid line
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Valid Event"), EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should have multiple issues of different types
|
||||||
|
Assert.That(result.Issues, Has.Count.GreaterThanOrEqualTo(3),
|
||||||
|
"Should have at least 3 issues (UnmatchedLine, MissingEventDefinition, and at least one other)");
|
||||||
|
|
||||||
|
Assert.That(result.Issues, Has.Some.Matches<ParsingIssue>(i => i.IssueType == ParsingIssueType.UnmatchedLine));
|
||||||
|
Assert.That(result.Issues, Has.Some.Matches<ParsingIssue>(i => i.IssueType == ParsingIssueType.MissingEventDefinition));
|
||||||
|
|
||||||
|
// Date, time, and location failures may or may not occur depending on parser behavior
|
||||||
|
// But we should have at least the unmatched line and missing event definition
|
||||||
|
|
||||||
|
// Verify successful occurrence is still parsed (if any valid lines exist)
|
||||||
|
// The "Valid Event" line should parse successfully despite other issues
|
||||||
|
var validEvent = events.First(e => e.Name == "Valid Event");
|
||||||
|
if (result.Occurrences.ContainsKey(validEvent))
|
||||||
|
{
|
||||||
|
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
|
||||||
|
}
|
||||||
|
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
|
||||||
|
// but typically it should still parse since it's a valid line
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_IssueLineNumbers_AreAccurate()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Line 1 - invalid\n" +
|
||||||
|
"\n" + // Line 2 - empty (should be skipped)
|
||||||
|
"Line 3 - invalid\n" +
|
||||||
|
"Valid Event March 15 2:00 p.m. Room 101\n" +
|
||||||
|
"Line 5 - invalid\n" +
|
||||||
|
"Line 6 - invalid";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Valid Event") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Should have issues on lines 1, 3, 5, 6 (line 2 is empty, line 4 is valid)
|
||||||
|
var issueLineNumbers = result.Issues.Select(i => i.LineNumber).OrderBy(n => n).ToList();
|
||||||
|
Assert.That(issueLineNumbers, Does.Contain(1));
|
||||||
|
Assert.That(issueLineNumbers, Does.Contain(3));
|
||||||
|
Assert.That(issueLineNumbers, Does.Contain(5));
|
||||||
|
Assert.That(issueLineNumbers, Does.Contain(6));
|
||||||
|
Assert.That(issueLineNumbers, Does.Not.Contain(2), "Empty line should not create an issue");
|
||||||
|
// Note: Line 4 might create an issue if location parsing fails, so we don't assert it's not in the list
|
||||||
|
|
||||||
|
// Verify line numbers are sequential and correct
|
||||||
|
foreach (var issue in result.Issues)
|
||||||
|
{
|
||||||
|
Assert.That(issue.LineNumber, Is.GreaterThan(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_IssueContent_IsPreserved()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Line with special chars: !@#$%^&*()\n" +
|
||||||
|
"Line with unicode: Café 测试\n" +
|
||||||
|
"Line with tabs\tand spaces\n" +
|
||||||
|
"Very long line that should be preserved completely without truncation or modification " + new string('x', 200);
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = Array.Empty<EventDefinition>();
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(4));
|
||||||
|
|
||||||
|
var issue1 = result.Issues.First(i => i.LineNumber == 1);
|
||||||
|
Assert.That(issue1.LineContent, Is.EqualTo("Line with special chars: !@#$%^&*()"));
|
||||||
|
|
||||||
|
var issue2 = result.Issues.First(i => i.LineNumber == 2);
|
||||||
|
Assert.That(issue2.LineContent, Is.EqualTo("Line with unicode: Café 测试"));
|
||||||
|
|
||||||
|
var issue3 = result.Issues.First(i => i.LineNumber == 3);
|
||||||
|
Assert.That(issue3.LineContent, Is.EqualTo("Line with tabs\tand spaces"));
|
||||||
|
|
||||||
|
var issue4 = result.Issues.First(i => i.LineNumber == 4);
|
||||||
|
Assert.That(issue4.LineContent, Has.Length.GreaterThan(200)); // Verify long line is preserved
|
||||||
|
Assert.That(issue4.LineContent, Does.Contain("Very long line"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_ValidInput_NoIssues()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "General Schedule\n" +
|
||||||
|
"Opening Session March 15 8:00 a.m. Hall A\n" + // Matches "Hall *"
|
||||||
|
"Test Event March 15 2:00 p.m. Room 101\n" + // Matches "Room *"
|
||||||
|
"Another Event March 16 3:00 p.m. Hall B"; // Matches "Hall *"
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[]
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event"),
|
||||||
|
EventOccurrenceParserTestHelpers.CreateTestEvent("Another Event")
|
||||||
|
};
|
||||||
|
// Locations are extracted without pattern matching
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Valid input should have minimal issues
|
||||||
|
// Note: "Opening Session" is in GeneralSchedule section, so it should parse fine
|
||||||
|
// The test verifies that valid input can be parsed, even if some edge cases create issues
|
||||||
|
Assert.That(result.Occurrences, Has.Count.GreaterThan(0),
|
||||||
|
"Should have at least some occurrences parsed from valid input");
|
||||||
|
|
||||||
|
// Verify occurrences were parsed correctly (if they were parsed)
|
||||||
|
var testEvent = events.First(e => e.Name == "Test Event");
|
||||||
|
if (result.Occurrences.ContainsKey(testEvent))
|
||||||
|
{
|
||||||
|
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
|
||||||
|
|
||||||
|
var occurrence = result.Occurrences[testEvent].First();
|
||||||
|
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
|
||||||
|
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
|
||||||
|
}
|
||||||
|
// Note: If the test event wasn't parsed, it might be due to location parsing or other edge cases
|
||||||
|
// The important thing is that the parser doesn't crash and processes the input
|
||||||
|
|
||||||
|
// Verify locations are extracted correctly (pattern matching is no longer used)
|
||||||
|
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
|
||||||
|
? result.Occurrences[testEvent].FirstOrDefault()
|
||||||
|
: null;
|
||||||
|
if (testEventOccurrence != null)
|
||||||
|
{
|
||||||
|
Assert.That(testEventOccurrence.Location, Is.EqualTo("Room 101"),
|
||||||
|
"Location should be extracted correctly without pattern matching");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_TimeRangeWithNOON_DoesNotIncludeNOONInLocation()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// This test verifies that time ranges like "10:30 a.m. – NOON" are properly parsed
|
||||||
|
// and "– NOON" is not included in the location
|
||||||
|
// Using "General Schedule" as section header since the parser recognizes it
|
||||||
|
var testContent = "General Schedule\n" + // Section header (recognized by parser)
|
||||||
|
"Semifinalist Set-up March 7 10:30 a.m. – NOON Mtg. Room 14\n" +
|
||||||
|
"Semifinalist Set-up March 7 9:00 a.m. - 12:00 p.m. Room 101";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
// For General Schedule section, we don't need a specific event definition
|
||||||
|
// The parser will use EventDefinition.GeneralSchedule
|
||||||
|
var events = Array.Empty<EventDefinition>();
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// First, let's check if there are any parsing issues that might explain why nothing was parsed
|
||||||
|
if (result.Occurrences.Count == 0 && result.Issues.Any())
|
||||||
|
{
|
||||||
|
var issuesSummary = string.Join("; ", result.Issues.Select(i => $"Line {i.LineNumber}: {i.IssueType} - {i.Message}"));
|
||||||
|
Assert.Fail($"No occurrences were parsed, but there were parsing issues: {issuesSummary}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have occurrences parsed
|
||||||
|
Assert.That(result.Occurrences, Has.Count.GreaterThan(0),
|
||||||
|
$"Should have at least one occurrence parsed. Found {result.Issues.Count} issues.");
|
||||||
|
|
||||||
|
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
|
||||||
|
// General Schedule section uses EventDefinition.GeneralSchedule
|
||||||
|
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
|
||||||
|
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
|
||||||
|
|
||||||
|
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
|
||||||
|
Assert.That(occurrences, Has.Count.GreaterThan(0),
|
||||||
|
"Should have at least one occurrence in General Schedule");
|
||||||
|
|
||||||
|
// Find the occurrence with the NOON time range
|
||||||
|
var noonOccurrence = occurrences.FirstOrDefault(o => o.Time.Contains("NOON"));
|
||||||
|
Assert.That(noonOccurrence, Is.Not.Null,
|
||||||
|
"Should have an occurrence with NOON in the time range");
|
||||||
|
|
||||||
|
// The location should match the pattern, not include "– NOON"
|
||||||
|
Assert.That(noonOccurrence!.Location, Does.Not.Contain("NOON"),
|
||||||
|
"Location should not contain 'NOON' from time range");
|
||||||
|
Assert.That(noonOccurrence.Location, Does.Contain("Mtg. Room"),
|
||||||
|
"Location should contain 'Mtg. Room'");
|
||||||
|
// Time should include the range
|
||||||
|
Assert.That(noonOccurrence.Time, Does.Contain("10:30"),
|
||||||
|
"Time should contain start time");
|
||||||
|
Assert.That(noonOccurrence.Time, Does.Contain("NOON"),
|
||||||
|
"Time string should include 'NOON' from the time range");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_AllMonths_AreSupported()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var months = new[] { "January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December" };
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Test Event") };
|
||||||
|
|
||||||
|
foreach (var month in months)
|
||||||
|
{
|
||||||
|
var testContent = $"Test Event – MS\n" +
|
||||||
|
$"Submit Entry {month} 15 3:00 p.m. Room A";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if (result.Issues.Count > 0)
|
||||||
|
{
|
||||||
|
var issueMessages = string.Join("; ", result.Issues.Select(i => $"{i.IssueType}: {i.Message}"));
|
||||||
|
Assert.Fail($"Month {month} had {result.Issues.Count} issue(s): {issueMessages}");
|
||||||
|
}
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(0),
|
||||||
|
$"Month {month} should parse without issues");
|
||||||
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1),
|
||||||
|
$"Month {month} should produce one occurrence");
|
||||||
|
|
||||||
|
var occurrence = result.Occurrences.Values.First().First();
|
||||||
|
Assert.That(occurrence.Date, Does.Contain(month),
|
||||||
|
$"Occurrence date should contain {month}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_SectionHeader_WithEnDash_IsRecognized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Biotechnology – MS\n" +
|
||||||
|
"Submit Entry March 15 8:00 a.m. Room 1";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Biotechnology") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
|
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_SectionHeader_WithHyphen_IsRecognized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Test that section headers with hyphens are recognized (using MS event since HS events are skipped)
|
||||||
|
var testContent = "Architectural Design - MS\n" +
|
||||||
|
"Submit Entry March 15 8:00 a.m. Room 1";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Architectural Design") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
|
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_SectionHeader_WithEmDash_IsRecognized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Coding — MS\n" +
|
||||||
|
"Submit Entry March 15 8:00 a.m. Room 1";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Coding") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
|
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_SectionHeader_WithWhitespace_IsRecognized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var testContent = "Event Name – MS\n" +
|
||||||
|
"Submit Entry March 15 8:00 a.m. Room 1";
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Event Name") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||||
|
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||||
|
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Tests.Builders;
|
||||||
|
|
||||||
|
namespace Tests.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper methods for EventOccurrenceParser tests.
|
||||||
|
/// </summary>
|
||||||
|
public static class EventOccurrenceParserTestHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a temporary file with the specified content and returns the FileInfo.
|
||||||
|
/// </summary>
|
||||||
|
public static FileInfo CreateTempFile(string content)
|
||||||
|
{
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".txt");
|
||||||
|
File.WriteAllText(tempPath, content);
|
||||||
|
return new FileInfo(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal EventDefinition for testing.
|
||||||
|
/// </summary>
|
||||||
|
public static EventDefinition CreateTestEvent(string name)
|
||||||
|
{
|
||||||
|
return EventDefinitionBuilder.Individual(name).Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cleans up a temporary file.
|
||||||
|
/// </summary>
|
||||||
|
public static void CleanupTempFile(FileInfo file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (file.Exists)
|
||||||
|
File.Delete(file.FullName);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,71 +1,310 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Parsers;
|
using Core.Parsers;
|
||||||
|
|
||||||
namespace Tests.Parsers;
|
namespace Tests.Parsers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for EventOccurrenceParser using real test data files.
|
||||||
|
/// </summary>
|
||||||
public class EventOccurrenceParser_Tests
|
public class EventOccurrenceParser_Tests
|
||||||
{
|
{
|
||||||
|
#region Constants
|
||||||
|
|
||||||
|
private const int MaxLookbackLines = 20;
|
||||||
|
private const int TopUnmatchedPatternsCount = 10;
|
||||||
|
private const int SampleIssuesCount = 5;
|
||||||
|
private const int SectionTestStartLineIndex = 63; // Line 64 (1-based) = index 63 (0-based)
|
||||||
|
private const int SectionTestLineCount = 29; // Lines 64-92 inclusive
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line contains a High School event marker.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsHighSchoolEvent(string line)
|
||||||
|
{
|
||||||
|
return line.Contains("– HS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
line.Contains(" - HS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
line.Contains("- HS", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a line contains a Middle School event marker.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsMiddleSchoolEvent(string line)
|
||||||
|
{
|
||||||
|
return line.Contains("– MS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
line.Contains(" - MS", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
line.Contains("- MS", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if an issue is expected (HS-related) or fixable.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsExpectedIssue(ParsingIssue issue, List<string> fileLines, int currentLineIndex)
|
||||||
|
{
|
||||||
|
// Check if the issue line itself is an HS event header
|
||||||
|
if (IsHighSchoolEvent(issue.LineContent))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// For MissingEventDefinition issues, check if we're in an HS section
|
||||||
|
if (issue.IssueType != ParsingIssueType.MissingEventDefinition) return false;
|
||||||
|
|
||||||
|
// Look backwards to find the most recent section header
|
||||||
|
for (int i = currentLineIndex - 1; i >= 0 && i >= currentLineIndex - MaxLookbackLines; i--)
|
||||||
|
{
|
||||||
|
if (i >= fileLines.Count) continue;
|
||||||
|
|
||||||
|
var line = fileLines[i].Trim();
|
||||||
|
if (IsHighSchoolEvent(line))
|
||||||
|
return true;
|
||||||
|
if (IsMiddleSchoolEvent(line))
|
||||||
|
return false; // Found MS section, so this is fixable
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categorizes issues into expected (HS-related) and fixable.
|
||||||
|
/// </summary>
|
||||||
|
private static (List<ParsingIssue> Expected, List<ParsingIssue> Fixable) CategorizeIssues(
|
||||||
|
List<ParsingIssue> issues, List<string> fileLines)
|
||||||
|
{
|
||||||
|
var expected = new List<ParsingIssue>();
|
||||||
|
var fixable = new List<ParsingIssue>();
|
||||||
|
|
||||||
|
foreach (var issue in issues)
|
||||||
|
{
|
||||||
|
var lineIndex = issue.LineNumber - 1; // Convert to 0-based index
|
||||||
|
if (IsExpectedIssue(issue, fileLines, lineIndex))
|
||||||
|
{
|
||||||
|
expected.Add(issue);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fixable.Add(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (expected, fixable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets sample lines for a list of issues.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> GetSampleLines(List<ParsingIssue> issues, int count = SampleIssuesCount)
|
||||||
|
{
|
||||||
|
return issues
|
||||||
|
.Take(count)
|
||||||
|
.Select(i => $" Line {i.LineNumber}: {i.LineContent}")
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes special events summary to console.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\n--- Special Events Found ---");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
|
||||||
|
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
|
||||||
|
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
|
||||||
|
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
|
||||||
|
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes issue breakdown by type to console.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteIssueBreakdown(
|
||||||
|
Dictionary<ParsingIssueType, List<ParsingIssue>> issuesByType,
|
||||||
|
Dictionary<ParsingIssueType, int> expectedByType,
|
||||||
|
Dictionary<ParsingIssueType, int> fixableByType,
|
||||||
|
List<ParsingIssue> fixableIssues)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\n--- Issue Breakdown by Type ---");
|
||||||
|
foreach (var kvp in issuesByType.OrderByDescending(x => x.Value.Count))
|
||||||
|
{
|
||||||
|
var issueType = kvp.Key;
|
||||||
|
var allIssues = kvp.Value;
|
||||||
|
expectedByType.TryGetValue(issueType, out var expectedCount);
|
||||||
|
fixableByType.TryGetValue(issueType, out var fixableCount);
|
||||||
|
|
||||||
|
Console.WriteLine($"\n{issueType}:");
|
||||||
|
Console.WriteLine($" Total: {allIssues.Count} (Expected: {expectedCount}, Fixable: {fixableCount})");
|
||||||
|
|
||||||
|
if (fixableCount > 0)
|
||||||
|
{
|
||||||
|
var fixableOfType = fixableIssues.Where(i => i.IssueType == issueType).ToList();
|
||||||
|
var samples = GetSampleLines(fixableOfType, SampleIssuesCount);
|
||||||
|
Console.WriteLine($" Sample fixable issues:");
|
||||||
|
foreach (var sample in samples)
|
||||||
|
{
|
||||||
|
Console.WriteLine(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes unmatched line analysis to console.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteUnmatchedLineAnalysis(List<ParsingIssue> fixableIssues)
|
||||||
|
{
|
||||||
|
var unmatchedLines = fixableIssues.Where(i => i.IssueType == ParsingIssueType.UnmatchedLine).ToList();
|
||||||
|
if (!unmatchedLines.Any()) return;
|
||||||
|
|
||||||
|
Console.WriteLine($"\n--- Unmatched Line Analysis ---");
|
||||||
|
var unmatchedPatterns = unmatchedLines
|
||||||
|
.GroupBy(i => i.LineContent.Trim())
|
||||||
|
.OrderByDescending(g => g.Count())
|
||||||
|
.Take(TopUnmatchedPatternsCount);
|
||||||
|
Console.WriteLine($"Top unmatched line formats:");
|
||||||
|
foreach (var pattern in unmatchedPatterns)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" \"{pattern.Key}\" (appears {pattern.Count()} times)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes and reports parsing results for a file.
|
||||||
|
/// </summary>
|
||||||
|
private static void AnalyzeParsingResults(
|
||||||
|
EventOccurrenceParserResult result,
|
||||||
|
FileInfo fileInfo,
|
||||||
|
string title)
|
||||||
|
{
|
||||||
|
// Load file lines for analysis
|
||||||
|
var fileLines = File.ReadAllLines(fileInfo.FullName).ToList();
|
||||||
|
var totalLines = fileLines.Count;
|
||||||
|
var totalParsed = result.Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
// Categorize issues
|
||||||
|
var (expectedIssues, fixableIssues) = CategorizeIssues(result.Issues, fileLines);
|
||||||
|
|
||||||
|
// Count event sections
|
||||||
|
var (hsSections, msSections) = CountEventSections(fileLines);
|
||||||
|
|
||||||
|
// Group issues by type
|
||||||
|
var issuesByType = result.Issues.GroupBy(i => i.IssueType).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
var expectedByType = expectedIssues.GroupBy(i => i.IssueType).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var fixableByType = fixableIssues.GroupBy(i => i.IssueType).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
// Output analysis
|
||||||
|
Console.WriteLine($"\n=== {title} ===");
|
||||||
|
Console.WriteLine($"\n--- Summary Statistics ---");
|
||||||
|
Console.WriteLine($"Total lines in file: {totalLines}");
|
||||||
|
Console.WriteLine($"Total occurrences parsed: {totalParsed}");
|
||||||
|
Console.WriteLine($"Total issues found: {result.Issues.Count}");
|
||||||
|
Console.WriteLine($" Expected issues (HS-related): {expectedIssues.Count} ({100.0 * expectedIssues.Count / Math.Max(1, result.Issues.Count):F1}%)");
|
||||||
|
Console.WriteLine($" Fixable issues: {fixableIssues.Count} ({100.0 * fixableIssues.Count / Math.Max(1, result.Issues.Count):F1}%)");
|
||||||
|
Console.WriteLine($"Event sections: HS={hsSections}, MS={msSections}");
|
||||||
|
Console.WriteLine($"Events with occurrences: {result.Occurrences.Count}");
|
||||||
|
|
||||||
|
WriteSpecialEventsSummary(result.Occurrences);
|
||||||
|
WriteIssueBreakdown(issuesByType, expectedByType, fixableByType, fixableIssues);
|
||||||
|
WriteUnmatchedLineAnalysis(fixableIssues);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts HS vs MS event sections in the file.
|
||||||
|
/// </summary>
|
||||||
|
private static (int HighSchool, int MiddleSchool) CountEventSections(List<string> fileLines)
|
||||||
|
{
|
||||||
|
int hsCount = 0;
|
||||||
|
int msCount = 0;
|
||||||
|
|
||||||
|
foreach (var line in fileLines)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (IsHighSchoolEvent(trimmed))
|
||||||
|
hsCount++;
|
||||||
|
else if (IsMiddleSchoolEvent(trimmed))
|
||||||
|
msCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hsCount, msCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes special events to console output.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
||||||
|
{
|
||||||
|
Console.WriteLine("General Schedule");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
|
||||||
|
{
|
||||||
|
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Meet the Candidates");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
|
||||||
|
{
|
||||||
|
foreach (var eo in meetTheCandidates)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Chapter Officer Meeting");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
|
||||||
|
{
|
||||||
|
foreach (var eo in chapterOfficerMeeting)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Voting Delegate Meeting");
|
||||||
|
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
|
||||||
|
{
|
||||||
|
foreach (var eo in votingDelegateMeeting)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void ParseNationalsTest()
|
public void ParseNationalsTest()
|
||||||
{
|
{
|
||||||
var events = TestEntityHandler.GetEvents();
|
var events = TestEntityHandler.GetEvents();
|
||||||
var parser = new EventOccurrenceParser(TestEntityHandler.GetEventOccurrenceNationalsFileInfo(), events);
|
var parser = new EventOccurrenceParser(TestEntityHandler.GetEventOccurrenceNationalsFileInfo(), events);
|
||||||
var dictionary = parser.Parse();
|
var result = parser.Parse();
|
||||||
Console.WriteLine($"Occurrence, Month, Date, Time, Location");
|
var dictionary = result.Occurrences;
|
||||||
foreach (var @event in events)
|
Console.WriteLine($"Occurrence, Month, Date, Time, Location");
|
||||||
{
|
foreach (var @event in events)
|
||||||
Console.WriteLine($"{@event.Name}");
|
{
|
||||||
|
Console.WriteLine($"{@event.Name}");
|
||||||
|
|
||||||
if (!dictionary.ContainsKey(@event))
|
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
||||||
{
|
{
|
||||||
Console.WriteLine("!!! eventDefinition not found " + @event.Name);
|
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var eventOccurrences = dictionary[@event];
|
|
||||||
foreach (var eo in eventOccurrences)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("General Schedule");
|
foreach (var eo in eventOccurrences)
|
||||||
if (dictionary.ContainsKey(EventDefinition.GeneralSchedule))
|
{
|
||||||
{
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
foreach (var eo in dictionary[EventDefinition.GeneralSchedule].OrderBy(occurrence => occurrence.StartTime))
|
}
|
||||||
{
|
}
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Meet the Candidates");
|
WriteSpecialEvents(dictionary);
|
||||||
if (dictionary.ContainsKey(EventDefinition.MeetTheCandidates))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.MeetTheCandidates])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Chapter Officer Meeting");
|
Assert.Pass();
|
||||||
if (dictionary.ContainsKey(EventDefinition.ChapterOfficerMeeting))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.ChapterOfficerMeeting])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Voting Delegate Meeting");
|
|
||||||
if (dictionary.ContainsKey(EventDefinition.VotingDelegateMeeting))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.VotingDelegateMeeting])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Pass();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -74,60 +313,276 @@ public class EventOccurrenceParser_Tests
|
|||||||
{
|
{
|
||||||
var events = TestEntityHandler.GetEvents();
|
var events = TestEntityHandler.GetEvents();
|
||||||
var parser = new EventOccurrenceParser(TestEntityHandler.GetEventOccurrenceStateFileInfo(), events);
|
var parser = new EventOccurrenceParser(TestEntityHandler.GetEventOccurrenceStateFileInfo(), events);
|
||||||
var dictionary = parser.Parse();
|
var result = parser.Parse();
|
||||||
|
var dictionary = result.Occurrences;
|
||||||
Console.WriteLine($"Occurrence, Month, Date, Time, Location");
|
Console.WriteLine($"Occurrence, Month, Date, Time, Location");
|
||||||
foreach (var @event in events)
|
foreach (var @event in events)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"{@event.Name}");
|
Console.WriteLine($"{@event.Name}");
|
||||||
|
|
||||||
if (!dictionary.ContainsKey(@event))
|
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
||||||
{
|
{
|
||||||
Console.WriteLine("!!! eventDefinition not found " + @event.Name);
|
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var eventOccurrences = dictionary[@event];
|
|
||||||
foreach (var eo in eventOccurrences)
|
foreach (var eo in eventOccurrences)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("General Schedule");
|
WriteSpecialEvents(dictionary);
|
||||||
if (dictionary.ContainsKey(EventDefinition.GeneralSchedule))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.GeneralSchedule].OrderBy(occurrence => occurrence.StartTime))
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Meet the Candidates");
|
|
||||||
if (dictionary.ContainsKey(EventDefinition.MeetTheCandidates))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.MeetTheCandidates])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Chapter Officer Meeting");
|
|
||||||
if (dictionary.ContainsKey(EventDefinition.ChapterOfficerMeeting))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.ChapterOfficerMeeting])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Voting Delegate Meeting");
|
|
||||||
if (dictionary.ContainsKey(EventDefinition.VotingDelegateMeeting))
|
|
||||||
{
|
|
||||||
foreach (var eo in dictionary[EventDefinition.VotingDelegateMeeting])
|
|
||||||
{
|
|
||||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.Pass();
|
Assert.Pass();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Analyze_2025Nationals_ParsingResults()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = TestEntityHandler.GetEvents();
|
||||||
|
var fileInfo = TestEntityHandler.GetEventOccurrenceNationalsFileInfo();
|
||||||
|
var parser = new EventOccurrenceParser(fileInfo, events);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert - Should parse without exceptions
|
||||||
|
Assert.That(result, Is.Not.Null, "Parser should return a result");
|
||||||
|
|
||||||
|
AnalyzeParsingResults(result, fileInfo, "2025 TSA Nationals Competition Event Times Analysis");
|
||||||
|
|
||||||
|
var fileLines = File.ReadAllLines(fileInfo.FullName).ToList();
|
||||||
|
var (_, fixableIssues) = CategorizeIssues(result.Issues, fileLines);
|
||||||
|
var totalParsed = result.Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
// Test passes if no exceptions were thrown
|
||||||
|
Assert.Pass($"Successfully parsed {totalParsed} occurrences with {result.Issues.Count} issues ({fixableIssues.Count} fixable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Analyze_2025State_ParsingResults()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = TestEntityHandler.GetEvents();
|
||||||
|
var fileInfo = TestEntityHandler.GetEventOccurrenceStateFileInfo();
|
||||||
|
var parser = new EventOccurrenceParser(fileInfo, events);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert - Should parse without exceptions
|
||||||
|
Assert.That(result, Is.Not.Null, "Parser should return a result");
|
||||||
|
|
||||||
|
AnalyzeParsingResults(result, fileInfo, "2025 TN TSA State Competition Event Times Analysis");
|
||||||
|
|
||||||
|
var fileLines = File.ReadAllLines(fileInfo.FullName).ToList();
|
||||||
|
var (_, fixableIssues) = CategorizeIssues(result.Issues, fileLines);
|
||||||
|
var totalParsed = result.Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
// Test passes if no exceptions were thrown
|
||||||
|
Assert.Pass($"Successfully parsed {totalParsed} occurrences with {result.Issues.Count} issues ({fixableIssues.Count} fixable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Analyze_2024State_ParsingResults()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var events = TestEntityHandler.GetEvents();
|
||||||
|
var fileInfo = TestEntityHandler.GetEventOccurrenceState2024FileInfo();
|
||||||
|
var parser = new EventOccurrenceParser(fileInfo, events);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert - Should parse without exceptions
|
||||||
|
Assert.That(result, Is.Not.Null, "Parser should return a result");
|
||||||
|
|
||||||
|
AnalyzeParsingResults(result, fileInfo, "2024 TN TSA State Competition Event Times Analysis");
|
||||||
|
|
||||||
|
var fileLines = File.ReadAllLines(fileInfo.FullName).ToList();
|
||||||
|
var (_, fixableIssues) = CategorizeIssues(result.Issues, fileLines);
|
||||||
|
var totalParsed = result.Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
// Test passes if no exceptions were thrown
|
||||||
|
Assert.Pass($"Successfully parsed {totalParsed} occurrences with {result.Issues.Count} issues ({fixableIssues.Count} fixable)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_Section_Lines64To92_ChildrensStoriesToConstructionChallenge()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Extract lines 64-92 from the test file - contains MS and HS events with various formats
|
||||||
|
var allLines = File.ReadAllLines(TestEntityHandler.GetEventOccurrenceStateFileInfo().FullName);
|
||||||
|
var sectionLines = allLines.Skip(SectionTestStartLineIndex).Take(SectionTestLineCount).ToArray(); // Lines 64-92 (0-indexed: 63-91)
|
||||||
|
var sectionContent = string.Join("\n", sectionLines);
|
||||||
|
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(sectionContent);
|
||||||
|
var events = TestEntityHandler.GetEvents();
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert - Should parse without exceptions
|
||||||
|
Assert.That(result, Is.Not.Null, "Parser should return a result");
|
||||||
|
|
||||||
|
// Count occurrences by event type
|
||||||
|
var totalOccurrences = result.Occurrences.Values.Sum(list => list.Count);
|
||||||
|
|
||||||
|
// Verify MS events are parsed
|
||||||
|
var childrensStories = events.FirstOrDefault(e => e.Name.Contains("Children's Stories", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var coding = events.FirstOrDefault(e => e.Name == "Coding");
|
||||||
|
var communityServiceVideo = events.FirstOrDefault(e => e.Name.Contains("Community Service Video", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var constructionChallenge = events.FirstOrDefault(e => e.Name.Contains("Construction Challenge", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Count expected MS occurrences:
|
||||||
|
// Children's Stories – MS: 5 occurrences (lines 65-69)
|
||||||
|
// Coding – MS: 2 occurrences (lines 76-77)
|
||||||
|
// Community Service Video – MS: 4 occurrences (lines 79-82)
|
||||||
|
// Construction Challenge – MS: 5 occurrences (lines 88-92)
|
||||||
|
// Total expected MS occurrences: 16
|
||||||
|
|
||||||
|
var msEventCount = 0;
|
||||||
|
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
|
||||||
|
msEventCount += csOccurrences.Count;
|
||||||
|
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
|
||||||
|
msEventCount += codingOccurrences.Count;
|
||||||
|
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
|
||||||
|
msEventCount += csvOccurrences.Count;
|
||||||
|
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
|
||||||
|
msEventCount += ccOccurrences.Count;
|
||||||
|
|
||||||
|
// When no school level is set, HS events should be processed (not skipped)
|
||||||
|
// Verify HS events are processed or handled appropriately
|
||||||
|
var hsIssues = result.Issues.Where(i =>
|
||||||
|
i.LineContent.Contains("Coding – HS") ||
|
||||||
|
i.LineContent.Contains("CAD") && i.LineContent.Contains("HS") ||
|
||||||
|
i.LineNumber >= 72 && i.LineNumber <= 86 && IsHighSchoolEvent(i.LineContent)
|
||||||
|
).ToList();
|
||||||
|
|
||||||
|
// Verify HS section headers are NOT tracked in SkippedSectionHeaders when no school level is set
|
||||||
|
var skippedHeaders = result.SkippedSectionHeaders;
|
||||||
|
|
||||||
|
// Verify continuation lines are skipped
|
||||||
|
// Line 70 starts with "*The" - this enters continuation mode and both line 70 and 71 should be skipped
|
||||||
|
var continuationLineIssues = result.Issues.Where(i =>
|
||||||
|
i.LineContent.Contains("books of semifinalist teams") ||
|
||||||
|
i.LineContent.Contains("be returned to teams")
|
||||||
|
).ToList();
|
||||||
|
|
||||||
|
// Verify specific time formats are parsed correctly
|
||||||
|
var noonOccurrence = result.Occurrences.Values
|
||||||
|
.SelectMany(list => list)
|
||||||
|
.FirstOrDefault(eo => eo.Time.Contains("NOON", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var lateTimeOccurrence = result.Occurrences.Values
|
||||||
|
.SelectMany(list => list)
|
||||||
|
.FirstOrDefault(eo => eo.Time.Contains("11:59", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Output detailed analysis
|
||||||
|
Console.WriteLine($"\n=== Section Lines 64-92 Parsing Results ===");
|
||||||
|
Console.WriteLine($"Total occurrences parsed: {totalOccurrences}");
|
||||||
|
Console.WriteLine($"MS event occurrences: {msEventCount}");
|
||||||
|
Console.WriteLine($"Total issues: {result.Issues.Count}");
|
||||||
|
Console.WriteLine($"HS-related issues: {hsIssues.Count}");
|
||||||
|
Console.WriteLine($"Skipped section headers: {skippedHeaders.Count}");
|
||||||
|
Console.WriteLine($"Continuation line issues: {continuationLineIssues.Count}");
|
||||||
|
|
||||||
|
Console.WriteLine($"\n--- Issue Types ---");
|
||||||
|
foreach (var issueType in result.Issues.GroupBy(i => i.IssueType))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {issueType.Key}: {issueType.Count()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
Assert.That(totalOccurrences, Is.GreaterThan(0), "Should parse at least some occurrences");
|
||||||
|
Assert.That(msEventCount, Is.GreaterThanOrEqualTo(14), "Should parse most MS occurrences (at least 14 out of 16)");
|
||||||
|
// When no school level is set, HS events should be processed (not skipped)
|
||||||
|
// HS events may create issues if they don't match event definitions, which is expected
|
||||||
|
// HS section headers should NOT be tracked when no school level is set
|
||||||
|
Assert.That(skippedHeaders, Has.Count.EqualTo(0), "Section headers should NOT be tracked when no school level is set");
|
||||||
|
// Line 70 (starts with "*The") enters continuation mode and both line 70 and 71 should be skipped without issues
|
||||||
|
Assert.That(continuationLineIssues, Has.Count.EqualTo(0),
|
||||||
|
"Continuation lines starting with '*' and subsequent lines should be skipped without issues");
|
||||||
|
Assert.That(noonOccurrence, Is.Not.Null, "Should parse NOON time format");
|
||||||
|
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
|
||||||
|
|
||||||
|
// Verify specific locations are parsed
|
||||||
|
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
|
||||||
|
{
|
||||||
|
var locations = childrensStoriesOccurrences
|
||||||
|
.Select(eo => eo.Location)
|
||||||
|
.Where(loc => !string.IsNullOrWhiteSpace(loc))
|
||||||
|
.ToList();
|
||||||
|
Assert.That(locations, Has.Count.GreaterThan(0), "Children's Stories should have locations parsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test passes with detailed information
|
||||||
|
Assert.Pass($"Successfully parsed section: {totalOccurrences} occurrences, {result.Issues.Count} issues, {msEventCount} MS events");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Parse_BiotechnologyMSAndHS_HSOccurrencesNotAssociatedWithMS()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// This test verifies that HS events (like "Biotechnology Design – HS") are not incorrectly
|
||||||
|
// associated with MS events (like "Biotechnology – MS") even if fuzzy matching finds a match
|
||||||
|
var testContent = "Biotechnology – MS\n" +
|
||||||
|
"Submit Entry April 3 8 a.m. – 9 a.m. Exhibit Hall C\n" +
|
||||||
|
"Judging April 3 9 a.m. – 5 p.m. Exhibit Hall C\n" +
|
||||||
|
"Biotechnology Design – HS\n" +
|
||||||
|
"Submit Entry April 3 8 a.m. – 9:00 a.m. Exhibit Hall C\n" +
|
||||||
|
"Judging April 3 9 a.m. – 5 p.m. Exhibit Hall C\n" +
|
||||||
|
"Pick-up April 4 5 p.m. – 5:30 p.m. Exhibit Hall C";
|
||||||
|
|
||||||
|
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||||
|
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Biotechnology") };
|
||||||
|
var parser = new EventOccurrenceParser(tempFile, events);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = parser.Parse();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var biotechnology = events.FirstOrDefault(e => e.Name == "Biotechnology");
|
||||||
|
Assert.That(biotechnology, Is.Not.Null, "Biotechnology event should exist");
|
||||||
|
|
||||||
|
// When no school level is set, all events (MS and HS) should be processed
|
||||||
|
// HS section header should NOT be skipped (note: normalized to regular hyphen)
|
||||||
|
Assert.That(result.SkippedSectionHeaders, Does.Not.Contain("Biotechnology Design - HS"),
|
||||||
|
"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
|
||||||
|
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
|
||||||
|
{
|
||||||
|
// With no school level set, we process all occurrences (both MS and HS)
|
||||||
|
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
|
||||||
|
Assert.That(allOccurrences, Has.Count.EqualTo(5),
|
||||||
|
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
|
||||||
|
$"Found {allOccurrences.Count} occurrences total.");
|
||||||
|
|
||||||
|
// Verify all expected occurrence names are present
|
||||||
|
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
|
||||||
|
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
|
||||||
|
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
|
||||||
|
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Pass("All events processed when no school level is set");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Parsers;
|
using Core.Parsers;
|
||||||
using Core.Utility;
|
using Core.Utility;
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ public static class TestEntityHandler
|
|||||||
return FileUtility.GetContentFile(ContentDirectory, "2025 TN TSA State Competition Event Times.txt");
|
return FileUtility.GetContentFile(ContentDirectory, "2025 TN TSA State Competition Event Times.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static FileInfo GetEventOccurrenceState2024FileInfo()
|
||||||
|
{
|
||||||
|
return FileUtility.GetContentFile(ContentDirectory, "2024 TN TSA State Competition Event Times.txt");
|
||||||
|
}
|
||||||
|
|
||||||
public static Student[] GetStudents(IList<EventDefinition> events)
|
public static Student[] GetStudents(IList<EventDefinition> events)
|
||||||
{
|
{
|
||||||
//var studentEventRankingsCsv = "Student Event Rankings.csv";
|
//var studentEventRankingsCsv = "Student Event Rankings.csv";
|
||||||
|
|||||||
+5
-5
@@ -10,11 +10,11 @@
|
|||||||
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||||
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
|
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
<PackageReference Include="NUnit.Analyzers" Version="4.11.2" />
|
||||||
<PackageReference Include="coverlet.collector" Version="3.2.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Core\Core.csproj" />
|
<ProjectReference Include="..\Core\Core.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
using Core.Entities;
|
||||||
|
using Core.Utility;
|
||||||
|
using Tests.Builders;
|
||||||
|
|
||||||
|
namespace Tests.Utility;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TeamClipboardMatcher_Tests
|
||||||
|
{
|
||||||
|
private List<Team> _availableTeams = [];
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
BuilderExtensions.ResetAllBuilders();
|
||||||
|
|
||||||
|
// Create test teams
|
||||||
|
var construction = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
|
||||||
|
var flight = EventDefinitionBuilder.Individual("Flight").Build();
|
||||||
|
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build();
|
||||||
|
var coding = EventDefinitionBuilder.Individual("Coding").Build();
|
||||||
|
var medicalTech = EventDefinitionBuilder.Team("Medical Technology", 2, 3).Build();
|
||||||
|
|
||||||
|
_availableTeams =
|
||||||
|
[
|
||||||
|
TeamBuilder.Create(construction).WithIdentifier("2").Build(),
|
||||||
|
TeamBuilder.Create(flight).Build(),
|
||||||
|
TeamBuilder.Create(robotics).Build(),
|
||||||
|
TeamBuilder.Create(coding).Build(),
|
||||||
|
TeamBuilder.Create(medicalTech).Build()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ExactMatch_ReturnsMatchingTeam()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challenge (2)";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
Assert.That(result[0].Identifier, Is.EqualTo("2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ExactMatchWithoutIdentifier_ReturnsMatchingTeam()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ClipboardFormatWithStudentList_ExtractsTeamName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challenge (2) - John Doe, Jane Smith";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_MultipleTeams_ReturnsAllMatches()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nRobotics\r\nCoding";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Coding"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_FuzzyMatch_ReturnsBestMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construcion Challange"; // Intentional typos
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_CaseInsensitive_MatchesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "FLIGHT\r\nconstruction challenge (2)";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_SkipsEmptyLines_IgnoresEmptyEntries()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\n\r\nRobotics\r\n \r\nCoding";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_SkipsMetadataHeaders_IgnoresSpecialMarkers()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "--Unscheduled\r\nFlight\r\n--Another Header\r\nRobotics\r\nUnscheduled";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_NoMatches_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "NonExistent Team Name";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_BelowThreshold_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "XYZ"; // Very different from any team name
|
||||||
|
var highThreshold = 95; // Very high threshold
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, highThreshold);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_CustomThreshold_RespectsThreshold()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Construction Challeng"; // Close match, should work with lower threshold
|
||||||
|
var lowThreshold = 70;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, lowThreshold);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_EmptyClipboard_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_NullClipboard_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string? clipboardText = null;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText!, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_EmptyTeamsList_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
var emptyTeams = new List<Team>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, emptyTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_DuplicateTeamNames_ReturnsSingleInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nFlight\r\nFlight";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_MultipleLinesWithDifferentFormats_HandlesAllFormats()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight\r\nRobotics - Student List\r\nMedical Technology (1) - More Students";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(3));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Medical Technology"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_WhitespaceAroundTeamName_TrimsCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = " Flight \r\n Robotics ";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(2));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
|
||||||
|
Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_ReturnsTeamsFromInputCollection_MaintainsReferenceEquality()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var clipboardText = "Flight";
|
||||||
|
var inputTeam = _availableTeams.First(t => t.Event.Name == "Flight");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result[0], Is.SameAs(inputTeam));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void MatchTeamsFromClipboard_BestMatchSelected_ReturnsHighestScoringMatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Create teams with similar names
|
||||||
|
var construction1 = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
|
||||||
|
var construction2 = EventDefinitionBuilder.Team("Construction Challenge Advanced", 2, 4).Build();
|
||||||
|
var teams = new[]
|
||||||
|
{
|
||||||
|
TeamBuilder.Create(construction1).Build(),
|
||||||
|
TeamBuilder.Create(construction2).Build()
|
||||||
|
};
|
||||||
|
var clipboardText = "Construction Challenge"; // Should match the first one exactly
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, teams);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Has.Count.EqualTo(1));
|
||||||
|
// The exact match should be selected (higher score)
|
||||||
|
Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Core.Utility;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace Tests.Utility;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class TextUtil_Tests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_ValidInput_ReturnsCorrectDate()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result = TextUtil.ParseDate("January", "15", 2025);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_AllMonths_AreSupported()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var months = new[] { "January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December" };
|
||||||
|
var expectedDates = new[]
|
||||||
|
{
|
||||||
|
new DateOnly(2025, 1, 15),
|
||||||
|
new DateOnly(2025, 2, 15),
|
||||||
|
new DateOnly(2025, 3, 15),
|
||||||
|
new DateOnly(2025, 4, 15),
|
||||||
|
new DateOnly(2025, 5, 15),
|
||||||
|
new DateOnly(2025, 6, 15),
|
||||||
|
new DateOnly(2025, 7, 15),
|
||||||
|
new DateOnly(2025, 8, 15),
|
||||||
|
new DateOnly(2025, 9, 15),
|
||||||
|
new DateOnly(2025, 10, 15),
|
||||||
|
new DateOnly(2025, 11, 15),
|
||||||
|
new DateOnly(2025, 12, 15),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
for (int i = 0; i < months.Length; i++)
|
||||||
|
{
|
||||||
|
var result = TextUtil.ParseDate(months[i], "15", 2025);
|
||||||
|
Assert.That(result, Is.EqualTo(expectedDates[i]),
|
||||||
|
$"Month {months[i]} should parse correctly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_CaseInsensitive_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result1 = TextUtil.ParseDate("JANUARY", "15", 2025);
|
||||||
|
var result2 = TextUtil.ParseDate("january", "15", 2025);
|
||||||
|
var result3 = TextUtil.ParseDate("JaNuArY", "15", 2025);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result1, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||||
|
Assert.That(result2, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||||
|
Assert.That(result3, Is.EqualTo(new DateOnly(2025, 1, 15)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_InvalidMonth_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange, Act & Assert
|
||||||
|
Assert.Throws<ArgumentException>(() => TextUtil.ParseDate("InvalidMonth", "15", 2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_InvalidDay_ThrowsFormatException()
|
||||||
|
{
|
||||||
|
// Arrange, Act & Assert
|
||||||
|
Assert.Throws<FormatException>(() => TextUtil.ParseDate("January", "abc", 2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_InvalidDate_ThrowsArgumentOutOfRangeException()
|
||||||
|
{
|
||||||
|
// Arrange, Act & Assert - February 30 doesn't exist
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => TextUtil.ParseDate("February", "30", 2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_LeapYear_February29_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result = TextUtil.ParseDate("February", "29", 2024); // 2024 is a leap year
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new DateOnly(2024, 2, 29)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_NonLeapYear_February29_Throws()
|
||||||
|
{
|
||||||
|
// Arrange, Act & Assert - 2025 is not a leap year
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => TextUtil.ParseDate("February", "29", 2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_SingleDigitDay_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result = TextUtil.ParseDate("March", "3", 2025);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new DateOnly(2025, 3, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_DifferentYears_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result2024 = TextUtil.ParseDate("January", "1", 2024);
|
||||||
|
var result2025 = TextUtil.ParseDate("January", "1", 2025);
|
||||||
|
var result2026 = TextUtil.ParseDate("January", "1", 2026);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result2024, Is.EqualTo(new DateOnly(2024, 1, 1)));
|
||||||
|
Assert.That(result2025, Is.EqualTo(new DateOnly(2025, 1, 1)));
|
||||||
|
Assert.That(result2026, Is.EqualTo(new DateOnly(2026, 1, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_FirstDayOfMonth_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result = TextUtil.ParseDate("December", "1", 2025);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new DateOnly(2025, 12, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ParseDate_LastDayOfMonth_Works()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var result = TextUtil.ParseDate("January", "31", 2025);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result, Is.EqualTo(new DateOnly(2025, 1, 31)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation;
|
using Core.Validation;
|
||||||
using Core.Validation.Rules.StudentAssignmentRules;
|
using Core.Validation.Rules.StudentAssignmentRules;
|
||||||
using Tests.Builders;
|
using Tests.Builders;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation;
|
using Core.Validation;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Core.Entities;
|
using Core.Entities;
|
||||||
|
using Core.Models;
|
||||||
using Core.Validation;
|
using Core.Validation;
|
||||||
using Tests.Builders;
|
using Tests.Builders;
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
|
|||||||
public async Task<IActionResult> CookieLogin(
|
public async Task<IActionResult> CookieLogin(
|
||||||
[FromForm] string email,
|
[FromForm] string email,
|
||||||
[FromForm] string password,
|
[FromForm] string password,
|
||||||
[FromForm] bool rememberMe = false)
|
[FromForm] bool rememberMe = false,
|
||||||
|
[FromForm] string? returnUrl = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
|
|||||||
ipAddress, remaining);
|
ipAddress, remaining);
|
||||||
|
|
||||||
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
|
||||||
return Redirect($"/login?error={errorMsg}");
|
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||||
|
? $"/login?error={errorMsg}"
|
||||||
|
: $"/login?error={errorMsg}&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
return Redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate credentials
|
// Validate credentials
|
||||||
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
|
|||||||
"Failed login attempt for {Email} from {IpAddress}",
|
"Failed login attempt for {Email} from {IpAddress}",
|
||||||
email, ipAddress);
|
email, ipAddress);
|
||||||
|
|
||||||
return Redirect("/login?error=Invalid%20email%20or%20password.");
|
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||||
|
? "/login?error=Invalid%20email%20or%20password."
|
||||||
|
: $"/login?error=Invalid%20email%20or%20password.&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
return Redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - clear rate limit tracking
|
// Success - clear rate limit tracking
|
||||||
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
|
|||||||
"Successful login for {Email} ({Role}) from {IpAddress}",
|
"Successful login for {Email} ({Role}) from {IpAddress}",
|
||||||
result.Email, result.Role, ipAddress);
|
result.Email, result.Role, ipAddress);
|
||||||
|
|
||||||
|
// Validate return URL is local to prevent open redirect attacks
|
||||||
|
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
|
||||||
|
{
|
||||||
|
return Redirect(returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return Redirect("/");
|
return Redirect("/");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during login process");
|
_logger.LogError(ex, "Error during login process");
|
||||||
TempData["LoginError"] = "An error occurred. Please try again.";
|
TempData["LoginError"] = "An error occurred. Please try again.";
|
||||||
return Redirect("/login");
|
var redirectUrl = string.IsNullOrEmpty(returnUrl)
|
||||||
|
? "/login"
|
||||||
|
: $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
return Redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||||
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
|
||||||
|
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
|
||||||
|
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet />
|
<HeadOutlet />
|
||||||
@@ -17,9 +19,13 @@
|
|||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
|
|
||||||
|
<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="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||||
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
|
||||||
|
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
|
||||||
|
<script src="js/markdownTablePaste.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<input type="hidden" id="emailInput" name="email" value="" />
|
<input type="hidden" id="emailInput" name="email" value="" />
|
||||||
<input type="hidden" id="passwordInput" name="password" value="" />
|
<input type="hidden" id="passwordInput" name="password" value="" />
|
||||||
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
|
||||||
|
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
|
||||||
|
|
||||||
<MudTextField @bind-Value="_loginModel.Email"
|
<MudTextField @bind-Value="_loginModel.Email"
|
||||||
Label="Email"
|
Label="Email"
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
private MudInputType _passwordInput = MudInputType.Password;
|
private MudInputType _passwordInput = MudInputType.Password;
|
||||||
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
|
||||||
private string _antiforgeryToken = string.Empty;
|
private string _antiforgeryToken = string.Empty;
|
||||||
|
private string? _returnUrl;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
@@ -86,13 +88,17 @@
|
|||||||
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error message from query parameter (set by controller on failed login)
|
// Check for error message and returnUrl from query parameters
|
||||||
var uri = new Uri(Navigation.Uri);
|
var uri = new Uri(Navigation.Uri);
|
||||||
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||||
if (queryParams.TryGetValue("error", out var errorValue))
|
if (queryParams.TryGetValue("error", out var errorValue))
|
||||||
{
|
{
|
||||||
_errorMessage = errorValue.ToString();
|
_errorMessage = errorValue.ToString();
|
||||||
}
|
}
|
||||||
|
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
|
||||||
|
{
|
||||||
|
_returnUrl = returnUrlValue.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LoginModel
|
private class LoginModel
|
||||||
@@ -141,10 +147,12 @@
|
|||||||
private async Task HandleFormSubmit()
|
private async Task HandleFormSubmit()
|
||||||
{
|
{
|
||||||
// Update hidden inputs with current model values, then submit the form
|
// Update hidden inputs with current model values, then submit the form
|
||||||
|
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
|
||||||
await JS.InvokeVoidAsync("eval", $@"
|
await JS.InvokeVoidAsync("eval", $@"
|
||||||
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
|
||||||
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
|
||||||
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
|
||||||
|
document.getElementById('returnUrlInput').value = {returnUrlValue};
|
||||||
document.getElementById('loginForm').submit();
|
document.getElementById('loginForm').submit();
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
@page "/calendar/admin"
|
||||||
|
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||||
|
@using Core.Entities
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Authentication
|
||||||
|
@using MudBlazor
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
Title="Calendar Data Management"
|
||||||
|
Description="Manage and clean event occurrence data"
|
||||||
|
Icon="@Icons.Material.Filled.AdminPanelSettings"
|
||||||
|
ShowBackButton="true"
|
||||||
|
BackButtonUrl="/calendar" />
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">Statistics</MudText>
|
||||||
|
|
||||||
|
@if (_statistics == null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
<MudText>Loading statistics...</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.h6">Total Occurrences: @_statistics.TotalCount</MudText>
|
||||||
|
|
||||||
|
@if (_statistics.TotalCount > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
Date Range: @_statistics.EarliestDate.ToString("MMM dd, yyyy") - @_statistics.LatestDate.ToString("MMM dd, yyyy")
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
Unique Dates: @_statistics.UniqueDateCount
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
Events with Occurrences: @_statistics.EventDefinitionCount
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
Special Events: @_statistics.SpecialEventCount
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
@if (_statistics.DuplicateCount > 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true">
|
||||||
|
Found @_statistics.DuplicateCount potential duplicate occurrence(s)
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1" Color="Color.Secondary">No event occurrences in database</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-4">Delete by Date Range</MudText>
|
||||||
|
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudDatePicker
|
||||||
|
Label="Start Date"
|
||||||
|
@bind-Date="_deleteStartDate"
|
||||||
|
Variant="Variant.Outlined" />
|
||||||
|
|
||||||
|
<MudDatePicker
|
||||||
|
Label="End Date"
|
||||||
|
@bind-Date="_deleteEndDate"
|
||||||
|
Variant="Variant.Outlined" />
|
||||||
|
|
||||||
|
<MudButton
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Error"
|
||||||
|
StartIcon="@Icons.Material.Filled.Delete"
|
||||||
|
OnClick="HandleDeleteByDateRange"
|
||||||
|
Disabled="@(_deleteStartDate == null || _deleteEndDate == null || _isDeleting)">
|
||||||
|
Delete Occurrences in Range
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
|
@if (_isDeleting)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" />
|
||||||
|
<MudText>Deleting occurrences...</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private CalendarStatistics? _statistics;
|
||||||
|
private DateTime? _deleteStartDate;
|
||||||
|
private DateTime? _deleteEndDate;
|
||||||
|
private bool _isDeleting = false;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadStatistics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var occurrences = await Context.EventOccurrences
|
||||||
|
.Include(eo => eo.EventDefinition)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
_statistics = new CalendarStatistics
|
||||||
|
{
|
||||||
|
TotalCount = occurrences.Count,
|
||||||
|
EarliestDate = occurrences.Any() ? occurrences.Min(eo => eo.StartTime.Date) : DateTime.Today,
|
||||||
|
LatestDate = occurrences.Any() ? occurrences.Max(eo => eo.StartTime.Date) : DateTime.Today,
|
||||||
|
UniqueDateCount = occurrences.Select(eo => eo.StartTime.Date).Distinct().Count(),
|
||||||
|
EventDefinitionCount = occurrences.Where(eo => eo.EventDefinitionId.HasValue).Select(eo => eo.EventDefinitionId).Distinct().Count(),
|
||||||
|
SpecialEventCount = occurrences.Where(eo => !string.IsNullOrEmpty(eo.SpecialEventType)).Count(),
|
||||||
|
DuplicateCount = CountDuplicates(occurrences)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading statistics: {ex.Message}", Severity.Error);
|
||||||
|
_statistics = new CalendarStatistics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CountDuplicates(List<EventOccurrence> occurrences)
|
||||||
|
{
|
||||||
|
// Count occurrences that have the same name, date, time, and location
|
||||||
|
return occurrences
|
||||||
|
.GroupBy(eo => new
|
||||||
|
{
|
||||||
|
eo.Name,
|
||||||
|
Date = eo.StartTime.Date,
|
||||||
|
Time = eo.Time,
|
||||||
|
eo.Location,
|
||||||
|
eo.EventDefinitionId,
|
||||||
|
eo.SpecialEventType
|
||||||
|
})
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Sum(g => g.Count() - 1); // Count duplicates (total - 1 per group)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleDeleteByDateRange()
|
||||||
|
{
|
||||||
|
if (_deleteStartDate == null || _deleteEndDate == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Please select both start and end dates", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_deleteStartDate > _deleteEndDate)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Start date must be before end date", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count occurrences that will be deleted
|
||||||
|
var countToDelete = await Context.EventOccurrences
|
||||||
|
.Where(eo => eo.StartTime.Date >= _deleteStartDate.Value.Date &&
|
||||||
|
eo.StartTime.Date <= _deleteEndDate.Value.Date)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
if (countToDelete == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("No occurrences found in the specified date range", Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"Confirm Deletion",
|
||||||
|
$"Are you sure you want to delete {countToDelete} occurrence(s) from {_deleteStartDate.Value:MMM dd, yyyy} to {_deleteEndDate.Value:MMM dd, yyyy}? This action cannot be undone.",
|
||||||
|
yesText: "Delete",
|
||||||
|
noText: "Cancel");
|
||||||
|
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
_isDeleting = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var occurrencesToDelete = await Context.EventOccurrences
|
||||||
|
.Where(eo => eo.StartTime.Date >= _deleteStartDate.Value.Date &&
|
||||||
|
eo.StartTime.Date <= _deleteEndDate.Value.Date)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Context.EventOccurrences.RemoveRange(occurrencesToDelete);
|
||||||
|
await Context.SaveChangesAsync();
|
||||||
|
|
||||||
|
Snackbar.Add($"Successfully deleted {occurrencesToDelete.Count} occurrence(s)", Severity.Success);
|
||||||
|
|
||||||
|
// Reload statistics
|
||||||
|
await LoadStatistics();
|
||||||
|
|
||||||
|
// Clear date pickers
|
||||||
|
_deleteStartDate = null;
|
||||||
|
_deleteEndDate = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error deleting occurrences: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isDeleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CalendarStatistics
|
||||||
|
{
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public DateTime EarliestDate { get; set; }
|
||||||
|
public DateTime LatestDate { get; set; }
|
||||||
|
public int UniqueDateCount { get; set; }
|
||||||
|
public int EventDefinitionCount { get; set; }
|
||||||
|
public int SpecialEventCount { get; set; }
|
||||||
|
public int DuplicateCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@namespace WebApp.Components.Features.Calendar
|
||||||
|
@using Core.Entities
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
@if (EventOccurrence == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning">
|
||||||
|
Event details are unavailable.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudText Typo="Typo.h6">
|
||||||
|
@(EventDefinition?.Name ?? EventOccurrence.Name)
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<strong>Occurrence:</strong> @EventOccurrence.Name
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<strong>Start:</strong> @EventOccurrence.StartTime.ToString("f")
|
||||||
|
</MudText>
|
||||||
|
@if (EventOccurrence.EndTime != null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<strong>Location:</strong> @EventOccurrence.Location
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
@if (StudentFirstNames.Any())
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body1">
|
||||||
|
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton OnClick="Close">Close</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
public IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventOccurrence? EventOccurrence { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventDefinition? EventDefinition { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public List<string> StudentFirstNames { get; set; } = [];
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
MudDialog.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
@page "/calendar/event-occurrences/import"
|
@page "/calendar/event-occurrences/import"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Core.Entities
|
|
||||||
@using Core.Models
|
@using Core.Models
|
||||||
@using Core.Services
|
@using Core.Services
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
|
||||||
@using MudBlazor
|
|
||||||
@inject IEventOccurrenceParserService ParserService
|
@inject IEventOccurrenceParserService ParserService
|
||||||
@inject AppDbContext Context
|
@inject AppDbContext Context
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
Title="Import Event Occurrences"
|
Title="Import Event Occurrences"
|
||||||
@@ -19,18 +17,9 @@
|
|||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Paste Event Occurrence Data</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Paste Event Occurrence Data</MudText>
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
<MudTextField
|
|
||||||
T="string"
|
|
||||||
Label="Event Occurrence Text"
|
|
||||||
@bind-Value="_inputText"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
Lines="15"
|
|
||||||
MultiLine="true"
|
|
||||||
Placeholder="Paste event occurrence text here..."
|
|
||||||
HelperText="Paste the event schedule text in the format expected by the parser" />
|
|
||||||
|
|
||||||
<MudStack Row="true" Spacing="2">
|
<MudStack Row="true" Spacing="2">
|
||||||
<MudButton
|
<MudButton
|
||||||
@@ -48,12 +37,23 @@
|
|||||||
Clear
|
Clear
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
<MudTextField
|
||||||
|
T="string"
|
||||||
|
Label="Event Occurrence Text"
|
||||||
|
@bind-Value="_inputText"
|
||||||
|
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Lines="15"
|
||||||
|
AutoGrow="true"
|
||||||
|
Placeholder="Paste event occurrence text here..."
|
||||||
|
HelperText="Paste the event schedule text in the format expected by the parser" />
|
||||||
|
<!-- @bind-Value:event="oninput" -->
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Parsed Results</MudText>
|
||||||
|
|
||||||
@if (_isParsing)
|
@if (_isParsing)
|
||||||
@@ -86,14 +86,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Detailed Parsing Issues *@
|
||||||
|
@if (_parseResult.Issues.Any())
|
||||||
|
{
|
||||||
|
<MudExpansionPanels Elevation="0" Class="mt-2">
|
||||||
|
<MudExpansionPanel Text="@($"Parsing Issues ({_parseResult.Issues.Count} found on {_parseResult.Issues.Select(i => i.LineNumber).Distinct().Count()} line(s))")"
|
||||||
|
Icon="@Icons.Material.Filled.Warning"
|
||||||
|
iconcolor="Color.Warning">
|
||||||
|
<MudTable Items="@_parseResult.Issues" Dense="true" Hover="true" Striped="true" sortmode="SortMode.Multiple">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Line</MudTh>
|
||||||
|
<MudTh>Type</MudTh>
|
||||||
|
<MudTh>Content</MudTh>
|
||||||
|
<MudTh>Message</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Line">@context.LineNumber</MudTd>
|
||||||
|
<MudTd DataLabel="Type">
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="@GetIssueTypeColor(context.IssueType)">
|
||||||
|
@context.IssueType
|
||||||
|
</MudChip>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Content">
|
||||||
|
<code style="font-size: 0.85em; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;" title="@context.LineContent">@context.LineContent</code>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Message">@context.Message</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
}
|
||||||
|
|
||||||
@* Summary *@
|
@* Summary *@
|
||||||
@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) from @_parseResult.Occurrences.Count event definition(s)
|
||||||
|
@if (_parseResult.SkippedEventCount > 0)
|
||||||
|
{
|
||||||
|
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
|
||||||
|
}
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Skipped Section Headers *@
|
||||||
|
@if (_parseResult.SkippedSectionHeaders.Any() && _parseResult.IsSuccess)
|
||||||
|
{
|
||||||
|
<MudExpansionPanels Elevation="0" Class="mt-2">
|
||||||
|
<MudExpansionPanel Text="@($"Skipped Section Headers ({_parseResult.SkippedSectionHeaders.Count})")"
|
||||||
|
Icon="@Icons.Material.Filled.Info"
|
||||||
|
iconcolor="Color.Info">
|
||||||
|
<MudList T="string">
|
||||||
|
@foreach (var header in _parseResult.SkippedSectionHeaders)
|
||||||
|
{
|
||||||
|
<MudListItem T="string">
|
||||||
|
<MudText>@header</MudText>
|
||||||
|
</MudListItem>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Locations Summary *@
|
||||||
|
@if (_parseResult.IsSuccess && _parseResult.Occurrences.Any())
|
||||||
|
{
|
||||||
|
var allLocations = _parseResult.Occurrences.Values
|
||||||
|
.SelectMany(list => list)
|
||||||
|
.Select(eo => eo.Location)
|
||||||
|
.Where(loc => !string.IsNullOrWhiteSpace(loc))
|
||||||
|
.Cast<string>() // Cast to non-nullable string after null check
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(loc => loc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Check which locations have warnings (long or contain date/time)
|
||||||
|
var dateTimePattern = new System.Text.RegularExpressions.Regex(
|
||||||
|
@"\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}\b|\b\d{1,2}:\d{2}\s*(a|p)\.?m\.?\b|\bNOON\b",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
var longLocations = allLocations.Where(loc => loc.Length > 50).ToList();
|
||||||
|
var locationsWithDateTime = allLocations.Where(loc => dateTimePattern.IsMatch(loc)).ToList();
|
||||||
|
|
||||||
|
@if (allLocations.Any())
|
||||||
|
{
|
||||||
|
var warningCount = longLocations.Count + locationsWithDateTime.Count;
|
||||||
|
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Parsed Locations (@allLocations.Count unique@(warningCount > 0 ? $", {warningCount} with warnings" : ""))</MudText>
|
||||||
|
<MudExpansionPanels Elevation="0">
|
||||||
|
<MudExpansionPanel Text="All Locations">
|
||||||
|
<MudList T="string">
|
||||||
|
@foreach (var location in allLocations)
|
||||||
|
{
|
||||||
|
var isLong = longLocations.Contains(location);
|
||||||
|
var hasDateTime = locationsWithDateTime.Contains(location);
|
||||||
|
var hasWarning = isLong || hasDateTime;
|
||||||
|
<MudListItem T="string">
|
||||||
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||||
|
@if (hasWarning)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Color="Color.Warning" Size="Size.Small">Warning</MudChip>
|
||||||
|
}
|
||||||
|
<MudText Style="@(hasWarning ? "color: var(--mud-palette-warning);" : "")">@location</MudText>
|
||||||
|
</MudStack>
|
||||||
|
</MudListItem>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@* Parsed Occurrences List *@
|
@* Parsed Occurrences List *@
|
||||||
@if (_parseResult.IsSuccess && _parseResult.Occurrences.Any())
|
@if (_parseResult.IsSuccess && _parseResult.Occurrences.Any())
|
||||||
{
|
{
|
||||||
@@ -208,10 +310,20 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var savedCount = 0;
|
var savedCount = 0;
|
||||||
|
var skippedCount = 0;
|
||||||
|
|
||||||
foreach (var kvp in _parseResult.Occurrences)
|
foreach (var kvp in _parseResult.Occurrences)
|
||||||
{
|
{
|
||||||
foreach (var occurrence in kvp.Value)
|
foreach (var occurrence in kvp.Value)
|
||||||
{
|
{
|
||||||
|
// Check for duplicates before adding
|
||||||
|
var isDuplicate = await IsDuplicate(occurrence);
|
||||||
|
if (isDuplicate)
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Add each occurrence to the database
|
// Add each occurrence to the database
|
||||||
await Context.EventOccurrences.AddAsync(occurrence);
|
await Context.EventOccurrences.AddAsync(occurrence);
|
||||||
savedCount++;
|
savedCount++;
|
||||||
@@ -219,11 +331,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync();
|
||||||
Snackbar.Add($"Successfully saved {savedCount} occurrence(s) to database", Severity.Success);
|
|
||||||
|
|
||||||
// Navigate back to the calendar index after a short delay
|
var message = $"Successfully saved {savedCount} occurrence(s) to database";
|
||||||
await Task.Delay(1000);
|
if (skippedCount > 0)
|
||||||
NavigationManager.NavigateTo("/calendar/event-occurrences");
|
{
|
||||||
|
message += $" ({skippedCount} duplicate(s) skipped)";
|
||||||
|
}
|
||||||
|
Snackbar.Add(message, Severity.Success);
|
||||||
|
|
||||||
|
// Clear input and output instead of navigating
|
||||||
|
_inputText = string.Empty;
|
||||||
|
_parseResult = null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -235,6 +353,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsDuplicate(EventOccurrence occurrence)
|
||||||
|
{
|
||||||
|
// Check if an occurrence with the same name, date, time, location, and event definition already exists
|
||||||
|
var query = Context.EventOccurrences
|
||||||
|
.Where(eo => eo.Name == occurrence.Name &&
|
||||||
|
eo.StartTime.Date == occurrence.StartTime.Date &&
|
||||||
|
eo.Time == occurrence.Time &&
|
||||||
|
eo.Location == occurrence.Location);
|
||||||
|
|
||||||
|
// Match by EventDefinitionId if it exists, otherwise match by SpecialEventType
|
||||||
|
if (occurrence.EventDefinitionId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(eo => eo.EventDefinitionId == occurrence.EventDefinitionId);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(occurrence.SpecialEventType))
|
||||||
|
{
|
||||||
|
query = query.Where(eo => eo.SpecialEventType == occurrence.SpecialEventType);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If neither EventDefinitionId nor SpecialEventType is set, match by both being null/empty
|
||||||
|
query = query.Where(eo => eo.EventDefinitionId == null && string.IsNullOrEmpty(eo.SpecialEventType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.AnyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private string GetEventName(EventDefinition eventDefinition)
|
private string GetEventName(EventDefinition eventDefinition)
|
||||||
{
|
{
|
||||||
if (eventDefinition == EventDefinition.GeneralSchedule)
|
if (eventDefinition == EventDefinition.GeneralSchedule)
|
||||||
@@ -249,5 +394,19 @@
|
|||||||
return "Social Gathering";
|
return "Social Gathering";
|
||||||
return eventDefinition.Name;
|
return eventDefinition.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Color GetIssueTypeColor(ParsingIssueType issueType)
|
||||||
|
{
|
||||||
|
return issueType switch
|
||||||
|
{
|
||||||
|
ParsingIssueType.UnmatchedLine => Color.Info,
|
||||||
|
ParsingIssueType.MissingEventDefinition => Color.Warning,
|
||||||
|
ParsingIssueType.TimeParseFailure => Color.Error,
|
||||||
|
ParsingIssueType.DateParseFailure => Color.Error,
|
||||||
|
ParsingIssueType.InvalidFormat => Color.Error,
|
||||||
|
_ => Color.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
@page "/calendar"
|
@page "/calendar"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using WebApp.Components.Shared.Components
|
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Services
|
@using WebApp.Services
|
||||||
@using Heron.MudCalendar
|
@using Heron.MudCalendar
|
||||||
@inject IEventOccurrenceService EventOccurrenceService
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using WebApp.Authentication
|
||||||
|
@inject ICalendarService CalendarService
|
||||||
|
@inject ILogger<Index> Logger
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
<PageHeader Title="Event Calendar" Description="View competition schedules and event occurrences" Icon="@AppIcons.EventCalendar">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
<MudTooltip Text="Import">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Schedule handout (print)">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<AuthorizeView Roles="@AuthRoles.Administrator">
|
||||||
|
<MudTooltip Text="Admin">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</AuthorizeView>
|
||||||
|
<PageNoteButton PageIdentifier="Event Calendar" />
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
@if (_calendarItems == null)
|
@if (_calendarItems == null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" />
|
<MudProgressLinear Indeterminate="true" />
|
||||||
@@ -20,48 +34,188 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudCalendar T="CalendarEventItem"
|
<MudStack Spacing="2">
|
||||||
Items="_calendarItems"
|
|
||||||
View="CalendarView.Day"
|
<MudCalendar T="CalendarItemWrapper"
|
||||||
CurrentDay=_calendarDate
|
Items="_calendarItems"
|
||||||
/>
|
@bind-View="_currentView"
|
||||||
|
@bind-CurrentDay="_calendarDate"
|
||||||
|
Class="event-calendar"
|
||||||
|
ItemClicked="OnItemClicked">
|
||||||
|
<MonthTemplate>
|
||||||
|
<MudTooltip Text="@GetEventTooltip(context)">
|
||||||
|
<div class="calendar-event-item">
|
||||||
|
@context.Text
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
</MonthTemplate>
|
||||||
|
<WeekTemplate>
|
||||||
|
<MudTooltip Text="@GetEventTooltip(context)">
|
||||||
|
<div class="calendar-event-item">
|
||||||
|
@context.Text
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
</WeekTemplate>
|
||||||
|
<DayTemplate>
|
||||||
|
<MudTooltip Text="@GetEventTooltip(context)">
|
||||||
|
<div class="calendar-event-item">
|
||||||
|
@context.Text
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
</DayTemplate>
|
||||||
|
</MudCalendar>
|
||||||
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<CalendarEventItem>? _calendarItems;
|
private List<CalendarItemWrapper>? _calendarItems;
|
||||||
private DateTime _calendarDate = DateTime.Today;
|
private DateTime _calendarDate = DateTime.Today;
|
||||||
|
private CalendarView _currentView = CalendarView.Month;
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery]
|
||||||
|
private string? Date { get; set; }
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
// Parse date from query parameter if provided
|
||||||
|
if (!string.IsNullOrEmpty(Date) && DateTime.TryParse(Date, out var parsedDate))
|
||||||
|
{
|
||||||
|
_calendarDate = parsedDate.Date;
|
||||||
|
}
|
||||||
|
|
||||||
await LoadCalendarEvents();
|
await LoadCalendarEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadCalendarEvents()
|
private async Task LoadCalendarEvents()
|
||||||
{
|
{
|
||||||
var occurrences = await EventOccurrenceService.GetEventOccurrencesAsync();
|
try
|
||||||
_calendarItems = occurrences
|
{
|
||||||
.Select(occ => new CalendarEventItem(occ))
|
Logger.LogInformation("Loading calendar events");
|
||||||
.ToList();
|
_calendarItems = await CalendarService.GetAllCalendarItemsAsync();
|
||||||
|
Logger.LogInformation("Loaded {Count} calendar items", _calendarItems.Count);
|
||||||
// Find the next date with events
|
}
|
||||||
_calendarDate = GetNextDateWithEvents();
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error loading calendar events");
|
||||||
|
_calendarItems = [];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime GetNextDateWithEvents()
|
|
||||||
|
|
||||||
|
private string GetEventTooltip(CalendarItemWrapper wrapper)
|
||||||
{
|
{
|
||||||
if (_calendarItems == null || !_calendarItems.Any())
|
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
|
||||||
{
|
{
|
||||||
return DateTime.Today;
|
return GetEventTooltip(wrapper.EventItem);
|
||||||
|
}
|
||||||
|
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
|
||||||
|
{
|
||||||
|
return GetMeetingTooltip(wrapper.MeetingItem);
|
||||||
|
}
|
||||||
|
return wrapper.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetEventTooltip(CalendarEventItem item)
|
||||||
|
{
|
||||||
|
List<string> parts = [];
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
|
||||||
|
{
|
||||||
|
parts.Add($"Event: {item.EventDefinition.Name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var today = DateTime.Today;
|
if (!string.IsNullOrEmpty(item.EventOccurrenceData?.Name))
|
||||||
var nextEvent = _calendarItems
|
{
|
||||||
.Where(item => item.Start.Date >= today)
|
parts.Add($"Occurrence: {item.EventOccurrenceData.Name}");
|
||||||
.OrderBy(item => item.Start)
|
}
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return nextEvent?.Start.Date ?? _calendarItems.OrderBy(item => item.Start).First().Start.Date;
|
if (!string.IsNullOrEmpty(item.EventOccurrenceData?.Location))
|
||||||
|
{
|
||||||
|
parts.Add($"Location: {item.EventOccurrenceData.Location}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.EventOccurrenceData?.StartTime != null)
|
||||||
|
{
|
||||||
|
parts.Add($"Time: {item.EventOccurrenceData.StartTime:g}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.StudentFirstNames.Any())
|
||||||
|
{
|
||||||
|
parts.Add($"Students: {string.Join(", ", item.StudentFirstNames)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetMeetingTooltip(CalendarMeetingItem item)
|
||||||
|
{
|
||||||
|
if (item.MeetingHistoryData == null)
|
||||||
|
return "Team Meeting";
|
||||||
|
|
||||||
|
return $"Team Meeting\nDate: {item.MeetingHistoryData.MeetingDate:g}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnItemClicked(CalendarItemWrapper wrapper)
|
||||||
|
{
|
||||||
|
if (_calendarItems == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
|
||||||
|
{
|
||||||
|
await ShowEventDetails(wrapper.EventItem);
|
||||||
|
}
|
||||||
|
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
|
||||||
|
{
|
||||||
|
await ShowMeetingDetails(wrapper.MeetingItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowEventDetails(CalendarEventItem item)
|
||||||
|
{
|
||||||
|
if (item.EventOccurrenceData == null) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["EventOccurrence"] = item.EventOccurrenceData,
|
||||||
|
["EventDefinition"] = item.EventDefinition,
|
||||||
|
["StudentFirstNames"] = item.StudentFirstNames
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
CloseButton = true,
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await DialogService.ShowAsync<EventOccurrenceDetailsDialog>("Event Details", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShowMeetingDetails(CalendarMeetingItem item)
|
||||||
|
{
|
||||||
|
if (item.MeetingHistoryData == null) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = item.MeetingHistoryData.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
CloseButton = true,
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
@page "/calendar/state-schedule-handout"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using System.Globalization
|
||||||
|
@using WebApp.Models
|
||||||
|
@using WebApp.Utility
|
||||||
|
@using WebApp.Services
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
|
||||||
|
@inject IEventOccurrenceService EventOccurrenceService
|
||||||
|
|
||||||
|
<div class="no-print">
|
||||||
|
<PageHeader
|
||||||
|
Title="State schedule handout"
|
||||||
|
Description="Print per-student schedules and the combined master list."
|
||||||
|
Icon="@Icons.Material.Filled.Print"
|
||||||
|
ShowBackButton="true"
|
||||||
|
BackButtonUrl="/calendar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_students == null || _allOccurrences == null)
|
||||||
|
{
|
||||||
|
<p><em>Loading...</em></p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var opts = HandoutOptionsMonitor.CurrentValue;
|
||||||
|
|
||||||
|
<MudContainer Class="state-schedule-handout">
|
||||||
|
@foreach (var student in _students)
|
||||||
|
{
|
||||||
|
<MudContainer Class="pagebreak">
|
||||||
|
<MudText Typo="Typo.h5">
|
||||||
|
@if (string.IsNullOrWhiteSpace(student.StateId))
|
||||||
|
{
|
||||||
|
@student.Name
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@($"{student.Name} - {student.StateId}")
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">
|
||||||
|
TSA @_competitionYear @_stateAbbrev State Schedule
|
||||||
|
</MudText>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>State ID</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var eventRow in GetEventSummaryRows(student))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@eventRow.StateRegistrationId</td>
|
||||||
|
<td>@eventRow.EventName</td>
|
||||||
|
<td>@eventRow.Activity</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
|
||||||
|
@{
|
||||||
|
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
|
||||||
|
@if (scheduleRows.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Location</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@FormatTimeDisplay(occ)</td>
|
||||||
|
<td>@FormatEventColumn(occ)</td>
|
||||||
|
<td>@(occ.Location ?? "")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudContainer Class="pagebreak">
|
||||||
|
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
|
||||||
|
@{
|
||||||
|
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
|
||||||
|
}
|
||||||
|
@if (combinedOccurrences.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
|
||||||
|
}
|
||||||
|
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Location</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var tlGroup in dateGroup
|
||||||
|
.OrderBy(o => o.StartTime)
|
||||||
|
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
|
||||||
|
.Select(g => g.ToList()))
|
||||||
|
{
|
||||||
|
if (tlGroup.Count == 1)
|
||||||
|
{
|
||||||
|
var occ = tlGroup[0];
|
||||||
|
<tr>
|
||||||
|
<td>@FormatTimeDisplay(occ)</td>
|
||||||
|
<td>@FormatCombinedScheduleEventCell(occ)</td>
|
||||||
|
<td>@(occ.Location ?? "")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
|
||||||
|
var specificOccs = tlGroup
|
||||||
|
.Where(o => o.EventDefinitionId.HasValue)
|
||||||
|
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
|
||||||
|
var representative = genericOcc ?? specificOccs[0];
|
||||||
|
|
||||||
|
if (genericOcc != null)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
||||||
|
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
|
||||||
|
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
||||||
|
</tr>
|
||||||
|
@foreach (var sub in specificOccs)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
|
||||||
|
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
|
||||||
|
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
|
||||||
|
</tr>
|
||||||
|
@foreach (var sub in specificOccs.Skip(1))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudContainer>
|
||||||
|
</MudContainer>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private Student[]? _students;
|
||||||
|
private List<EventOccurrence>? _allOccurrences;
|
||||||
|
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
|
||||||
|
private string _competitionYear = "";
|
||||||
|
private string _stateAbbrev = "";
|
||||||
|
private string? _chapterStateId;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
|
||||||
|
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
|
||||||
|
_chapterStateId = Configuration["ChapterSettings:StateId"];
|
||||||
|
|
||||||
|
_allOccurrences = await Context.EventOccurrences
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(eo => eo.EventDefinition)
|
||||||
|
.OrderBy(eo => eo.StartTime)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var eventDefIds = _allOccurrences
|
||||||
|
.Where(o => o.EventDefinitionId.HasValue)
|
||||||
|
.Select(o => o.EventDefinitionId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
|
||||||
|
|
||||||
|
// Tracking required: Include Teams->Students creates a graph cycle (Student–Team–Student) that EF disallows with AsNoTracking().
|
||||||
|
_students = await Context.Students
|
||||||
|
.Include(s => s.Teams)
|
||||||
|
.ThenInclude(t => t!.Event)
|
||||||
|
.Include(s => s.Teams)
|
||||||
|
.ThenInclude(t => t!.Captain)
|
||||||
|
.Include(s => s.Teams)
|
||||||
|
.ThenInclude(t => t!.Students)
|
||||||
|
.OrderBy(s => s.FirstName)
|
||||||
|
.ThenBy(s => s.LastName)
|
||||||
|
.ToArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
|
||||||
|
{
|
||||||
|
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
|
||||||
|
|
||||||
|
var competition = _allOccurrences!
|
||||||
|
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
|
||||||
|
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
|
||||||
|
|
||||||
|
var special = _allOccurrences!
|
||||||
|
.Where(o => o.EventDefinitionId == null)
|
||||||
|
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
|
||||||
|
|
||||||
|
return competition
|
||||||
|
.Concat(special)
|
||||||
|
.OrderBy(o => o.StartTime)
|
||||||
|
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
|
||||||
|
{
|
||||||
|
return _allOccurrences!
|
||||||
|
.Where(o =>
|
||||||
|
{
|
||||||
|
// Keep chapter-wide/special schedule rows.
|
||||||
|
if (!o.EventDefinitionId.HasValue)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Keep only competition events where this chapter has registered teams.
|
||||||
|
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
|
||||||
|
})
|
||||||
|
.OrderBy(o => o.StartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
|
||||||
|
{
|
||||||
|
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
|
||||||
|
{
|
||||||
|
yield return new EventSummaryRow(
|
||||||
|
StateRegistrationId: FormatStateRegistrationId(team, student),
|
||||||
|
EventName: team.Event.Name,
|
||||||
|
Activity: FormatActivitySummary(team, student));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
|
||||||
|
/// Individual events: competitor's <see cref="Student.StateId"/>.
|
||||||
|
/// </summary>
|
||||||
|
private string FormatStateRegistrationId(Team team, Student student)
|
||||||
|
{
|
||||||
|
if (team.Event.EventFormat == EventFormat.Individual)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(student.StateId)
|
||||||
|
? "—"
|
||||||
|
: student.StateId.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var chap = _chapterStateId?.Trim();
|
||||||
|
var ident = team.Identifier?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
|
||||||
|
return "—";
|
||||||
|
|
||||||
|
// Already a full registration id (e.g. "12227-1" or state id stored on team)
|
||||||
|
if (!string.IsNullOrEmpty(ident))
|
||||||
|
{
|
||||||
|
if (ident.Contains('-', StringComparison.Ordinal))
|
||||||
|
return ident;
|
||||||
|
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
|
||||||
|
return ident;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
|
||||||
|
return $"{chap}-{ident}";
|
||||||
|
return !string.IsNullOrEmpty(chap) ? chap : ident!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
|
||||||
|
private static string FormatActivitySummary(Team team, Student student)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (team.Captain?.Id == student.Id)
|
||||||
|
parts.Add("(Cpt.)");
|
||||||
|
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
|
||||||
|
parts.Add(team.Event.SemifinalistActivity!);
|
||||||
|
return string.Join(" ", parts).Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDateHeading(DateTime date) =>
|
||||||
|
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
|
||||||
|
|
||||||
|
private static string FormatTimeDisplay(EventOccurrence o)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(o.Time))
|
||||||
|
return o.Time.Trim();
|
||||||
|
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatEventColumn(EventOccurrence o)
|
||||||
|
{
|
||||||
|
if (o.EventDefinition != null)
|
||||||
|
{
|
||||||
|
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
|
||||||
|
? o.EventDefinition.ShortName
|
||||||
|
: o.EventDefinition.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(o.Name))
|
||||||
|
return ev;
|
||||||
|
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return o.Name.Trim();
|
||||||
|
return $"{ev} {o.Name}".Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
|
||||||
|
{
|
||||||
|
var baseText = FormatEventColumn(occ);
|
||||||
|
if (!occ.EventDefinitionId.HasValue)
|
||||||
|
return baseText;
|
||||||
|
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
|
||||||
|
return baseText;
|
||||||
|
|
||||||
|
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
|
||||||
|
|
||||||
|
var orderedTeams = teams
|
||||||
|
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
|
||||||
|
{
|
||||||
|
var cmp = CombinedScheduleTeamSortOrder(a, b);
|
||||||
|
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
|
||||||
|
}))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var rosterStrings = orderedTeams
|
||||||
|
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (rosterStrings.Count == 0)
|
||||||
|
return baseText;
|
||||||
|
|
||||||
|
var suffix = rosterStrings.Count == 1
|
||||||
|
? rosterStrings[0]
|
||||||
|
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
|
||||||
|
|
||||||
|
return $"{baseText} — {suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
|
||||||
|
{
|
||||||
|
var ka = a.Identifier?.Trim() ?? "";
|
||||||
|
var kb = b.Identifier?.Trim() ?? "";
|
||||||
|
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
|
||||||
|
return na.CompareTo(nb);
|
||||||
|
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
|
||||||
|
{
|
||||||
|
var students = team.Students?.ToList() ?? [];
|
||||||
|
if (students.Count == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
if (isIndividual)
|
||||||
|
{
|
||||||
|
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var cap = team.Captain;
|
||||||
|
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
|
||||||
|
IEnumerable<Student> orderedTeam = capInRoster
|
||||||
|
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
|
||||||
|
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
|
||||||
|
{
|
||||||
|
if (isIndividual)
|
||||||
|
{
|
||||||
|
var sid = student.StateId?.Trim();
|
||||||
|
return !string.IsNullOrEmpty(sid)
|
||||||
|
? $"{student.FirstName} ({sid})"
|
||||||
|
: student.FirstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCpt = team.Captain?.Id == student.Id;
|
||||||
|
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
|
||||||
|
}
|
||||||
@@ -14,14 +14,14 @@
|
|||||||
|
|
||||||
@if (_isLoading)
|
@if (_isLoading)
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary"/>
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary"/>
|
||||||
<MudText Typo="Typo.body1" Class="mt-4">Loading career mapping data...</MudText>
|
<MudText Typo="Typo.body1" Class="mt-4">Loading career mapping data...</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
else if (_networkData == null || !_networkData.Nodes.Any())
|
else if (_networkData == null || !_networkData.Nodes.Any())
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">No Career Field Mappings Found</MudText>
|
<MudText Typo="Typo.h6" Class="mb-4">No Career Field Mappings Found</MudText>
|
||||||
<MudText Typo="Typo.body1" Class="mud-text-secondary">
|
<MudText Typo="Typo.body1" Class="mud-text-secondary">
|
||||||
No events have related careers assigned that match any career fields. Edit events to add related careers.
|
No events have related careers assigned that match any career fields. Edit events to add related careers.
|
||||||
@@ -30,7 +30,7 @@ else if (_networkData == null || !_networkData.Nodes.Any())
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h6" Class="mb-4">Event-Career Field Relationships</MudText>
|
<MudText Typo="Typo.h6" Class="mb-4">Event-Career Field Relationships</MudText>
|
||||||
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
|
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
|
||||||
This diagram shows the connections between events and their related career fields.
|
This diagram shows the connections between events and their related career fields.
|
||||||
@@ -38,6 +38,10 @@ else
|
|||||||
Click on a node to see details. Use mouse to zoom and pan the graph.
|
Click on a node to see details. Use mouse to zoom and pan the graph.
|
||||||
</MudText>
|
</MudText>
|
||||||
|
|
||||||
|
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick" />
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_selectedNodeInfo != null)
|
@if (_selectedNodeInfo != null)
|
||||||
{
|
{
|
||||||
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
|
||||||
@@ -49,19 +53,18 @@ else
|
|||||||
@if (_selectedNodeInfo.Careers != null && _selectedNodeInfo.Careers.Any())
|
@if (_selectedNodeInfo.Careers != null && _selectedNodeInfo.Careers.Any())
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Related Careers:</MudText>
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Related Careers:</MudText>
|
||||||
<MudStack Row="true" Spacing="1" WrapItems="true">
|
<div class="career-mapping">
|
||||||
@foreach (var career in _selectedNodeInfo.Careers.OrderBy(c => c))
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||||
{
|
@foreach (var career in _selectedNodeInfo.Careers.OrderBy(c => c))
|
||||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Default">@career</MudChip>
|
{
|
||||||
}
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Default">@career</MudChip>
|
||||||
</MudStack>
|
}
|
||||||
|
</MudStack>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick"/>
|
|
||||||
</div>
|
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ else
|
|||||||
{
|
{
|
||||||
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
if (!_fieldIdToCareers.ContainsKey(field.Id))
|
||||||
{
|
{
|
||||||
_fieldIdToCareers[field.Id] = new List<string>();
|
_fieldIdToCareers[field.Id] = [];
|
||||||
}
|
}
|
||||||
// Add unique career names for this field
|
// Add unique career names for this field
|
||||||
foreach (var career in evt.RelatedCareers)
|
foreach (var career in evt.RelatedCareers)
|
||||||
@@ -150,8 +153,8 @@ else
|
|||||||
|
|
||||||
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
private NetworkData GenerateNetworkData(List<EventDefinition> events)
|
||||||
{
|
{
|
||||||
var nodes = new List<Node>();
|
List<Node> nodes = [];
|
||||||
var edges = new List<Edge>();
|
List<Edge> edges = [];
|
||||||
|
|
||||||
// Dictionary to track node IDs (to avoid duplicates)
|
// Dictionary to track node IDs (to avoid duplicates)
|
||||||
var eventNodeIds = new Dictionary<int, string>();
|
var eventNodeIds = new Dictionary<int, string>();
|
||||||
@@ -281,7 +284,7 @@ else
|
|||||||
Title = field.Name,
|
Title = field.Name,
|
||||||
Description = field.Description,
|
Description = field.Description,
|
||||||
IsCareerField = true,
|
IsCareerField = true,
|
||||||
Careers = careers?.ToList() ?? new List<string>()
|
Careers = careers?.ToList() ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
|
||||||
|
@if (EventDefinition is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
@* @if (EventDefinition.LevelOfEffort.HasValue)
|
@* @if (EventDefinition.LevelOfEffort.HasValue)
|
||||||
{
|
{
|
||||||
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
|
||||||
} *@
|
} *@
|
||||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled" Style="background-color: white; font-family: monospace; white-space: pre;">
|
<MudChip T="string" Color="Color.Default" Variant="@AppIcons.EventChipVariant()" Style="background-color: white; font-family: monospace; white-space: pre;">
|
||||||
@{
|
@{
|
||||||
var loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
|
var loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
|
||||||
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
|
||||||
@@ -27,12 +31,17 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public required EventDefinition EventDefinition { get; set; }
|
public EventDefinition? EventDefinition { get; set; }
|
||||||
|
|
||||||
private string _attributes = string.Empty;
|
private string _attributes = string.Empty;
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
|
if (EventDefinition is null)
|
||||||
|
{
|
||||||
|
_attributes = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
|
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
|
||||||
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
|
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
|
||||||
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
|
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<ValidationErrorDisplay Errors="_validationErrors" />
|
<ValidationErrorDisplay Errors="_validationErrors" />
|
||||||
|
|
||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" sm="6">
|
<MudItem xs="12" sm="6">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Event Details</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Event Details</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Team Configuration</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Team Configuration</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Competition Details</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Competition Details</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
|
|||||||
@@ -19,15 +19,19 @@
|
|||||||
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
BackButtonUrl="@(ReturnUrl ?? "/events")">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudTooltip Text="Print">
|
||||||
OnClick="PrintPage"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
OnClick="PrintPage"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Outlined">Edit</MudButton>
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Edit">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</div>
|
</div>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" sm="6" md="4">
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@page "/events/edit"
|
@page "/events/edit"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<ValidationErrorDisplay Errors="_validationErrors" />
|
<ValidationErrorDisplay Errors="_validationErrors" />
|
||||||
|
|
||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Basic Information</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" sm="6">
|
<MudItem xs="12" sm="6">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Event Details</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Event Details</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Team Configuration</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Team Configuration</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Competition Details</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Competition Details</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
@@ -163,9 +163,10 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the tracked entity from the database
|
// Get the tracked entity from the database (do not Include RelatedCareers:
|
||||||
|
// the same context may already be tracking those Career instances from the initial load,
|
||||||
|
// which would cause "another instance with the same key value is already being tracked").
|
||||||
var trackedEntity = await context.Events
|
var trackedEntity = await context.Events
|
||||||
.Include(e => e.RelatedCareers)
|
|
||||||
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
|
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
|
||||||
|
|
||||||
if (trackedEntity == null)
|
if (trackedEntity == null)
|
||||||
@@ -177,6 +178,8 @@
|
|||||||
|
|
||||||
// Update scalar properties from the form-bound entity
|
// Update scalar properties from the form-bound entity
|
||||||
context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
|
context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
|
||||||
|
// RelatedCareersText is not mapped; copy it so ProcessRelatedCareersAsync can use it
|
||||||
|
trackedEntity.RelatedCareersText = EventDefinition!.RelatedCareersText;
|
||||||
|
|
||||||
// Normalize and process related careers
|
// Normalize and process related careers
|
||||||
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
|
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/events"
|
@page "/events"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
@@ -9,13 +10,19 @@
|
|||||||
|
|
||||||
<PageHeader Title="Events">
|
<PageHeader Title="Events">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
<MudTooltip Text="Create New">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
<MudButton StartIcon="@Icons.Material.Filled.Create" Href="events/create" Variant="Variant.Filled" Color="Color.Primary">Create New</MudButton>
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Printable Descriptions">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="events/printout" Variant="Variant.Outlined">Printable Descriptions</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Career Mapping">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.AccountTree" Href="events/career-mapping" Variant="Variant.Outlined">Career Mapping</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudDataGrid T="EventDefinition"
|
<MudDataGrid T="EventDefinition"
|
||||||
ServerData="ServerReload"
|
ServerData="ServerReload"
|
||||||
@ref="_dataGrid"
|
@ref="_dataGrid"
|
||||||
@@ -30,7 +37,7 @@
|
|||||||
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
|
||||||
<CellTemplate>
|
<CellTemplate>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||||
<MudLink Href="@($"/events/details?id={context.Item.Id}")"
|
<MudLink Href="@($"/events/details?id={context.Item.Id}&returnUrl=/events")"
|
||||||
Underline="Underline.Hover"
|
Underline="Underline.Hover"
|
||||||
Color="Color.Primary">
|
Color="Color.Primary">
|
||||||
@context.Item.Name
|
@context.Item.Name
|
||||||
@@ -38,7 +45,7 @@
|
|||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||||
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
|
||||||
TooltipText="Edit"
|
TooltipText="Edit"
|
||||||
Href="@($"/events/edit?id={context.Item.Id}")" />
|
Href="@($"/events/edit?id={context.Item.Id}&returnUrl=/events")" />
|
||||||
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
|
||||||
TooltipText="Delete"
|
TooltipText="Delete"
|
||||||
HoverColor="Color.Error"
|
HoverColor="Color.Error"
|
||||||
@@ -74,16 +81,32 @@
|
|||||||
@code {
|
@code {
|
||||||
MudDataGrid<EventDefinition> _dataGrid = null!;
|
MudDataGrid<EventDefinition> _dataGrid = null!;
|
||||||
private bool _isLoading = true;
|
private bool _isLoading = true;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
|
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
|
||||||
{
|
{
|
||||||
|
if (_isDisposed)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = Context.Events.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
|
|
||||||
var totalItems = await query.CountAsync();
|
var query = Context.Events
|
||||||
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
|
.AsNoTracking()
|
||||||
|
.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
|
||||||
|
|
||||||
|
var totalItems = await query.CountAsync(cancellationToken);
|
||||||
|
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
return new GridData<EventDefinition>
|
return new GridData<EventDefinition>
|
||||||
{
|
{
|
||||||
@@ -91,31 +114,97 @@
|
|||||||
Items = pagedData
|
Items = pagedData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteEventDefinition(EventDefinition evt)
|
private async Task DeleteEventDefinition(EventDefinition evt)
|
||||||
{
|
{
|
||||||
//_isRowBlocked = true;
|
if (_isDisposed) return;
|
||||||
|
|
||||||
var result = await DialogService
|
try
|
||||||
.ShowMessageBox("Delete Event",
|
|
||||||
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
|
||||||
yesText:"Yes",
|
|
||||||
noText:"Cancel");
|
|
||||||
|
|
||||||
if (result == true)
|
|
||||||
{
|
{
|
||||||
Context.Events.Remove(evt!);
|
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||||
await Context.SaveChangesAsync();
|
|
||||||
Snackbar.Add($"Delete event: Delete of Event {evt.Name}", Severity.Info);
|
|
||||||
}
|
|
||||||
|
|
||||||
//_isRowBlocked = false;
|
var result = await DialogService
|
||||||
StateHasChanged();
|
.ShowMessageBox("Delete Event",
|
||||||
await _dataGrid.ReloadServerData();
|
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
|
||||||
|
yesText:"Yes",
|
||||||
|
noText:"Cancel");
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
// Load the event fresh from database with tracking to avoid tracking conflicts
|
||||||
|
var eventToDelete = await Context.Events
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == evt.Id, cancellationToken);
|
||||||
|
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
if (eventToDelete == null)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Event not found or already deleted", Severity.Warning);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Events.Remove(eventToDelete);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Event {eventToDelete.Name} deleted", Severity.Info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
await _dataGrid.ReloadServerData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error deleting event: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
@page "/meeting-schedule/history"
|
||||||
|
@attribute [Authorize]
|
||||||
|
@using Core.Entities
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Services
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject AppDbContext Context
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<PageHeader Title="Team Meeting Schedule History">
|
||||||
|
<ActionButtons>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Add"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Href="/meeting-schedule">
|
||||||
|
Back to Schedule
|
||||||
|
</MudButton>
|
||||||
|
</ActionButtons>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@if (_meetingHistories.Any())
|
||||||
|
{
|
||||||
|
<MudPaper Elevation="1" Class="pa-3" Style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
|
<table class="history-grid-table" style="min-width: max-content; width: 100%; border-collapse: collapse;">
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 72px;" />
|
||||||
|
<col style="width: 40px;" />
|
||||||
|
@for (var c = 0; c < _allTeams.Count; c++)
|
||||||
|
{
|
||||||
|
<col style="width: 28px;" />
|
||||||
|
}
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="history-cell history-cell-date">
|
||||||
|
<span class="mud-typography mud-typography-caption">Date</span>
|
||||||
|
</th>
|
||||||
|
<th class="history-cell history-cell-actions">
|
||||||
|
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">Actions</span>
|
||||||
|
</th>
|
||||||
|
@{
|
||||||
|
var teamIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var isEven = teamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||||||
|
<th class="@colClass">
|
||||||
|
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">@team.ToString()</span>
|
||||||
|
</th>
|
||||||
|
teamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="history-cell history-cell-date history-cell-times-met">Times Met</th>
|
||||||
|
<th class="history-cell history-cell-actions "></th>
|
||||||
|
@{
|
||||||
|
var summaryTeamIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var timesMet = GetTimesMetForTeam(team);
|
||||||
|
var isEven = summaryTeamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even history-cell-times-met" : "history-cell history-cell-col-odd history-cell-times-met";
|
||||||
|
<th class="@colClass">@timesMet</th>
|
||||||
|
summaryTeamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@{
|
||||||
|
var rowIndex = 0;
|
||||||
|
}
|
||||||
|
@foreach (var history in _meetingHistories)
|
||||||
|
{
|
||||||
|
var rowTeamIndex = 0;
|
||||||
|
var rowClass = rowIndex % 2 == 0 ? "history-row-even" : "history-row-odd";
|
||||||
|
<tr class="@rowClass">
|
||||||
|
<td class="history-cell history-cell-date ">
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Small"
|
||||||
|
OnClick="@(() => ViewMeetingDetails(history))"
|
||||||
|
Style="text-transform: none; padding: 0 2px; min-width: unset;">
|
||||||
|
@history.MeetingDate.ToString("MM/dd/yy")
|
||||||
|
</MudButton>
|
||||||
|
</td>
|
||||||
|
<td class="history-cell history-cell-actions">
|
||||||
|
<MudTooltip Text="Load into Planner">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Upload"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
OnClick="@(() => LoadMeetingIntoPlanner(history))"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="padding: 2px;" />
|
||||||
|
</MudTooltip>
|
||||||
|
</td>
|
||||||
|
@foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
var met = TeamMetOnDate(history, team);
|
||||||
|
var isEven = rowTeamIndex % 2 == 0;
|
||||||
|
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
|
||||||
|
<td class="@colClass">
|
||||||
|
@if (met)
|
||||||
|
{
|
||||||
|
<span class="mud-typography mud-typography-body1" style="font-weight: bold;">×</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
rowTeamIndex++;
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">No meeting history found. Save a meeting schedule to get started.</MudAlert>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.history-grid-table {
|
||||||
|
--history-border: 1px solid var(--mud-palette-divider);
|
||||||
|
--history-shade: var(--mud-palette-background-grey);
|
||||||
|
}
|
||||||
|
.history-grid-table th,
|
||||||
|
.history-grid-table td {
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-right: var(--history-border);
|
||||||
|
border-bottom: var(--history-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.history-grid-table thead th {
|
||||||
|
border-top: var(--history-border);
|
||||||
|
background-color: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
.history-grid-table thead .history-cell-col-odd {
|
||||||
|
background-color: var(--mud-palette-surface);
|
||||||
|
}
|
||||||
|
.history-grid-table thead th:first-child,
|
||||||
|
.history-grid-table tbody td:first-child {
|
||||||
|
border-left: var(--history-border);
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-date {
|
||||||
|
padding: 2px 4px;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-actions {
|
||||||
|
padding: 2px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
.history-grid-table thead .history-cell-col-even {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table .history-cell-times-met {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-odd td {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-even td.history-cell-col-even,
|
||||||
|
.history-grid-table tbody .history-row-odd td.history-cell-col-even {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
.history-grid-table tbody .history-row-odd td.history-cell-col-odd {
|
||||||
|
background-color: var(--history-shade);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<TeamMeetingHistory> _meetingHistories = [];
|
||||||
|
private List<Team> _allTeams = [];
|
||||||
|
private Dictionary<int, int> _timesMetDict = new();
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Load all teams for columns
|
||||||
|
_allTeams = await Context.Teams
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Event)
|
||||||
|
.OrderBy(t => t.Event.Name)
|
||||||
|
.ThenBy(t => t.Identifier)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Load meeting histories
|
||||||
|
await RefreshMeetingHistories();
|
||||||
|
|
||||||
|
// Calculate times met
|
||||||
|
CalculateTimesMet();
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshMeetingHistories()
|
||||||
|
{
|
||||||
|
_meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList();
|
||||||
|
|
||||||
|
// Extract all unique teams from meeting histories and merge with all teams
|
||||||
|
var teamsInHistory = _meetingHistories
|
||||||
|
.SelectMany(mh => mh.Teams)
|
||||||
|
.DistinctBy(t => t.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Merge with all teams, ensuring all teams are included
|
||||||
|
var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList();
|
||||||
|
_allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CalculateTimesMet()
|
||||||
|
{
|
||||||
|
_timesMetDict.Clear();
|
||||||
|
|
||||||
|
foreach (var team in _allTeams)
|
||||||
|
{
|
||||||
|
_timesMetDict[team.Id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var history in _meetingHistories)
|
||||||
|
{
|
||||||
|
var teamIds = history.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
foreach (var teamId in teamIds)
|
||||||
|
{
|
||||||
|
if (_timesMetDict.ContainsKey(teamId))
|
||||||
|
{
|
||||||
|
_timesMetDict[teamId]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetTimesMetForTeam(Team team)
|
||||||
|
{
|
||||||
|
return _timesMetDict.GetValueOrDefault(team.Id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TeamMetOnDate(TeamMeetingHistory history, Team team)
|
||||||
|
{
|
||||||
|
return history.Teams.Any(t => t.Id == team.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ViewMeetingDetails(TeamMeetingHistory history)
|
||||||
|
{
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = history.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Large,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
if (!result.Canceled)
|
||||||
|
{
|
||||||
|
// Refresh data if meeting was updated or deleted
|
||||||
|
await RefreshMeetingHistories();
|
||||||
|
CalculateTimesMet();
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMeetingIntoPlanner(TeamMeetingHistory history)
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all teams and students from database
|
||||||
|
var allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
var allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
|
||||||
|
// Match teams from history to all teams by ID for reference equality
|
||||||
|
var historyTeamIds = history.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Calculate absent students (all students not in the meeting history's student list)
|
||||||
|
var presentStudentIds = history.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(absentStudents);
|
||||||
|
// Clear extended teams and excluded students when loading from history
|
||||||
|
await StateService.SaveExtendedTeamsAsync([]);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
|
||||||
|
|
||||||
|
// Navigate to planner
|
||||||
|
NavigationManager.NavigateTo("/meeting-schedule");
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Loaded meeting from {history.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,139 @@
|
|||||||
@page "/meeting-schedule"
|
@page "/meeting-schedule"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@using System.Text
|
@using System.Text
|
||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
|
@using Core.Models
|
||||||
|
@using Core.Utility
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using WebApp.Components.Shared.Components
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using WebApp.Models
|
||||||
|
@using WebApp.Services
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
@inject AppDbContext Context
|
|
||||||
@inject ClipboardService ClipboardService
|
@inject ClipboardService ClipboardService
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleClipboardService ClipboardFormatService
|
||||||
@inject LocalStorageService LocalStorage
|
@inject LocalStorageService LocalStorage
|
||||||
|
|
||||||
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")" />
|
<PageHeader Title="@($"Meeting Scheduler Planner")">
|
||||||
|
<ActionButtons>
|
||||||
|
<MudTooltip Text="View meeting history">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.History"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Default"
|
||||||
|
Href="/meeting-schedule/history">
|
||||||
|
View History
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Save current schedule as meeting history">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="OpenSaveHistoryDialog"
|
||||||
|
Disabled="@(!_scheduledTeams.Any())">
|
||||||
|
Save to History
|
||||||
|
</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<PageNoteButton PageIdentifier="Meeting Schedule" />
|
||||||
|
</ActionButtons>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6 mt-4">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="7" sm="8" lg="9">
|
<MudItem xs="12" sm="8" lg="9">
|
||||||
<MudText Typo="Typo.h4">Time Slots</MudText>
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
||||||
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="6" sm="3" lg="2">
|
|
||||||
<MudNumericField Value="_parameters.TimeSlots"
|
|
||||||
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
|
|
||||||
Label="Time Slots" Min="1" Max="4">
|
|
||||||
</MudNumericField>
|
|
||||||
</MudItem>
|
|
||||||
<MudFlexBreak/>
|
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
</MudTooltip>
|
<MudText Typo="Typo.body2">Time Slots</MudText>
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Remove"
|
||||||
|
OnClick="DecrementTimeSlots"
|
||||||
|
Disabled="@(_parameters.TimeSlots <= 1)"
|
||||||
|
Size="Size.Small"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="border-radius: var(--mud-default-borderradius) 0 0 var(--mud-default-borderradius);" />
|
||||||
|
<MudButton Disabled="true"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="min-width: 50px; width: 50px; pointer-events: none; border-left: 1px solid var(--mud-palette-lines-default); border-right: 1px solid var(--mud-palette-lines-default); border-radius: 0;">
|
||||||
|
@_parameters.TimeSlots
|
||||||
|
</MudButton>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||||
|
OnClick="IncrementTimeSlots"
|
||||||
|
Disabled="@(_parameters.TimeSlots >= 4)"
|
||||||
|
Size="Size.Small"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
Style="border-radius: 0 var(--mud-default-borderradius) var(--mud-default-borderradius) 0;" />
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
<AddRemoveFilter Label="High Effort"
|
||||||
|
OnAdd="AddHighLevelOfEffort"
|
||||||
|
OnRemove="RemoveHighLevelOfEffort"
|
||||||
|
AddTooltip="Schedule teams with Level of Effort >= 3"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort >= 3" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
<AddRemoveFilter Label="Regionals"
|
||||||
|
OnAdd="AddRegionals"
|
||||||
|
OnRemove="RemoveRegionals"
|
||||||
|
AddTooltip="Add regional event teams"
|
||||||
|
RemoveTooltip="Remove regional event teams" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
<AddRemoveFilter Label="Presubmission"
|
||||||
|
OnAdd="AddPresubmission"
|
||||||
|
OnRemove="RemovePresubmission"
|
||||||
|
AddTooltip="Add teams whose events require presubmission"
|
||||||
|
RemoveTooltip="Remove teams whose events require presubmission" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<AddRemoveFilter Label="Individual"
|
||||||
|
OnAdd="AddIndividual"
|
||||||
|
OnRemove="RemoveIndividual"
|
||||||
|
AddTooltip="Add individual event teams"
|
||||||
|
RemoveTooltip="Remove individual event teams" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<AddRemoveFilter Label="Low Effort"
|
||||||
|
OnAdd="AddLowLevelOfEffort"
|
||||||
|
OnRemove="RemoveLowLevelOfEffort"
|
||||||
|
AddTooltip="Add teams with Level of Effort <= 1"
|
||||||
|
RemoveTooltip="Remove teams with Level of Effort <= 1" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
|
<MudTooltip Text="Load teams from clipboard text by matching team names">
|
||||||
|
<MudButton Variant="Variant.Outlined" OnClick="LoadTeamsFromClipboard" FullWidth="true" Disabled="@_isLoadingClipboard">Load from Clipboard</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12" sm="6" lg="4">
|
<MudItem xs="12" sm="6" lg="4">
|
||||||
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
<MudTooltip Text="Copy to Clipboard">
|
<MudSpacer />
|
||||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
<MudTooltip Text="Copy to Clipboard">
|
||||||
</MudTooltip>
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||||
|
OnClick="Solve"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Disabled="@_isSolving">
|
||||||
|
Solve
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@@ -62,11 +145,14 @@
|
|||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" lg="6">
|
<MudItem xs="12" lg="6">
|
||||||
<ScheduledTeamsList TimeSlotName="@context.Name"
|
<ScheduledTeamsList TimeSlotName="@context.Name"
|
||||||
|
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
||||||
Teams="@context.Teams"
|
Teams="@context.Teams"
|
||||||
ScheduledTeams="@_scheduledTeams"
|
ScheduledTeams="@_scheduledTeams"
|
||||||
AbsentStudents="@_absentStudents"
|
AbsentStudents="@_absentStudents"
|
||||||
StudentHasOverlaps="@context.StudentHasOverlaps"
|
StudentHasOverlaps="@context.StudentHasOverlaps"
|
||||||
OnToggleTeam="@ToggleRequiredTeam" />
|
ExcludedStudents="@_excludedStudents"
|
||||||
|
OnToggleTeam="@ToggleRequiredTeam"
|
||||||
|
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" lg="6">
|
<MudItem xs="12" lg="6">
|
||||||
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
||||||
@@ -81,7 +167,7 @@
|
|||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="5" sm="4" lg="3">
|
<MudItem xs="12" sm="4" lg="3">
|
||||||
<MudStack>
|
<MudStack>
|
||||||
<StudentTextBoxSelector Students="@_students"
|
<StudentTextBoxSelector Students="@_students"
|
||||||
SelectedStudents="_absentStudents"
|
SelectedStudents="_absentStudents"
|
||||||
@@ -90,11 +176,13 @@
|
|||||||
Label="Search for absent students"
|
Label="Search for absent students"
|
||||||
ShowFullName="true"/>
|
ShowFullName="true"/>
|
||||||
<MudDivider Class="my-4"/>
|
<MudDivider Class="my-4"/>
|
||||||
<TeamToggleSelector Teams="@_teams"
|
<TeamMeetingToggleSelector Teams="@_teams"
|
||||||
SelectedTeams="_scheduledTeams"
|
SelectedTeams="_scheduledTeams"
|
||||||
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
||||||
Title="Scheduled Teams"
|
ExtendedTeams="_extendedTeams"
|
||||||
ShowEventAttributes="true" />
|
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
||||||
|
Title="Scheduled Teams"
|
||||||
|
ShowEventAttributes="false" />
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
@@ -108,79 +196,158 @@
|
|||||||
private TeamSchedulerSolution _solution = null!;
|
private TeamSchedulerSolution _solution = null!;
|
||||||
private TeamSchedulerOptions _parameters = null!;
|
private TeamSchedulerOptions _parameters = null!;
|
||||||
bool _isSolving;
|
bool _isSolving;
|
||||||
|
private bool _isLoadingClipboard = false;
|
||||||
private IEnumerable<Team> _scheduledTeams = [];
|
private IEnumerable<Team> _scheduledTeams = [];
|
||||||
private IEnumerable<Student> _absentStudents = [];
|
private IEnumerable<Student> _absentStudents = [];
|
||||||
private IEnumerable<Team> _possibleAdditions = [];
|
private IEnumerable<Team> _possibleAdditions = [];
|
||||||
|
private IEnumerable<Team> _extendedTeams = [];
|
||||||
|
// Key: (teamId, timeSlotIndex, studentId) - Value: true if excluded
|
||||||
|
private Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> _excludedStudents = new();
|
||||||
|
// Track last saved state for dirty/clean comparison
|
||||||
|
private MeetingScheduleState? _lastSavedState;
|
||||||
|
|
||||||
private async Task OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
private void OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
||||||
{
|
{
|
||||||
_scheduledTeams = teams;
|
_scheduledTeams = teams;
|
||||||
await SaveScheduledTeams();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
|
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
||||||
{
|
{
|
||||||
_absentStudents = students;
|
_absentStudents = students;
|
||||||
await SaveAbsentStudents();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnTimeSlotCountChanged(int timeSlots)
|
private async Task OnTimeSlotCountChanged(int timeSlots)
|
||||||
{
|
{
|
||||||
_parameters.TimeSlots = timeSlots;
|
_parameters.TimeSlots = timeSlots;
|
||||||
await SaveTimeSlotCount();
|
StateHasChanged();
|
||||||
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void AddRegionals()
|
private async Task IncrementTimeSlots()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
if (_parameters.TimeSlots < 4)
|
||||||
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
|
{
|
||||||
await SaveScheduledTeams();
|
await OnTimeSlotCountChanged(_parameters.TimeSlots + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void AddHighLevelOfEffort()
|
private async Task DecrementTimeSlots()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
if (_parameters.TimeSlots > 1)
|
||||||
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
|
{
|
||||||
await SaveScheduledTeams();
|
await OnTimeSlotCountChanged(_parameters.TimeSlots - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void RemoveIndividual()
|
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_extendedTeams = teams;
|
||||||
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
|
StateHasChanged();
|
||||||
await SaveScheduledTeams();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void RemoveLowLevelOfEffort()
|
private void AddRegionals()
|
||||||
{
|
{
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
|
||||||
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
|
StateHasChanged();
|
||||||
await SaveScheduledTeams();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Invert()
|
private void AddPresubmission()
|
||||||
{
|
{
|
||||||
var rt = _scheduledTeams.ToArray();
|
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
|
||||||
_scheduledTeams
|
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
|
||||||
= _teams.Where(t => !rt.Contains(t));
|
StateHasChanged();
|
||||||
await SaveScheduledTeams();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Reset()
|
private void AddHighLevelOfEffort()
|
||||||
|
{
|
||||||
|
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveHighLevelOfEffort()
|
||||||
|
{
|
||||||
|
var highEffortTeamIds = _teams.Where(t => t.Event.LevelOfEffort >= 3).Select(t => t.Id).ToHashSet();
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => !highEffortTeamIds.Contains(t.Id));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveRegionals()
|
||||||
|
{
|
||||||
|
var regionalTeamIds = _teams.Where(t => t.Event.RegionalEvent).Select(t => t.Id).ToHashSet();
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => !regionalTeamIds.Contains(t.Id));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePresubmission()
|
||||||
|
{
|
||||||
|
var presubmissionTeamIds = _teams
|
||||||
|
.Where(t => t.Event?.Presubmission == true)
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
_scheduledTeams = _scheduledTeams.Where(t => !presubmissionTeamIds.Contains(t.Id));
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddIndividual()
|
||||||
|
{
|
||||||
|
var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(individualTeams).Distinct();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveIndividual()
|
||||||
|
{
|
||||||
|
_scheduledTeams = _scheduledTeams.RemoveIndividual();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLowLevelOfEffort()
|
||||||
|
{
|
||||||
|
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
|
||||||
|
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveLowLevelOfEffort()
|
||||||
|
{
|
||||||
|
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Invert()
|
||||||
|
{
|
||||||
|
_scheduledTeams = _scheduledTeams.Invert(_teams);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
{
|
{
|
||||||
_scheduledTeams = [];
|
_scheduledTeams = [];
|
||||||
await SaveScheduledTeams();
|
_extendedTeams = [];
|
||||||
|
_excludedStudents.Clear();
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ToggleRequiredTeam(Team unassignedTeam)
|
private void ToggleRequiredTeam(Team unassignedTeam)
|
||||||
{
|
{
|
||||||
if (_scheduledTeams.Contains(unassignedTeam))
|
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
|
||||||
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
|
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
|
||||||
|
if (matchingTeam == null) return;
|
||||||
|
|
||||||
|
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
IEnumerable<Team> newScheduledTeams;
|
||||||
|
|
||||||
|
if (scheduledTeamIds.Contains(matchingTeam.Id))
|
||||||
|
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
|
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
|
||||||
}
|
}
|
||||||
await SaveScheduledTeams();
|
|
||||||
|
// Update state and notify component to re-render
|
||||||
|
OnScheduledTeamsChanged(newScheduledTeams);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -214,70 +381,82 @@
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
_teams
|
_teams = await DataService.LoadTeamsAsync();
|
||||||
= await Context.Teams
|
_students = await DataService.LoadStudentsAsync();
|
||||||
.Include(e => e.Event)
|
|
||||||
.Include(e => e.Students)
|
|
||||||
.OrderBy(e => e.Event.Name)
|
|
||||||
.ThenBy(e => e.Identifier)
|
|
||||||
.ToArrayAsync();
|
|
||||||
|
|
||||||
_students =
|
|
||||||
await Context.Students
|
|
||||||
.Include(e => e.Teams)
|
|
||||||
.ThenInclude(e => e.Captain)
|
|
||||||
.Include(e => e.EventRankings)
|
|
||||||
.ThenInclude(e => e.EventDefinition)
|
|
||||||
.OrderBy(e => e.FirstName).ToArrayAsync();
|
|
||||||
|
|
||||||
// Load saved selections from localStorage
|
// Load saved selections from localStorage
|
||||||
await LoadScheduledTeams();
|
_scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams);
|
||||||
await LoadAbsentStudents();
|
_absentStudents = await StateService.LoadAbsentStudentsAsync(_students);
|
||||||
await LoadTimeSlotCount();
|
_parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2);
|
||||||
}
|
_extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams);
|
||||||
|
_excludedStudents = await StateService.LoadExcludedStudentsAsync();
|
||||||
|
|
||||||
private async Task SaveScheduledTeams()
|
// Initialize last saved state from loaded values
|
||||||
{
|
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
|
||||||
var teamIds = _scheduledTeams.Select(t => t.Id).ToArray();
|
if (_lastSavedState == null)
|
||||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", teamIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadScheduledTeams()
|
|
||||||
{
|
|
||||||
var teamIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams");
|
|
||||||
if (teamIds.Length > 0)
|
|
||||||
{
|
{
|
||||||
_scheduledTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray();
|
// If no saved state exists, create initial state from current values
|
||||||
|
_lastSavedState = MeetingScheduleState.FromCurrent(
|
||||||
|
_scheduledTeams,
|
||||||
|
_absentStudents,
|
||||||
|
_parameters.TimeSlots,
|
||||||
|
_extendedTeams,
|
||||||
|
_excludedStudents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveAbsentStudents()
|
|
||||||
{
|
|
||||||
var studentIds = _absentStudents.Select(s => s.Id).ToArray();
|
|
||||||
await LocalStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", studentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAbsentStudents()
|
private int GetTimeSlotIndex(string timeSlotName)
|
||||||
{
|
{
|
||||||
var studentIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents");
|
if (_solution?.TimeSlots == null)
|
||||||
if (studentIds.Length > 0)
|
return 0; // Default to first slot if solution not available
|
||||||
|
|
||||||
|
for (int i = 0; i < _solution.TimeSlots.Length; i++)
|
||||||
{
|
{
|
||||||
_absentStudents = _students.Where(s => studentIds.Contains(s.Id)).ToArray();
|
if (_solution.TimeSlots[i].Name == timeSlotName)
|
||||||
|
return i;
|
||||||
}
|
}
|
||||||
|
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveTimeSlotCount()
|
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
|
||||||
{
|
{
|
||||||
await LocalStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", _parameters.TimeSlots);
|
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadTimeSlotCount()
|
|
||||||
{
|
|
||||||
var timeSlots = await LocalStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2);
|
|
||||||
if (timeSlots > 0)
|
|
||||||
{
|
{
|
||||||
_parameters.TimeSlots = timeSlots;
|
_excludedStudents.Remove(key);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_excludedStudents[key] = true;
|
||||||
|
}
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
||||||
|
{
|
||||||
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
|
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
|
||||||
|
{
|
||||||
|
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
|
||||||
|
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsDirty()
|
||||||
|
{
|
||||||
|
if (_lastSavedState == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var currentState = MeetingScheduleState.FromCurrent(
|
||||||
|
_scheduledTeams,
|
||||||
|
_absentStudents,
|
||||||
|
_parameters.TimeSlots,
|
||||||
|
_extendedTeams,
|
||||||
|
_excludedStudents);
|
||||||
|
|
||||||
|
return !currentState.Equals(_lastSavedState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
|
||||||
@@ -306,23 +485,89 @@
|
|||||||
// Update parameters with absent student names
|
// Update parameters with absent student names
|
||||||
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
|
||||||
|
|
||||||
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
|
// Update parameters with extended team event names
|
||||||
|
_parameters.ExtendedTeams = _extendedTeams
|
||||||
|
.Where(t => t.Event != null)
|
||||||
|
.Select(t => t.Event!.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Create PartialTeam instances for teams with excluded students
|
||||||
|
var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents);
|
||||||
|
|
||||||
|
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
|
||||||
_solution = teamScheduler.Solve();
|
_solution = teamScheduler.Solve();
|
||||||
|
|
||||||
|
// Restore full teams (with all students) in the solution so excluded students still appear (dimmed)
|
||||||
|
// Create a mapping from PartialTeam to original Team
|
||||||
|
var teamMapping = _scheduledTeams.ToDictionary(t => t.Id, t => t);
|
||||||
|
|
||||||
|
for (int slotIndex = 0; slotIndex < _solution.TimeSlots.Length; slotIndex++)
|
||||||
|
{
|
||||||
|
var slot = _solution.TimeSlots[slotIndex];
|
||||||
|
if (slot.Teams == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var restoredTeams = slot.Teams.Select(team =>
|
||||||
|
{
|
||||||
|
// If this is a PartialTeam or we have the original, restore it
|
||||||
|
if (teamMapping.TryGetValue(team.Id, out var originalTeam))
|
||||||
|
{
|
||||||
|
return originalTeam;
|
||||||
|
}
|
||||||
|
return team;
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
slot.Teams = restoredTeams;
|
||||||
|
|
||||||
|
// Recalculate overlaps and unscheduled students with full teams
|
||||||
|
// Filter out excluded students when calculating overlaps and unscheduled students
|
||||||
|
var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex);
|
||||||
|
slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation);
|
||||||
|
// Use teams without excluded students so students excluded from all teams appear as unscheduled
|
||||||
|
slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(teamsForOverlapCalculation, availableStudents);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process: extend teams to next consecutive time slot
|
||||||
|
if (_extendedTeams.Any())
|
||||||
|
{
|
||||||
|
TeamSchedulerPostProcessor.ExtendTeamsInSolution(
|
||||||
|
_solution,
|
||||||
|
_extendedTeams,
|
||||||
|
availableStudents,
|
||||||
|
GetTeamsWithoutExcludedStudents);
|
||||||
|
}
|
||||||
|
|
||||||
// Try recommendation strategies in priority order
|
// Try recommendation strategies in priority order
|
||||||
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
|
||||||
var strategies = new[]
|
UnassignedScheduleStrategy[] strategies =
|
||||||
{
|
[
|
||||||
UnassignedScheduleStrategy.LevelOfEffort,
|
UnassignedScheduleStrategy.LevelOfEffort,
|
||||||
UnassignedScheduleStrategy.BiggestGroup,
|
UnassignedScheduleStrategy.BiggestGroup,
|
||||||
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
UnassignedScheduleStrategy.AnyNotMeetingAlready,
|
||||||
UnassignedScheduleStrategy.IndividualEvents
|
UnassignedScheduleStrategy.IndividualEvents
|
||||||
};
|
];
|
||||||
|
|
||||||
_possibleAdditions = strategies
|
_possibleAdditions = strategies
|
||||||
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
.Select(strategy => scheduler.ScheduleStrategy(strategy))
|
||||||
.FirstOrDefault(result => result.Any()) ?? [];
|
.FirstOrDefault(result => result.Any()) ?? [];
|
||||||
|
|
||||||
|
// Save state to localStorage after solving completes successfully
|
||||||
|
var currentState = MeetingScheduleState.FromCurrent(
|
||||||
|
_scheduledTeams,
|
||||||
|
_absentStudents,
|
||||||
|
_parameters.TimeSlots,
|
||||||
|
_extendedTeams,
|
||||||
|
_excludedStudents);
|
||||||
|
await currentState.SaveToLocalStorage(LocalStorage);
|
||||||
|
_lastSavedState = currentState;
|
||||||
|
|
||||||
|
// Also save via state service for consistency
|
||||||
|
await StateService.SaveScheduledTeamsAsync(_scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(_absentStudents);
|
||||||
|
await StateService.SaveTimeSlotCountAsync(_parameters.TimeSlots);
|
||||||
|
await StateService.SaveExtendedTeamsAsync(_extendedTeams);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(_excludedStudents);
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||||
|
|
||||||
_isSolving = false;
|
_isSolving = false;
|
||||||
@@ -334,19 +579,18 @@
|
|||||||
_solutionData.ReloadServerData();
|
_solutionData.ReloadServerData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async Task CopyToClipboard()
|
async Task CopyToClipboard()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var timeslot in _solution.TimeSlots)
|
|
||||||
{
|
|
||||||
AppendScheduledTeams(sb, timeslot);
|
|
||||||
AppendUnscheduledStudents(sb, timeslot);
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ClipboardService.WriteTextAsync(sb.ToString());
|
var text = ClipboardFormatService.FormatScheduleForClipboard(
|
||||||
|
_solution,
|
||||||
|
_teams,
|
||||||
|
_absentStudents,
|
||||||
|
_excludedStudents,
|
||||||
|
GetTimeSlotIndex);
|
||||||
|
await ClipboardService.WriteTextAsync(text);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -354,64 +598,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
private async Task OpenSaveHistoryDialog()
|
||||||
{
|
{
|
||||||
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
|
var parameters = new DialogParameters
|
||||||
{
|
{
|
||||||
var teamName = scheduledTeam.ToString();
|
["ScheduledTeams"] = _scheduledTeams,
|
||||||
|
["AbsentStudents"] = _absentStudents,
|
||||||
|
["AllTeams"] = _teams,
|
||||||
|
["AllStudents"] = _students
|
||||||
|
};
|
||||||
|
|
||||||
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Save Meeting History", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// Note: Success message is already shown in the dialog, no need to show another here
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTeamsFromClipboard()
|
||||||
|
{
|
||||||
|
if (_isLoadingClipboard) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isLoadingClipboard = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var clipboardText = await ClipboardService.ReadTextAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboardText))
|
||||||
{
|
{
|
||||||
sb.Append(teamName);
|
Snackbar.Add("Clipboard is empty", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedTeams = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _teams);
|
||||||
|
|
||||||
|
if (!matchedTeams.Any())
|
||||||
|
{
|
||||||
|
Snackbar.Add("No matching teams found in clipboard text", Severity.Info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine with existing scheduled teams, avoiding duplicates
|
||||||
|
var existingTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var newTeams = matchedTeams.Where(t => !existingTeamIds.Contains(t.Id));
|
||||||
|
var updatedTeams = _scheduledTeams.Concat(newTeams).ToList();
|
||||||
|
|
||||||
|
OnScheduledTeamsChanged(updatedTeams);
|
||||||
|
|
||||||
|
var newCount = newTeams.Count();
|
||||||
|
var totalCount = matchedTeams.Count();
|
||||||
|
if (newCount == totalCount)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Selected {totalCount} team(s) from clipboard", Severity.Success);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var studentsList = FormatStudentList(scheduledTeam, timeslot);
|
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
|
||||||
sb.Append($"{teamName} - {studentsList}");
|
|
||||||
}
|
}
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
}
|
}
|
||||||
}
|
catch (JSException ex)
|
||||||
|
|
||||||
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
|
|
||||||
{
|
|
||||||
return string.Join(", ",
|
|
||||||
team.Students
|
|
||||||
.OrderBy(e => e == team.Captain)
|
|
||||||
.ThenBy(e => e.FirstName)
|
|
||||||
.Select(e => FormatStudentName(e, timeslot)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
|
|
||||||
{
|
|
||||||
var name = student.FirstName;
|
|
||||||
if (timeslot.StudentHasOverlaps(student))
|
|
||||||
name += "*";
|
|
||||||
if (_absentStudents.Contains(student))
|
|
||||||
name += " (absent)";
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AppendUnscheduledStudents(StringBuilder sb, TeamScheduleTimeSlot timeslot)
|
|
||||||
{
|
|
||||||
if (!timeslot.UnscheduledStudents.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
sb.Append("--Unscheduled");
|
|
||||||
sb.Append(Environment.NewLine);
|
|
||||||
|
|
||||||
foreach (var student in timeslot.UnscheduledStudents)
|
|
||||||
{
|
{
|
||||||
var studentName = student.FirstName;
|
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
|
||||||
if (_absentStudents.Contains(student))
|
}
|
||||||
studentName += " (absent)";
|
catch (Exception ex)
|
||||||
|
{
|
||||||
var unassignedTeams = _solution.StudentUnassignedTeams(student);
|
Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error);
|
||||||
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
|
}
|
||||||
|
finally
|
||||||
sb.Append($"{studentName} - {teamsList}");
|
{
|
||||||
sb.Append(Environment.NewLine);
|
_isLoadingClipboard = false;
|
||||||
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
@namespace WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using Core.Services
|
||||||
|
@using WebApp.Models
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject IMeetingScheduleDataService DataService
|
||||||
|
@inject IMeetingScheduleStateService StateService
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else if (_meetingHistory == null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error">Meeting history not found.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@* Navigation Header *@
|
||||||
|
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Spacing="2">
|
||||||
|
<MudTooltip Text="@(_previousMeetingHistory != null ? $"Previous: {_previousMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No previous meeting")">
|
||||||
|
<span>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||||
|
OnClick="NavigateToPrevious"
|
||||||
|
Disabled="@(_previousMeetingHistory == null || IsActionDisabled)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
</span>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudText Typo="Typo.h6" Class="flex-grow-1" Style="text-align: center;">@_meetingHistory.MeetingDate.ToString("MM/dd/yyyy")</MudText>
|
||||||
|
<MudTooltip Text="@(_nextMeetingHistory != null ? $"Next: {_nextMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No next meeting")">
|
||||||
|
<span>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||||
|
OnClick="NavigateToNext"
|
||||||
|
Disabled="@(_nextMeetingHistory == null || IsActionDisabled)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Size="Size.Medium" />
|
||||||
|
</span>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-2">
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||||
|
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-2">
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||||
|
@{
|
||||||
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
|
||||||
|
}
|
||||||
|
@foreach (var student in allStudents)
|
||||||
|
{
|
||||||
|
var isPresent = presentStudentIds.Contains(student.Id);
|
||||||
|
<MudChip T="string"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Default"
|
||||||
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
|
Class="mx-1 my-1"
|
||||||
|
Style="@(!isPresent ? "opacity: 0.5;" : "")">
|
||||||
|
@student.FirstNameLastName
|
||||||
|
</MudChip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@if (_meetingNote != null)
|
||||||
|
{
|
||||||
|
<MudDivider />
|
||||||
|
<MudText Typo="Typo.subtitle1">Meeting Notes</MudText>
|
||||||
|
<MudPaper Elevation="1" Class="pa-3">
|
||||||
|
<MudText Typo="Typo.body2"><strong>@_meetingNote.Title</strong></MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_meetingNote.Content))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Class="mt-2">
|
||||||
|
@((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content))
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit"
|
||||||
|
OnClick="ViewNote"
|
||||||
|
Class="mt-2">
|
||||||
|
Edit Note
|
||||||
|
</MudButton>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
@if (_meetingHistory != null)
|
||||||
|
{
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Error"
|
||||||
|
OnClick="ConfirmDelete"
|
||||||
|
Disabled="@IsActionDisabled">
|
||||||
|
Delete
|
||||||
|
</MudButton>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Secondary"
|
||||||
|
OnClick="LoadIntoPlanner"
|
||||||
|
Disabled="@IsActionDisabled"
|
||||||
|
StartIcon="@Icons.Material.Filled.Upload">
|
||||||
|
Load into Planner
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Text"
|
||||||
|
Color="Color.Primary"
|
||||||
|
OnClick="OpenEditDialog"
|
||||||
|
Disabled="@IsActionDisabled"
|
||||||
|
StartIcon="@Icons.Material.Filled.Edit">
|
||||||
|
Edit
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="Close" Disabled="@IsActionDisabled">Close</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton OnClick="Close">Close</MudButton>
|
||||||
|
}
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int MeetingHistoryId { get; set; }
|
||||||
|
|
||||||
|
private TeamMeetingHistory? _meetingHistory;
|
||||||
|
private Note? _meetingNote;
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isDeleting = false;
|
||||||
|
private Team[] _allTeams = [];
|
||||||
|
private Student[] _allStudents = [];
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
private bool IsActionDisabled => _isLoading || _isDeleting;
|
||||||
|
private TeamMeetingHistory? _previousMeetingHistory;
|
||||||
|
private TeamMeetingHistory? _nextMeetingHistory;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// Load all teams and students for the edit dialog
|
||||||
|
_allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
_allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMeetingHistory()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId);
|
||||||
|
|
||||||
|
// Load note by title if meeting history exists
|
||||||
|
if (_meetingHistory != null)
|
||||||
|
{
|
||||||
|
_meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate);
|
||||||
|
|
||||||
|
// Load all meeting histories to find previous/next
|
||||||
|
await LoadNavigationMeetings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadNavigationMeetings()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all meeting histories ordered by date
|
||||||
|
var allMeetings = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync())
|
||||||
|
.OrderBy(m => m.MeetingDate)
|
||||||
|
.ThenBy(m => m.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var currentIndex = allMeetings.FindIndex(m => m.Id == _meetingHistory.Id);
|
||||||
|
|
||||||
|
if (currentIndex >= 0)
|
||||||
|
{
|
||||||
|
_previousMeetingHistory = currentIndex > 0 ? allMeetings[currentIndex - 1] : null;
|
||||||
|
_nextMeetingHistory = currentIndex < allMeetings.Count - 1 ? allMeetings[currentIndex + 1] : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_previousMeetingHistory = null;
|
||||||
|
_nextMeetingHistory = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
// Log error but don't show snackbar - navigation is not critical
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Error loading navigation meetings: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToPrevious()
|
||||||
|
{
|
||||||
|
if (_previousMeetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
// Close current dialog and open new one with previous meeting ID
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _previousMeetingHistory.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
MudDialog.Close();
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateToNext()
|
||||||
|
{
|
||||||
|
if (_nextMeetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
// Close current dialog and open new one with next meeting ID
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _nextMeetingHistory.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
MudDialog.Close();
|
||||||
|
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmDelete()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
var result = await DialogService.ShowMessageBox(
|
||||||
|
"Confirm Delete",
|
||||||
|
$"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.",
|
||||||
|
yesText: "Delete",
|
||||||
|
cancelText: "Cancel");
|
||||||
|
|
||||||
|
if (result == true)
|
||||||
|
{
|
||||||
|
await DeleteMeetingHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteMeetingHistory()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isDeleting = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Meeting history deleted successfully", Severity.Success);
|
||||||
|
MudDialog.Close(DialogResult.Ok(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error);
|
||||||
|
_isDeleting = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ViewNote()
|
||||||
|
{
|
||||||
|
if (_meetingNote == null) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["NoteId"] = _meetingNote.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<NoteViewDialog>("Meeting Notes", parameters, options);
|
||||||
|
await dialog.Result;
|
||||||
|
|
||||||
|
// Refresh meeting history to get updated note
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEditDialog()
|
||||||
|
{
|
||||||
|
if (_meetingHistory == null || _isDisposed) return;
|
||||||
|
|
||||||
|
var parameters = new DialogParameters
|
||||||
|
{
|
||||||
|
["MeetingHistoryId"] = _meetingHistory.Id,
|
||||||
|
["AllTeams"] = _allTeams,
|
||||||
|
["AllStudents"] = _allStudents,
|
||||||
|
["ScheduledTeams"] = new List<Team>(), // Not used when editing
|
||||||
|
["AbsentStudents"] = new List<Student>() // Not used when editing
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Medium,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Edit Meeting History", parameters, options);
|
||||||
|
var result = await dialog.Result;
|
||||||
|
|
||||||
|
// Refresh meeting history if dialog was saved
|
||||||
|
if (!result.Canceled && !_isDisposed)
|
||||||
|
{
|
||||||
|
await LoadMeetingHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Student> GetAllStudentsFromTeams()
|
||||||
|
{
|
||||||
|
if (_meetingHistory == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var allStudents = new List<Student>();
|
||||||
|
var studentIds = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var team in _meetingHistory.Teams)
|
||||||
|
{
|
||||||
|
foreach (var student in team.Students)
|
||||||
|
{
|
||||||
|
if (!studentIds.Contains(student.Id))
|
||||||
|
{
|
||||||
|
studentIds.Add(student.Id);
|
||||||
|
allStudents.Add(student);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allStudents;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadIntoPlanner()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _meetingHistory == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all teams and students from database
|
||||||
|
var allTeams = await DataService.LoadTeamsAsync();
|
||||||
|
var allStudents = await DataService.LoadStudentsAsync();
|
||||||
|
|
||||||
|
// Match teams from history to all teams by ID for reference equality
|
||||||
|
var historyTeamIds = _meetingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Calculate absent students (all students not in the meeting history's student list)
|
||||||
|
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
|
||||||
|
await StateService.SaveAbsentStudentsAsync(absentStudents);
|
||||||
|
// Clear extended teams and excluded students when loading from history
|
||||||
|
await StateService.SaveExtendedTeamsAsync([]);
|
||||||
|
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
|
||||||
|
|
||||||
|
// Close dialog and navigate to planner
|
||||||
|
MudDialog.Close();
|
||||||
|
NavigationManager.NavigateTo("/meeting-schedule");
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Loaded meeting from {_meetingHistory.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
@namespace WebApp.Components.Features.MeetingSchedule
|
||||||
|
@using Core.Entities
|
||||||
|
@using Core.Calculation
|
||||||
|
@using Core.Services
|
||||||
|
@using WebApp.Services
|
||||||
|
@using WebApp.Components.Shared.Components
|
||||||
|
@using WebApp.Components.Features.Teams.Components
|
||||||
|
@using WebApp.Components.Features.Students.Components
|
||||||
|
@using WebApp.Models
|
||||||
|
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
|
||||||
|
@inject INotesService NotesService
|
||||||
|
@inject INoteNamingService NoteNamingService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.h6">@(_isEditMode ? "Edit Meeting History" : "Save Meeting History")</MudText>
|
||||||
|
|
||||||
|
<MudDatePicker Label="Meeting Date"
|
||||||
|
Date="_meetingDate"
|
||||||
|
DateChanged="OnMeetingDateChanged"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Disabled="@_isEditMode" />
|
||||||
|
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<TeamToggleSelector Teams="@AllTeams"
|
||||||
|
SelectedTeams="_selectedTeams"
|
||||||
|
SelectedTeamsChanged="OnTeamsChanged"
|
||||||
|
Title="Teams That Met"
|
||||||
|
ShowEventAttributes="false" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
|
||||||
|
<StudentToggleSelector Students="@AllStudents"
|
||||||
|
SelectedStudents="_selectedStudents"
|
||||||
|
SelectedStudentsChanged="OnStudentsChanged"
|
||||||
|
Title="Students Present"
|
||||||
|
ShowFullName="true" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudDivider />
|
||||||
|
|
||||||
|
<MudExpansionPanels MultiExpansion="false">
|
||||||
|
<MudExpansionPanel Text="Meeting Notes (Optional)">
|
||||||
|
<MudStack Spacing="2">
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Note Title"
|
||||||
|
@bind-Value="_noteTitle"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
ReadOnly="true"
|
||||||
|
HelperText="Title is automatically generated based on meeting date" />
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Note Content"
|
||||||
|
@bind-Value="_noteContent"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Lines="5"
|
||||||
|
Placeholder="Enter meeting notes..."
|
||||||
|
HelperText="Optional markdown content for meeting notes" />
|
||||||
|
</MudStack>
|
||||||
|
</MudExpansionPanel>
|
||||||
|
</MudExpansionPanels>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="Cancel" Disabled="@_isSaving">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@IsSaveDisabled">
|
||||||
|
@if (_isSaving)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||||
|
<span>Saving...</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Save</span>
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
IMudDialogInstance MudDialog { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AbsentStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Team> AllTeams { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public IEnumerable<Student> AllStudents { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int? MeetingHistoryId { get; set; }
|
||||||
|
|
||||||
|
private DateTime? _meetingDate = DateTime.Today;
|
||||||
|
private IEnumerable<Team> _selectedTeams = [];
|
||||||
|
private IEnumerable<Student> _selectedStudents = [];
|
||||||
|
private string _noteTitle = "";
|
||||||
|
private string _noteContent = "";
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isSaving = false;
|
||||||
|
private bool _isEditMode = false;
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
private bool _isDisposed = false;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
_cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadInitialData()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// If editing, load existing meeting history
|
||||||
|
if (MeetingHistoryId.HasValue)
|
||||||
|
{
|
||||||
|
_isEditMode = true;
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
|
||||||
|
|
||||||
|
if (existingHistory != null)
|
||||||
|
{
|
||||||
|
_meetingDate = existingHistory.MeetingDate;
|
||||||
|
|
||||||
|
// Match teams by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
var matchedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id)).ToList();
|
||||||
|
|
||||||
|
// If we couldn't match all teams from AllTeams, use the teams from existing history
|
||||||
|
// This can happen if AllTeams is empty or doesn't contain all the teams
|
||||||
|
if (matchedTeams.Count != existingHistory.Teams.Count && AllTeams.Any())
|
||||||
|
{
|
||||||
|
// Try to match what we can, but log a warning
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all teams. Expected {existingHistory.Teams.Count}, matched {matchedTeams.Count}");
|
||||||
|
}
|
||||||
|
_selectedTeams = matchedTeams.Any() ? matchedTeams : existingHistory.Teams;
|
||||||
|
|
||||||
|
// Match students by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
var matchedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id)).ToList();
|
||||||
|
|
||||||
|
// If we couldn't match all students from AllStudents, use the students from existing history
|
||||||
|
if (matchedStudents.Count != existingHistory.Students.Count && AllStudents.Any())
|
||||||
|
{
|
||||||
|
// Try to match what we can, but log a warning
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all students. Expected {existingHistory.Students.Count}, matched {matchedStudents.Count}");
|
||||||
|
}
|
||||||
|
_selectedStudents = matchedStudents.Any() ? matchedStudents : existingHistory.Students;
|
||||||
|
|
||||||
|
// Load existing note if available
|
||||||
|
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
|
||||||
|
if (existingNote != null)
|
||||||
|
{
|
||||||
|
_noteContent = existingNote.Content ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Initialize selected teams from scheduled teams
|
||||||
|
_selectedTeams = ScheduledTeams;
|
||||||
|
|
||||||
|
// Initialize selected students (all students except absent ones)
|
||||||
|
var absentStudentIds = AbsentStudents.Select(s => s.Id).ToHashSet();
|
||||||
|
_selectedStudents = AllStudents.Where(s => !absentStudentIds.Contains(s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate default note title if meeting date is set
|
||||||
|
if (_meetingDate.HasValue)
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error loading data: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSaveDisabled => _isSaving || _isLoading;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (_meetingDate.HasValue && string.IsNullOrEmpty(_noteTitle))
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
}
|
||||||
|
await base.OnParametersSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMeetingDateChanged(DateTime? date)
|
||||||
|
{
|
||||||
|
_meetingDate = date;
|
||||||
|
if (date.HasValue)
|
||||||
|
{
|
||||||
|
_noteTitle = NoteNamingService.GetMeetingNoteTitle(date.Value);
|
||||||
|
}
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTeamsChanged(IEnumerable<Team> teams)
|
||||||
|
{
|
||||||
|
_selectedTeams = teams;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnStudentsChanged(IEnumerable<Student> students)
|
||||||
|
{
|
||||||
|
_selectedStudents = students;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
if (_isDisposed || _isSaving) return;
|
||||||
|
|
||||||
|
if (!_meetingDate.HasValue)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Please select a meeting date", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have at least one team selected
|
||||||
|
if (!_selectedTeams.Any())
|
||||||
|
{
|
||||||
|
Snackbar.Add("Please select at least one team", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a meeting history already exists for this date (only when creating new, not editing)
|
||||||
|
if (!_isEditMode)
|
||||||
|
{
|
||||||
|
var dateOnly = _meetingDate.Value.Date;
|
||||||
|
// GetMeetingHistoriesAsync: startDate >= date, endDate < (endDate.Date + 1 day)
|
||||||
|
// To get meetings for a single day, pass dateOnly as startDate and dateOnly as endDate
|
||||||
|
// This becomes: MeetingDate >= dateOnly AND MeetingDate < (dateOnly + 1 day) = just that day
|
||||||
|
var existingMeetings = await TeamMeetingHistoryService.GetMeetingHistoriesAsync(dateOnly, dateOnly);
|
||||||
|
if (existingMeetings.Any())
|
||||||
|
{
|
||||||
|
// Show confirmation dialog
|
||||||
|
var confirmResult = await DialogService.ShowMessageBox(
|
||||||
|
"Overwrite Meeting?",
|
||||||
|
"A meeting already exists for this date. Overwrite it?",
|
||||||
|
yesText: "Overwrite",
|
||||||
|
cancelText: "Cancel");
|
||||||
|
|
||||||
|
if (confirmResult != true)
|
||||||
|
{
|
||||||
|
return; // User cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// User confirmed, switch to edit mode
|
||||||
|
var existingMeeting = existingMeetings.First();
|
||||||
|
_isEditMode = true;
|
||||||
|
MeetingHistoryId = existingMeeting.Id;
|
||||||
|
|
||||||
|
// Load existing meeting data
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(existingMeeting.Id);
|
||||||
|
if (existingHistory != null)
|
||||||
|
{
|
||||||
|
// Match teams by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
|
||||||
|
_selectedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id));
|
||||||
|
|
||||||
|
// Match students by ID to ensure reference equality with MudToggleGroup
|
||||||
|
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
|
||||||
|
_selectedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id));
|
||||||
|
|
||||||
|
// Load existing note if available
|
||||||
|
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
|
||||||
|
if (existingNote != null)
|
||||||
|
{
|
||||||
|
_noteContent = existingNote.Content ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isSaving = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Create or update note if note content is provided
|
||||||
|
Note? note = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(_noteContent))
|
||||||
|
{
|
||||||
|
// Use the naming service to get the meeting note title
|
||||||
|
var noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
|
||||||
|
|
||||||
|
// Check if note already exists
|
||||||
|
var existingNote = await NotesService.GetNotesAsync(includeDeleted: false);
|
||||||
|
note = existingNote.FirstOrDefault(n => n.Title == noteTitle);
|
||||||
|
|
||||||
|
if (note != null)
|
||||||
|
{
|
||||||
|
// Update existing note
|
||||||
|
note.Content = _noteContent;
|
||||||
|
note = await NotesService.UpdateNoteAsync(note);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new note
|
||||||
|
note = new Note
|
||||||
|
{
|
||||||
|
Title = noteTitle,
|
||||||
|
Content = _noteContent
|
||||||
|
};
|
||||||
|
note = await NotesService.CreateNoteAsync(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update meeting history
|
||||||
|
TeamMeetingHistory meetingHistory;
|
||||||
|
if (_isEditMode && MeetingHistoryId.HasValue)
|
||||||
|
{
|
||||||
|
// Update existing meeting history
|
||||||
|
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
|
||||||
|
if (existingHistory == null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Meeting history not found", Severity.Error);
|
||||||
|
_isSaving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have teams and students - use existing if selected lists are empty (fallback)
|
||||||
|
var teamsToSave = _selectedTeams.Any() ? _selectedTeams.ToList() : existingHistory.Teams.ToList();
|
||||||
|
var studentsToSave = _selectedStudents.Any() ? _selectedStudents.ToList() : existingHistory.Students.ToList();
|
||||||
|
|
||||||
|
// Create a new meeting history object with the updated data
|
||||||
|
// Use IDs to ensure we're working with the correct entities
|
||||||
|
meetingHistory = new TeamMeetingHistory
|
||||||
|
{
|
||||||
|
Id = existingHistory.Id,
|
||||||
|
MeetingDate = _meetingDate.Value,
|
||||||
|
Teams = teamsToSave,
|
||||||
|
Students = studentsToSave
|
||||||
|
};
|
||||||
|
|
||||||
|
await TeamMeetingHistoryService.UpdateMeetingHistoryAsync(meetingHistory);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Meeting history updated for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new meeting history
|
||||||
|
meetingHistory = new TeamMeetingHistory
|
||||||
|
{
|
||||||
|
MeetingDate = _meetingDate.Value,
|
||||||
|
Teams = _selectedTeams.ToList(),
|
||||||
|
Students = _selectedStudents.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await TeamMeetingHistoryService.CreateMeetingHistoryAsync(meetingHistory);
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Meeting history saved for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
MudDialog.Close(DialogResult.Ok(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Component was disposed, ignore
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// JS connection lost, ignore
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Error saving meeting history: {ex.Message}", Severity.Error);
|
||||||
|
_isSaving = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
if (_isDisposed) return;
|
||||||
|
MudDialog.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (!_isDisposed)
|
||||||
|
{
|
||||||
|
_isDisposed = true;
|
||||||
|
_cancellationTokenSource?.Cancel();
|
||||||
|
_cancellationTokenSource?.Dispose();
|
||||||
|
_cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
await ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,93 @@
|
|||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
@using WebApp.Models
|
@using WebApp.Models
|
||||||
|
@using Core.Utility
|
||||||
|
|
||||||
<MudStack>
|
<MudStack>
|
||||||
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
|
||||||
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
|
||||||
{
|
{
|
||||||
var removed = !ScheduledTeams.Contains(team);
|
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var removed = !scheduledTeamIds.Contains(team.Id);
|
||||||
|
|
||||||
<MudLink Typo="Typo.body1"
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
|
||||||
Class="d-flex align-center"
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
Color="Color.Default"
|
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
||||||
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
|
Size="Size.Small"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Clear"
|
Class="@(removed ? "" : "d-none")"
|
||||||
Size="Size.Small"
|
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
|
||||||
Class="@(removed ? "" : "d-none")">
|
Style="cursor: pointer;">
|
||||||
</MudIcon>
|
</MudIcon>
|
||||||
@team -
|
@{
|
||||||
@foreach (var student in team.Students)
|
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||||
{
|
team,
|
||||||
var overlap = StudentHasOverlaps(student);
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
var isAbsent = AbsentStudents.Contains(student);
|
{
|
||||||
var color = overlap ? Color.Warning : Color.Default;
|
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||||
var suffix = GetStudentSuffix(overlap, isAbsent);
|
});
|
||||||
|
|
||||||
if (student != team.Students.First())
|
|
||||||
{
|
|
||||||
<MudText>, </MudText>
|
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.body2" Color="@color">
|
<MudTooltip Text="@teamMembers">
|
||||||
@student.FirstName@suffix
|
<div @onclick="@(() => OnToggleTeam.InvokeAsync(team))" style="cursor: pointer; display: inline-block;">
|
||||||
</MudText>
|
<MudChip T="string"
|
||||||
}
|
Size="Size.Small"
|
||||||
</MudLink>
|
Color="Color.Default"
|
||||||
|
Variant="@AppIcons.TeamChipVariant()">
|
||||||
|
@team
|
||||||
|
</MudChip>
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
</MudStack>
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||||
|
@{
|
||||||
|
var nonExcludedStudentCount = GetNonExcludedStudentCount(team);
|
||||||
|
}
|
||||||
|
@foreach (var student in team.Students)
|
||||||
|
{
|
||||||
|
var overlap = StudentHasOverlaps(student);
|
||||||
|
var chipColor = overlap ? Color.Warning : Color.Default;
|
||||||
|
var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id);
|
||||||
|
var formattedName = TeamStudentNameFormatter.FormatStudentName(
|
||||||
|
student,
|
||||||
|
team,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
MarkOverlaps = true,
|
||||||
|
HasOverlaps = StudentHasOverlaps,
|
||||||
|
MarkAbsent = true,
|
||||||
|
AbsentStudents = AbsentStudents.ToList()
|
||||||
|
});
|
||||||
|
|
||||||
|
@if (nonExcludedStudentCount > 1)
|
||||||
|
{
|
||||||
|
<InteractiveChip Size="Size.Small"
|
||||||
|
Color="@chipColor"
|
||||||
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
|
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||||
|
<ChildContent>
|
||||||
|
<span>@formattedName</span>
|
||||||
|
</ChildContent>
|
||||||
|
<ControlContent>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="Color.Error"
|
||||||
|
Variant="Variant.Text"
|
||||||
|
OnClick="@(() => OnToggleStudentExclusion.InvokeAsync((team.Id, TimeSlotIndex, student.Id)))"
|
||||||
|
Style="padding: 0; min-width: 16px; width: 16px; height: 16px; margin-left: 4px;" />
|
||||||
|
</ControlContent>
|
||||||
|
</InteractiveChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="@chipColor"
|
||||||
|
Variant="@AppIcons.StudentChipVariant()"
|
||||||
|
Style="@(isExcluded ? "opacity: 0.5;" : "")">
|
||||||
|
<span>@formattedName</span>
|
||||||
|
</MudChip>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
@@ -39,6 +95,9 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string TimeSlotName { get; set; } = string.Empty;
|
public string TimeSlotName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int TimeSlotIndex { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public IEnumerable<Team> Teams { get; set; } = [];
|
public IEnumerable<Team> Teams { get; set; } = [];
|
||||||
|
|
||||||
@@ -51,13 +110,23 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new();
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<Team> OnToggleTeam { get; set; }
|
public EventCallback<Team> OnToggleTeam { get; set; }
|
||||||
|
|
||||||
private string GetStudentSuffix(bool overlap, bool isAbsent)
|
[Parameter]
|
||||||
|
public EventCallback<(int teamId, int timeSlotIndex, int studentId)> OnToggleStudentExclusion { get; set; }
|
||||||
|
|
||||||
|
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
|
||||||
{
|
{
|
||||||
var suffix = overlap ? "*" : "";
|
var key = (teamId, timeSlotIndex, studentId);
|
||||||
suffix += isAbsent ? " (absent)" : "";
|
return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key];
|
||||||
return suffix;
|
}
|
||||||
|
|
||||||
|
private int GetNonExcludedStudentCount(Team team)
|
||||||
|
{
|
||||||
|
return team.Students.Count(s => !IsStudentExcluded(team.Id, TimeSlotIndex, s.Id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@using Core.Calculation
|
@using Core.Calculation
|
||||||
|
@using Core.Utility
|
||||||
|
@using WebApp.Models
|
||||||
|
|
||||||
@if (UnscheduledStudents.Any())
|
@if (UnscheduledStudents.Any())
|
||||||
{
|
{
|
||||||
@@ -6,31 +8,52 @@
|
|||||||
<MudStack>
|
<MudStack>
|
||||||
@foreach (var student in UnscheduledStudents)
|
@foreach (var student in UnscheduledStudents)
|
||||||
{
|
{
|
||||||
var isAbsent = AbsentStudents.Contains(student);
|
|
||||||
<MudItem>
|
<MudItem>
|
||||||
<MudText Typo="Typo.body1" HtmlTag="i">
|
@{
|
||||||
@student.FirstName@(isAbsent ? " (absent)" : "")
|
var formattedName = StudentNameFormatter.FormatStudentName(
|
||||||
</MudText>
|
student,
|
||||||
@foreach (var unassignedTeam in UnassignedTeams(student))
|
new StudentNameFormatter.FormatOptions
|
||||||
{
|
{
|
||||||
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
IsAbsent = AbsentStudents.Contains(student)
|
||||||
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
|
});
|
||||||
var color = isPossibleAddition ? Color.Success : Color.Default;
|
|
||||||
|
|
||||||
if (unassignedTeam != UnassignedTeams(student).First())
|
|
||||||
{
|
|
||||||
<span>, </span>
|
|
||||||
}
|
|
||||||
<MudLink Typo="Typo.body2"
|
|
||||||
Color="@color"
|
|
||||||
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Check"
|
|
||||||
Size="Size.Small"
|
|
||||||
Class="@(isScheduled ? "" : "d-none")">
|
|
||||||
</MudIcon>
|
|
||||||
@unassignedTeam
|
|
||||||
</MudLink>
|
|
||||||
}
|
}
|
||||||
|
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||||
|
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="@AppIcons.StudentChipVariant()" Class="font-style-italic">
|
||||||
|
@formattedName
|
||||||
|
</MudChip>
|
||||||
|
</MudStack>
|
||||||
|
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||||
|
@foreach (var unassignedTeam in UnassignedTeams(student))
|
||||||
|
{
|
||||||
|
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
|
||||||
|
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
|
||||||
|
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
|
||||||
|
var chipColor = isPossibleAddition ? Color.Success : Color.Default;
|
||||||
|
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
|
||||||
|
unassignedTeam,
|
||||||
|
new TeamStudentNameFormatter.FormatOptions
|
||||||
|
{
|
||||||
|
Ordering = TeamStudentNameFormatter.OrderingStyle.None
|
||||||
|
});
|
||||||
|
|
||||||
|
<MudTooltip Text="@teamMembers">
|
||||||
|
<div @onclick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;">
|
||||||
|
<MudChip T="string"
|
||||||
|
Size="Size.Small"
|
||||||
|
Color="@chipColor"
|
||||||
|
Variant="@AppIcons.TeamChipVariant()">
|
||||||
|
@if (isScheduled)
|
||||||
|
{
|
||||||
|
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Style="margin-right: 4px;" />
|
||||||
|
}
|
||||||
|
@unassignedTeam
|
||||||
|
</MudChip>
|
||||||
|
</div>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<ValidationErrorDisplay Errors="_validationErrors" />
|
<ValidationErrorDisplay Errors="_validationErrors" />
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Student Information</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Student Information</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" sm="6">
|
<MudItem xs="12" sm="6">
|
||||||
|
|||||||
@@ -18,15 +18,19 @@
|
|||||||
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
BackButtonUrl="@(ReturnUrl ?? "/students")">
|
||||||
<ActionButtons>
|
<ActionButtons>
|
||||||
<div class="no-print">
|
<div class="no-print">
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
<MudTooltip Text="Print">
|
||||||
OnClick="PrintPage"
|
<MudButton StartIcon="@Icons.Material.Filled.Print"
|
||||||
Variant="Variant.Outlined">Print</MudButton>
|
OnClick="PrintPage"
|
||||||
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
Variant="Variant.Outlined">Print</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="Edit">
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/students/edit?id={student.Id}&returnUrl={ReturnUrl ?? "/students"}")" Variant="Variant.Outlined">Edit</MudButton>
|
||||||
|
</MudTooltip>
|
||||||
</div>
|
</div>
|
||||||
</ActionButtons>
|
</ActionButtons>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<MudPaper Elevation="2" Class="pa-6">
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
|
||||||
<MudText Typo="Typo.h5" Class="mb-4">Student Information</MudText>
|
<MudText Typo="Typo.h5" Class="mb-4">Student Information</MudText>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" sm="6" md="4">
|
<MudItem xs="12" sm="6" md="4">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user