8c4c21f204
This commit updates the Index.razor component of the MeetingSchedule feature to improve the calculation of student overlaps and unscheduled students. The logic now filters out excluded students when determining overlaps and ensures that students excluded from all teams are correctly identified as unscheduled. These changes enhance the accuracy of the scheduling process and improve the overall user experience.
729 lines
29 KiB
Plaintext
729 lines
29 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"]}")" />
|
|
|
|
<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="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();
|
|
|
|
private async Task OnScheduledTeamsChanged(IEnumerable<Team> teams)
|
|
{
|
|
_scheduledTeams = teams;
|
|
await SaveScheduledTeams();
|
|
}
|
|
|
|
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
|
|
{
|
|
_absentStudents = students;
|
|
await SaveAbsentStudents();
|
|
}
|
|
|
|
private async Task OnTimeSlotCountChanged(int timeSlots)
|
|
{
|
|
_parameters.TimeSlots = timeSlots;
|
|
await SaveTimeSlotCount();
|
|
}
|
|
|
|
private async Task OnExtendedTeamsChanged(IEnumerable<Team> 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 = [];
|
|
_extendedTeams = [];
|
|
_excludedStudents.Clear();
|
|
await SaveScheduledTeams();
|
|
await SaveExtendedTeams();
|
|
await SaveExcludedStudents();
|
|
}
|
|
|
|
private async Task ToggleRequiredTeam(Team unassignedTeam)
|
|
{
|
|
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
|
|
IEnumerable<Team> newScheduledTeams;
|
|
|
|
if (scheduledTeamIds.Contains(unassignedTeam.Id))
|
|
newScheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id);
|
|
else
|
|
{
|
|
newScheduledTeams = _scheduledTeams.Concat([unassignedTeam]);
|
|
}
|
|
|
|
// Update state and notify component to re-render
|
|
await 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();
|
|
}
|
|
|
|
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 async Task OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
|
|
{
|
|
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
|
|
{
|
|
_excludedStudents.Remove(key);
|
|
}
|
|
else
|
|
{
|
|
_excludedStudents[key] = true;
|
|
}
|
|
await SaveExcludedStudents();
|
|
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 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()) ?? [];
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
} |