890 lines
35 KiB
Plaintext
890 lines
35 KiB
Plaintext
@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
|
|
|
|
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")">
|
|
<ActionButtons>
|
|
<PageNoteButton PageIdentifier="Meeting Schedule" />
|
|
</ActionButtons>
|
|
</PageHeader>
|
|
|
|
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
|
|
<MudGrid>
|
|
<MudItem xs="12" sm="8" lg="9">
|
|
<MudText Typo="Typo.h4">Time Slots</MudText>
|
|
<MudPaper Class="pa-2 ma-2" Elevation="3">
|
|
<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">
|
|
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
|
|
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
|
|
</MudTooltip>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="6" lg="4">
|
|
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
|
|
</MudItem>
|
|
<MudItem xs="12">
|
|
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
|
Class="ma-3"
|
|
OnClick="Solve"
|
|
Color="Color.Primary"
|
|
Disabled="@_isSolving">
|
|
Solve
|
|
</MudButton>
|
|
<MudTooltip Text="Copy to Clipboard">
|
|
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
|
</MudTooltip>
|
|
</MudItem>
|
|
|
|
</MudGrid>
|
|
</MudPaper>
|
|
|
|
<MudTable T="TeamScheduleTimeSlot" ServerData="SolveSchedule" @ref="_solutionData">
|
|
<HeaderContent>
|
|
</HeaderContent>
|
|
<RowTemplate>
|
|
<MudTd>
|
|
<MudGrid>
|
|
<MudItem xs="12" lg="6">
|
|
<ScheduledTeamsList TimeSlotName="@context.Name"
|
|
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
|
|
Teams="@context.Teams"
|
|
ScheduledTeams="@_scheduledTeams"
|
|
AbsentStudents="@_absentStudents"
|
|
StudentHasOverlaps="@context.StudentHasOverlaps"
|
|
ExcludedStudents="@_excludedStudents"
|
|
OnToggleTeam="@ToggleRequiredTeam"
|
|
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
|
|
</MudItem>
|
|
<MudItem xs="12" lg="6">
|
|
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
|
|
AbsentStudents="@_absentStudents"
|
|
ScheduledTeams="@_scheduledTeams"
|
|
PossibleAdditions="@_possibleAdditions"
|
|
UnassignedTeams="@_solution.StudentUnassignedTeams"
|
|
OnToggleTeam="@ToggleRequiredTeam" />
|
|
</MudItem>
|
|
</MudGrid>
|
|
</MudTd>
|
|
</RowTemplate>
|
|
</MudTable>
|
|
</MudItem>
|
|
<MudItem xs="12" sm="4" lg="3">
|
|
<MudStack>
|
|
<StudentTextBoxSelector Students="@_students"
|
|
SelectedStudents="_absentStudents"
|
|
SelectedStudentsChanged="OnAbsentStudentsChanged"
|
|
Title="Absent Students"
|
|
Label="Search for absent students"
|
|
ShowFullName="true"/>
|
|
<MudDivider Class="my-4"/>
|
|
<TeamMeetingToggleSelector Teams="@_teams"
|
|
SelectedTeams="_scheduledTeams"
|
|
SelectedTeamsChanged="OnScheduledTeamsChanged"
|
|
ExtendedTeams="_extendedTeams"
|
|
ExtendedTeamsChanged="OnExtendedTeamsChanged"
|
|
Title="Scheduled Teams"
|
|
ShowEventAttributes="false" />
|
|
</MudStack>
|
|
</MudItem>
|
|
|
|
</MudGrid>
|
|
</MudPaper>
|
|
|
|
@code {
|
|
private Team[] _teams = null!;
|
|
private Student[] _students = null!;
|
|
MudTable<TeamScheduleTimeSlot> _solutionData = null!;
|
|
private TeamSchedulerSolution _solution = null!;
|
|
private TeamSchedulerOptions _parameters = null!;
|
|
bool _isSolving;
|
|
private IEnumerable<Team> _scheduledTeams = [];
|
|
private IEnumerable<Student> _absentStudents = [];
|
|
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 void OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
|
{
|
|
_scheduledTeams = teams;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
|
|
{
|
|
_absentStudents = students;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task OnTimeSlotCountChanged(int timeSlots)
|
|
{
|
|
_parameters.TimeSlots = timeSlots;
|
|
StateHasChanged();
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private void OnExtendedTeamsChanged(IEnumerable<Team> 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<Team> 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<ExcludedStudent[]>("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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates teams with excluded students filtered out for overlap calculation.
|
|
/// </summary>
|
|
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<TableData<TeamScheduleTimeSlot>> 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<TeamScheduleTimeSlot> { 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<TeamScheduleTimeSlot> { 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<TeamScheduleTimeSlot> { 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<Team> 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<MeetingScheduleState>
|
|
{
|
|
public HashSet<int> ScheduledTeamIds { get; set; } = [];
|
|
public HashSet<int> AbsentStudentIds { get; set; } = [];
|
|
public int TimeSlotCount { get; set; }
|
|
public HashSet<int> 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<Team> scheduledTeams,
|
|
IEnumerable<Student> absentStudents,
|
|
int timeSlotCount,
|
|
IEnumerable<Team> 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<MeetingScheduleState?> 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<ExcludedStudent[]>("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);
|
|
}
|
|
}
|
|
|
|
} |