@page "/meeting-schedule" @attribute [Authorize] @using System.Text @using Core.Calculation @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components @using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @inject ClipboardService ClipboardService @inject LocalStorageService LocalStorage Time Slots Add High Effort Add Regionals Remove Individual Remove Low Effort Invert Reset Solve @code { private Team[] _teams = null!; private Student[] _students = null!; MudTable _solutionData = null!; private TeamSchedulerSolution _solution = null!; private TeamSchedulerOptions _parameters = null!; bool _isSolving; private IEnumerable _scheduledTeams = []; private IEnumerable _absentStudents = []; private IEnumerable _possibleAdditions = []; private IEnumerable _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 void OnScheduledTeamsChanged(IEnumerable teams) { _scheduledTeams = teams; StateHasChanged(); } private void OnAbsentStudentsChanged(IEnumerable students) { _absentStudents = students; StateHasChanged(); } private async Task OnTimeSlotCountChanged(int timeSlots) { _parameters.TimeSlots = timeSlots; StateHasChanged(); await Task.CompletedTask; } private void OnExtendedTeamsChanged(IEnumerable teams) { _extendedTeams = teams; StateHasChanged(); } private void AddRegionals() { _scheduledTeams = _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct(); StateHasChanged(); } private void AddHighLevelOfEffort() { _scheduledTeams = _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct(); StateHasChanged(); } private void RemoveIndividual() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual); StateHasChanged(); } private void RemoveLowLevelOfEffort() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1); StateHasChanged(); } private void Invert() { var rt = _scheduledTeams.ToArray(); _scheduledTeams = _teams.Where(t => !rt.Contains(t)); StateHasChanged(); } private void Reset() { _scheduledTeams = []; _extendedTeams = []; _excludedStudents.Clear(); StateHasChanged(); } private void ToggleRequiredTeam(Team unassignedTeam) { // Find the matching team from _teams to ensure reference equality with MudToggleGroup var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id); if (matchingTeam == null) return; var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet(); IEnumerable newScheduledTeams; if (scheduledTeamIds.Contains(matchingTeam.Id)) newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id); else { newScheduledTeams = _scheduledTeams.Concat([matchingTeam]); } // Update state and notify component to re-render OnScheduledTeamsChanged(newScheduledTeams); } protected override async Task OnInitializedAsync() { _parameters = new TeamSchedulerOptions( 2, mustIncludeEvents: [ // "Medical Technology", "Electrical Applications" , "RegionalTeam", // ,"Dragster", "Flight" ], extended: [ // "Invention", "Construction Challenge", "Mechanical", "Mass", "Micro" //"STEM" //"Community", "Vlogging"// "Microcontroller" ], omittedEvents: [ // "Vlogging", "Junior", "Community Service Video", "Digital Photography", // "STEM" //"Leadership",// "Electrical", //"Construction" // "Forensic", //"CAD" //"I&I Team 1", "I&I Team 2"//, "Website Design", ], absentStudents: [ ] ); _teams = await Context.Teams .AsNoTracking() .Include(e => e.Event) .Include(e => e.Students) .OrderBy(e => e.Event.Name) .ThenBy(e => e.Identifier) .ToArrayAsync(); _students = await Context.Students .AsNoTracking() .Include(e => e.Teams) .ThenInclude(t => t.Event) .Include(e => e.Teams) .ThenInclude(t => t.Captain) .Include(e => e.EventRankings) .ThenInclude(e => e.EventDefinition) .OrderBy(e => e.FirstName).ToArrayAsync(); // Load saved selections from localStorage await LoadScheduledTeams(); await LoadAbsentStudents(); await LoadTimeSlotCount(); await LoadExtendedTeams(); await LoadExcludedStudents(); // Initialize last saved state from loaded values _lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students); if (_lastSavedState == null) { // If no saved state exists, create initial state from current values _lastSavedState = MeetingScheduleState.FromCurrent( _scheduledTeams, _absentStudents, _parameters.TimeSlots, _extendedTeams, _excludedStudents); } } private async Task SaveScheduledTeams() { var teamIds = _scheduledTeams.Select(t => t.Id).ToArray(); 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(); } } private async Task SaveAbsentStudents() { var studentIds = _absentStudents.Select(s => s.Id).ToArray(); await LocalStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", studentIds); } private async Task LoadAbsentStudents() { var studentIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents"); if (studentIds.Length > 0) { _absentStudents = _students.Where(s => studentIds.Contains(s.Id)).ToArray(); } } private async Task SaveTimeSlotCount() { await LocalStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", _parameters.TimeSlots); } private async Task LoadTimeSlotCount() { var timeSlots = await LocalStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2); if (timeSlots > 0) { _parameters.TimeSlots = timeSlots; } } private async Task SaveExtendedTeams() { var teamIds = _extendedTeams.Select(t => t.Id).ToArray(); await LocalStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", teamIds); } private async Task LoadExtendedTeams() { var teamIds = await LocalStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams"); if (teamIds.Length > 0) { _extendedTeams = _teams.Where(t => teamIds.Contains(t.Id)).ToArray(); } } private async Task SaveExcludedStudents() { var exclusions = _excludedStudents.Keys .Where(k => _excludedStudents[k]) .Select(k => new ExcludedStudent(k.teamId, k.timeSlotIndex, k.studentId)) .ToArray(); await LocalStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions); } private async Task LoadExcludedStudents() { var exclusions = await LocalStorage.GetJsonAsync("MeetingSchedule_ExcludedStudents"); if (exclusions != null && exclusions.Length > 0) { _excludedStudents = exclusions.ToDictionary( e => (e.TeamId, e.TimeSlotIndex, e.StudentId), _ => true); } } private int GetTimeSlotIndex(string timeSlotName) { if (_solution?.TimeSlots == null) return 0; // Default to first slot if solution not available for (int i = 0; i < _solution.TimeSlots.Length; i++) { if (_solution.TimeSlots[i].Name == timeSlotName) return i; } return 0; // Default to first slot if not found (shouldn't happen in normal flow) } private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key) { if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded) { _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; } /// /// Creates teams with excluded students filtered out for overlap calculation. /// private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex) { return teams.Select(team => { // Find excluded students for this team in this time slot // More efficient: iterate through team students and check exclusions var includedStudents = team.Students .Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, 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 students removed return new Team { Id = team.Id, Event = team.Event, Students = includedStudents, Captain = team.Captain, Identifier = team.Identifier }; }).ToArray(); } private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId); 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> SolveSchedule(TableState arg1, CancellationToken arg2) { _isSolving = true; // Check if there are any teams to schedule if (!_scheduledTeams.Any()) { _isSolving = false; _solution = new TeamSchedulerSolution([], [], "No teams selected"); return new TableData { Items = [] }; } // Filter out absent students var availableStudents = _students.Where(s => !_absentStudents.Contains(s)).ToArray(); // Check if there are any available students if (availableStudents.Length == 0) { _isSolving = false; _solution = new TeamSchedulerSolution([], [], "No available students"); return new TableData { Items = [] }; } // Update parameters with absent student names _parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray(); // 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 // Aggregate exclusions across all time slots (since we don't know assignment yet) var teamsForScheduling = _scheduledTeams.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 excludedStudents = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList(); if (excludedStudents.Count == 0) return team; // Create PartialTeam with excluded students return team.CloneWithOmittedStudents(excludedStudents); }).ToArray(); var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents); _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()) { ExtendTeamsInSolution(_solution, _extendedTeams, availableStudents); } // Try recommendation strategies in priority order var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots); UnassignedScheduleStrategy[] strategies = [ UnassignedScheduleStrategy.LevelOfEffort, UnassignedScheduleStrategy.BiggestGroup, UnassignedScheduleStrategy.AnyNotMeetingAlready, UnassignedScheduleStrategy.IndividualEvents ]; _possibleAdditions = strategies .Select(strategy => scheduler.ScheduleStrategy(strategy)) .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; await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found _isSolving = false; return new TableData { Items = _solution.TimeSlots }; } private void Solve() { _solutionData.ReloadServerData(); } // TODO: Move team extension logic into Core.Calculation.TeamScheduler to handle extended teams // as part of the constraint programming model rather than post-processing private void ExtendTeamsInSolution(TeamSchedulerSolution solution, IEnumerable extendedTeams, Student[] allStudents) { 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); // Use teams without excluded students so students excluded from all teams appear as unscheduled 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); // Use teams without excluded students so students excluded from all teams appear as unscheduled previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents); } } } async Task CopyToClipboard() { var sb = new StringBuilder(); foreach (var timeslot in _solution.TimeSlots) { AppendScheduledTeams(sb, timeslot); AppendUnscheduledStudents(sb, timeslot); sb.Append(Environment.NewLine); } try { await ClipboardService.WriteTextAsync(sb.ToString()); } catch { Console.WriteLine("Cannot write text to clipboard"); } } private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot) { var timeSlotIndex = GetTimeSlotIndex(timeslot.Name); foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString())) { var teamName = scheduledTeam.ToString(); if (scheduledTeam.Event.EventFormat is EventFormat.Individual) { sb.Append(teamName); } else { var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex); sb.Append($"{teamName} - {studentsList}"); } sb.Append(Environment.NewLine); } } private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot, int timeSlotIndex) { // Filter out excluded students for this team and time slot var excludedStudentIds = _excludedStudents.Keys .Where(k => k.teamId == team.Id && k.timeSlotIndex == timeSlotIndex && _excludedStudents[k]) .Select(k => k.studentId) .ToHashSet(); var includedStudents = team.Students .Where(s => !excludedStudentIds.Contains(s.Id)) .ToList(); // Create a temporary team with only included students for formatting var teamForFormatting = new Team { Id = team.Id, Event = team.Event, Students = includedStudents, Captain = team.Captain, Identifier = team.Identifier }; return TeamStudentNameFormatter.FormatStudentList( teamForFormatting, new TeamStudentNameFormatter.FormatOptions { Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst, MarkOverlaps = true, HasOverlaps = timeslot.StudentHasOverlaps, MarkAbsent = true, AbsentStudents = _absentStudents.ToList() }); } private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot) { // Find the team this student belongs to for formatting context var team = _teams.FirstOrDefault(t => t.Students.Contains(student)); if (team == null) { // No team context, use StudentNameFormatter directly return StudentNameFormatter.FormatStudentName( student, new StudentNameFormatter.FormatOptions { HasOverlap = timeslot.StudentHasOverlaps(student), IsAbsent = _absentStudents.Contains(student) }); } return TeamStudentNameFormatter.FormatStudentName( student, team, new TeamStudentNameFormatter.FormatOptions { MarkOverlaps = true, HasOverlaps = timeslot.StudentHasOverlaps, MarkAbsent = true, AbsentStudents = _absentStudents.ToList() }); } 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 = StudentNameFormatter.FormatStudentName( student, new StudentNameFormatter.FormatOptions { IsAbsent = _absentStudents.Contains(student) }); var unassignedTeams = _solution.StudentUnassignedTeams(student); var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString())); sb.Append($"{studentName} - {teamsList}"); sb.Append(Environment.NewLine); } } private class MeetingScheduleState : IEquatable { public HashSet ScheduledTeamIds { get; set; } = []; public HashSet AbsentStudentIds { get; set; } = []; public int TimeSlotCount { get; set; } public HashSet ExtendedTeamIds { get; set; } = []; public HashSet<(int teamId, int timeSlotIndex, int studentId)> ExcludedStudents { get; set; } = []; public bool Equals(MeetingScheduleState? other) { if (other == null) return false; return ScheduledTeamIds.SetEquals(other.ScheduledTeamIds) && AbsentStudentIds.SetEquals(other.AbsentStudentIds) && TimeSlotCount == other.TimeSlotCount && ExtendedTeamIds.SetEquals(other.ExtendedTeamIds) && ExcludedStudents.SetEquals(other.ExcludedStudents); } public override bool Equals(object? obj) => Equals(obj as MeetingScheduleState); public override int GetHashCode() { var hash = new HashCode(); hash.Add(ScheduledTeamIds.Count); foreach (var id in ScheduledTeamIds.OrderBy(x => x)) hash.Add(id); hash.Add(AbsentStudentIds.Count); foreach (var id in AbsentStudentIds.OrderBy(x => x)) hash.Add(id); hash.Add(TimeSlotCount); hash.Add(ExtendedTeamIds.Count); foreach (var id in ExtendedTeamIds.OrderBy(x => x)) hash.Add(id); hash.Add(ExcludedStudents.Count); foreach (var key in ExcludedStudents.OrderBy(x => x)) hash.Add(key); return hash.ToHashCode(); } public static MeetingScheduleState FromCurrent( IEnumerable scheduledTeams, IEnumerable absentStudents, int timeSlotCount, IEnumerable extendedTeams, Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents) { return new MeetingScheduleState { ScheduledTeamIds = scheduledTeams.Select(t => t.Id).ToHashSet(), AbsentStudentIds = absentStudents.Select(s => s.Id).ToHashSet(), TimeSlotCount = timeSlotCount, ExtendedTeamIds = extendedTeams.Select(t => t.Id).ToHashSet(), ExcludedStudents = excludedStudents.Keys .Where(k => excludedStudents[k]) .ToHashSet() }; } public static async Task FromLocalStorage( LocalStorageService localStorage, Team[] allTeams, Student[] allStudents) { // Load scheduled teams var scheduledTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ScheduledTeams"); var absentStudentIds = await localStorage.GetIntArrayAsync("MeetingSchedule_AbsentStudents"); var timeSlotCount = await localStorage.GetIntAsync("MeetingSchedule_TimeSlotCount", defaultValue: 2); var extendedTeamIds = await localStorage.GetIntArrayAsync("MeetingSchedule_ExtendedTeams"); var exclusions = await localStorage.GetJsonAsync("MeetingSchedule_ExcludedStudents"); // If no state exists, return null if (scheduledTeamIds.Length == 0 && absentStudentIds.Length == 0 && timeSlotCount == 2 && extendedTeamIds.Length == 0 && (exclusions == null || exclusions.Length == 0)) { return null; } var excludedStudentsSet = new HashSet<(int teamId, int timeSlotIndex, int studentId)>(); if (exclusions != null && exclusions.Length > 0) { foreach (var exclusion in exclusions) { excludedStudentsSet.Add((exclusion.TeamId, exclusion.TimeSlotIndex, exclusion.StudentId)); } } return new MeetingScheduleState { ScheduledTeamIds = scheduledTeamIds.ToHashSet(), AbsentStudentIds = absentStudentIds.ToHashSet(), TimeSlotCount = timeSlotCount > 0 ? timeSlotCount : 2, ExtendedTeamIds = extendedTeamIds.ToHashSet(), ExcludedStudents = excludedStudentsSet }; } public async Task SaveToLocalStorage(LocalStorageService localStorage) { await localStorage.SetIntArrayAsync("MeetingSchedule_ScheduledTeams", ScheduledTeamIds.ToArray()); await localStorage.SetIntArrayAsync("MeetingSchedule_AbsentStudents", AbsentStudentIds.ToArray()); await localStorage.SetIntAsync("MeetingSchedule_TimeSlotCount", TimeSlotCount); await localStorage.SetIntArrayAsync("MeetingSchedule_ExtendedTeams", ExtendedTeamIds.ToArray()); var exclusions = ExcludedStudents.Select(e => new ExcludedStudent(e.teamId, e.timeSlotIndex, e.studentId)).ToArray(); await localStorage.SetJsonAsync("MeetingSchedule_ExcludedStudents", exclusions); } } }