@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();
}
}
}