@page "/meeting-schedule" @attribute [Authorize] @using System.Text @using Core.Calculation @using Core.Models @using Core.Utility @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components @using WebApp.Components.Features.MeetingSchedule @using WebApp.Models @using WebApp.Services @inject IConfiguration Configuration @inject ClipboardService ClipboardService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject IMeetingScheduleStateService StateService @inject IMeetingScheduleDataService DataService @inject IMeetingScheduleClipboardService ClipboardFormatService @inject LocalStorageService LocalStorage View History Save as History Time Slots Time Slots @_parameters.TimeSlots Invert Load from Clipboard 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 bool _isLoadingClipboard = false; 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 async Task IncrementTimeSlots() { if (_parameters.TimeSlots < 4) { await OnTimeSlotCountChanged(_parameters.TimeSlots + 1); } } private async Task DecrementTimeSlots() { if (_parameters.TimeSlots > 1) { await OnTimeSlotCountChanged(_parameters.TimeSlots - 1); } } private void OnExtendedTeamsChanged(IEnumerable teams) { _extendedTeams = teams; StateHasChanged(); } private void AddRegionals() { _scheduledTeams = _scheduledTeams.AddRegionals(_teams); StateHasChanged(); } 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 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 = []; _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 DataService.LoadTeamsAsync(); _students = await DataService.LoadStudentsAsync(); // Load saved selections from localStorage _scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams); _absentStudents = await StateService.LoadAbsentStudentsAsync(_students); _parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2); _extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams); _excludedStudents = await StateService.LoadExcludedStudentsAsync(); // 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 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; } 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> 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 var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents); 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()) { TeamSchedulerPostProcessor.ExtendTeamsInSolution( _solution, _extendedTeams, availableStudents, GetTeamsWithoutExcludedStudents); } // 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; // 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 _isSolving = false; return new TableData { Items = _solution.TimeSlots }; } private void Solve() { _solutionData.ReloadServerData(); } async Task CopyToClipboard() { try { var text = ClipboardFormatService.FormatScheduleForClipboard( _solution, _teams, _absentStudents, _excludedStudents, GetTimeSlotIndex); await ClipboardService.WriteTextAsync(text); } catch { Console.WriteLine("Cannot write text to clipboard"); } } private async Task OpenSaveHistoryDialog() { var parameters = new DialogParameters { ["ScheduledTeams"] = _scheduledTeams, ["AbsentStudents"] = _absentStudents, ["AllTeams"] = _teams, ["AllStudents"] = _students }; var options = new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true, CloseButton = true }; var dialog = await DialogService.ShowAsync("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)) { 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 { Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success); } } catch (JSException ex) { Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error); } catch (Exception ex) { Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error); } finally { _isLoadingClipboard = false; StateHasChanged(); } } }