@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 = []; private async Task OnScheduledTeamsChanged(IEnumerable teams) { _scheduledTeams = teams; await SaveScheduledTeams(); } private async Task OnAbsentStudentsChanged(IEnumerable students) { _absentStudents = students; await SaveAbsentStudents(); } private async Task OnTimeSlotCountChanged(int timeSlots) { _parameters.TimeSlots = timeSlots; await SaveTimeSlotCount(); } private async Task OnExtendedTeamsChanged(IEnumerable teams) { _extendedTeams = teams; await SaveExtendedTeams(); } private async void AddRegionals() { _scheduledTeams = _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct(); await SaveScheduledTeams(); } private async void AddHighLevelOfEffort() { _scheduledTeams = _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct(); await SaveScheduledTeams(); } private async void RemoveIndividual() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual); await SaveScheduledTeams(); } private async void RemoveLowLevelOfEffort() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1); await SaveScheduledTeams(); } private async void Invert() { var rt = _scheduledTeams.ToArray(); _scheduledTeams = _teams.Where(t => !rt.Contains(t)); await SaveScheduledTeams(); } private async void Reset() { _scheduledTeams = []; await SaveScheduledTeams(); } private async void ToggleRequiredTeam(Team unassignedTeam) { var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet(); if (scheduledTeamIds.Contains(unassignedTeam.Id)) _scheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id); else { _scheduledTeams = _scheduledTeams.Concat([unassignedTeam]); } await SaveScheduledTeams(); } 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(); } 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> 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(); var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents); _solution = teamScheduler.Solve(); // 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()) ?? []; 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(); nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlot.Teams); nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlot.Teams, 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(); previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlot.Teams); previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlot.Teams, 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) { 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); sb.Append($"{teamName} - {studentsList}"); } sb.Append(Environment.NewLine); } } private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot) { return TeamStudentNameFormatter.FormatStudentList( team, 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); } } }