Refactor the Meeting Scheduler

This commit is contained in:
2025-11-30 23:03:52 -05:00
parent 46843fea0f
commit 382fffe1d4
4 changed files with 271 additions and 140 deletions
@@ -25,23 +25,23 @@
<MudFlexBreak/> <MudFlexBreak/>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false"> <MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
<MudButton Variant="Variant.Outlined" OnClick="() => AddHighLevelOfEffort()" FullWidth="true">Add High Effort</MudButton> <MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
</MudTooltip> </MudTooltip>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" OnClick="() => AddRegionals()" FullWidth="true">Add Regionals</MudButton> <MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" OnClick="() => RemoveIndividual()" FullWidth="true">Remove Individual</MudButton> <MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" OnClick="() => RemoveLowLevelOfEffort()" FullWidth="true">Remove Low Effort</MudButton> <MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" OnClick="() => Invert()" FullWidth="true">Invert</MudButton> <MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" lg="4"> <MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="() => Reset()" FullWidth="true">Reset</MudButton> <MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
</MudItem> </MudItem>
<MudItem xs="12"> <MudItem xs="12">
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton> <MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
@@ -58,74 +58,22 @@
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd> <MudTd>
<MudGrid Class=""> <MudGrid>
<MudItem xs="12" lg="6"> <MudItem xs="12" lg="6">
<MudStack> <ScheduledTeamsList TimeSlotName="@context.Name"
<MudText Typo="Typo.h6">@context.Name</MudText> Teams="@context.Teams"
@foreach (var team in context.Teams.OrderBy(e => e.ToString())) ScheduledTeams="@_scheduledTeams"
{ AbsentStudents="@_absentStudents"
var removed = !_scheduledTeams.Contains(team); StudentHasOverlaps="@context.StudentHasOverlaps"
OnToggleTeam="@ToggleRequiredTeam" />
<MudLink Typo="Typo.body1"
Class="d-flex align-center"
Color="Color.Default"
OnClick="@(() => ToggleRequiredTeam(team))">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")">
</MudIcon>
@team -
@{var first = true;}
@foreach (var student in team.Students)
{
var overlap = context.StudentHasOverlaps(student);
var isAbsent = _absentStudents.Contains(student);
var color = overlap ? Color.Warning : Color.Default;
@if(!first) {<MudText>, </MudText>}
{first = false;}
<MudText
Typo="Typo.body2"
Color="@color">
&nbsp;@student.FirstName@(overlap ? "*" : "")@(isAbsent ? " (absent)" : "")
</MudText>
}
</MudLink>
}
</MudStack>
</MudItem> </MudItem>
<MudItem xs="12" lg="6"> <MudItem xs="12" lg="6">
@if (context.UnscheduledStudents.Any()) <UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
{ AbsentStudents="@_absentStudents"
<MudText Typo="Typo.body1" HtmlTag="strong">Unscheduled</MudText> ScheduledTeams="@_scheduledTeams"
<MudStack> PossibleAdditions="@_possibleAdditions"
@foreach (var student in context.UnscheduledStudents) UnassignedTeams="@_solution.StudentUnassignedTeams"
{ OnToggleTeam="@ToggleRequiredTeam" />
var isAbsent = _absentStudents.Contains(student);
<MudItem>
<MudText Typo="Typo.body1" HtmlTag="i">@student.FirstName@(isAbsent ? " (absent)" : "")&nbsp;</MudText>
@{var first = true;}
@foreach (var unassignedTeam in _solution.StudentUnassignedTeams(student))
{
var color = _possibleAdditions.Contains(unassignedTeam, new TeamIdComparer()) ? Color.Success : Color.Default;
var added = _scheduledTeams.Contains(unassignedTeam);
@if(!first) {<span>, </span>}
{first = false;}
<MudLink Typo="Typo.body2"
Color="@color"
OnClick="@(() => ToggleRequiredTeam(unassignedTeam))">
<MudIcon Icon="@Icons.Material.Filled.Check"
Size="Size.Small"
Class="@(added ? "" : "d-none")">
</MudIcon>
@unassignedTeam
</MudLink>
}
</MudItem>
}
</MudStack>
}
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudTd> </MudTd>
@@ -140,24 +88,10 @@
Label="Search for absent students" Label="Search for absent students"
ShowFullName="true"/> ShowFullName="true"/>
<MudDivider Class="my-4"/> <MudDivider Class="my-4"/>
<MudText Typo="Typo.h4">Scheduled Teams</MudText> <TeamToggleSelector Teams="@_teams"
<MudToggleGroup T="Team" @bind-SelectedTeams="_scheduledTeams"
SelectionMode="SelectionMode.MultiSelection" Title="Scheduled Teams"
@bind-Values="_scheduledTeams" ShowEventAttributes="true" />
Vertical="true"
CheckMark>
@foreach (var team in _teams.OrderBy(e => e.Event.Name))
{
<MudToggleItem Value="@team" Style="font-size: .75rem;">
<MudTooltip Text="@team.StudentsFirstNames">
<div class="d-flex align-center justify-space-between flex-wrap">
<MudText Class="ellipsis">@team.ToString()</MudText>
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
</div>
</MudTooltip>
</MudToggleItem>
}
</MudToggleGroup>
</MudStack> </MudStack>
</MudItem> </MudItem>
@@ -175,38 +109,38 @@
private IEnumerable<Student> _absentStudents = []; private IEnumerable<Student> _absentStudents = [];
private IEnumerable<Team> _possibleAdditions = []; private IEnumerable<Team> _possibleAdditions = [];
private async Task AddRegionals() private void AddRegionals()
{ {
_scheduledTeams _scheduledTeams
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct(); = _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
} }
private async Task AddHighLevelOfEffort() private void AddHighLevelOfEffort()
{ {
_scheduledTeams _scheduledTeams
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct(); = _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
} }
private async Task RemoveIndividual() private void RemoveIndividual()
{ {
_scheduledTeams _scheduledTeams
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual); = _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
} }
private async Task RemoveLowLevelOfEffort() private void RemoveLowLevelOfEffort()
{ {
_scheduledTeams _scheduledTeams
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1); = _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
} }
private async Task Invert() private void Invert()
{ {
var rt = _scheduledTeams.ToArray(); var rt = _scheduledTeams.ToArray();
_scheduledTeams _scheduledTeams
= _teams.Where(t => !rt.Contains(t)); = _teams.Where(t => !rt.Contains(t));
} }
private async Task Reset() private void Reset()
{ {
_scheduledTeams = []; _scheduledTeams = [];
} }
@@ -282,18 +216,19 @@
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents); var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
_solution = teamScheduler.Solve(); _solution = teamScheduler.Solve();
var loe = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.LevelOfEffort); // Try recommendation strategies in priority order
var biggest = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.BiggestGroup); var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
var individual = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.IndividualEvents); var strategies = new[]
var anyNotMeetingAlready = new UnassignedStudentScheduler(_teams, _solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.AnyNotMeetingAlready); {
UnassignedScheduleStrategy.LevelOfEffort,
UnassignedScheduleStrategy.BiggestGroup,
UnassignedScheduleStrategy.AnyNotMeetingAlready,
UnassignedScheduleStrategy.IndividualEvents
};
_possibleAdditions = loe; _possibleAdditions = strategies
if (!_possibleAdditions.Any()) .Select(strategy => scheduler.ScheduleStrategy(strategy))
_possibleAdditions = biggest; .FirstOrDefault(result => result.Any()) ?? [];
if (!_possibleAdditions.Any())
_possibleAdditions = anyNotMeetingAlready;
if (!_possibleAdditions.Any())
_possibleAdditions = individual;
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
@@ -311,40 +246,8 @@
var sb = new StringBuilder(); var sb = new StringBuilder();
foreach (var timeslot in _solution.TimeSlots) foreach (var timeslot in _solution.TimeSlots)
{ {
//var overlaps AppendScheduledTeams(sb, timeslot);
// = TeamSchedulerSolution.GetStudentTeamOverlaps(timeslot).Select(e => e.Item1).ToArray(); AppendUnscheduledStudents(sb, timeslot);
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
{
var t = scheduledTeam.ToString();
var s =
string.Join(", ",
scheduledTeam.Students
.OrderBy(e => e == scheduledTeam.Captain)
.ThenBy(e => e.FirstName)
.Select(e => e.FirstName + (timeslot.StudentHasOverlaps(e) ? "*" : "") + (_absentStudents.Contains(e) ? " (absent)" : "")));
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
sb.Append(t);
else
sb.Append($"{t} - {s}");
sb.Append(Environment.NewLine);
}
if (timeslot.UnscheduledStudents.Any())
{
sb.Append("--Unscheduled");
sb.Append(Environment.NewLine);
foreach (var student in timeslot.UnscheduledStudents)
{
var s = student.FirstName + (_absentStudents.Contains(student) ? " (absent)" : "");
var unassignedTeams = _solution.StudentUnassignedTeams(student);
var t = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
sb.Append($"{s} - {t}");
sb.Append(Environment.NewLine);
}
}
sb.Append(Environment.NewLine); sb.Append(Environment.NewLine);
} }
@@ -358,4 +261,64 @@
} }
} }
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 string.Join(", ",
team.Students
.OrderBy(e => e == team.Captain)
.ThenBy(e => e.FirstName)
.Select(e => FormatStudentName(e, timeslot)));
}
private string FormatStudentName(Student student, TeamScheduleTimeSlot timeslot)
{
var name = student.FirstName;
if (timeslot.StudentHasOverlaps(student))
name += "*";
if (_absentStudents.Contains(student))
name += " (absent)";
return name;
}
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 = student.FirstName;
if (_absentStudents.Contains(student))
studentName += " (absent)";
var unassignedTeams = _solution.StudentUnassignedTeams(student);
var teamsList = string.Join(", ", unassignedTeams.Select(e => e.ToString()));
sb.Append($"{studentName} - {teamsList}");
sb.Append(Environment.NewLine);
}
}
} }
@@ -0,0 +1,62 @@
@using Core.Calculation
<MudStack>
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
@foreach (var team in Teams.OrderBy(e => e.ToString()))
{
var removed = !ScheduledTeams.Contains(team);
<MudLink Typo="Typo.body1"
Class="d-flex align-center"
Color="Color.Default"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")">
</MudIcon>
@team -
@foreach (var student in team.Students)
{
var overlap = StudentHasOverlaps(student);
var isAbsent = AbsentStudents.Contains(student);
var color = overlap ? Color.Warning : Color.Default;
var suffix = GetStudentSuffix(overlap, isAbsent);
if (student != team.Students.First())
{
<MudText>, </MudText>
}
<MudText Typo="Typo.body2" Color="@color">
&nbsp;@student.FirstName@suffix
</MudText>
}
</MudLink>
}
</MudStack>
@code {
[Parameter]
public string TimeSlotName { get; set; } = string.Empty;
[Parameter]
public IEnumerable<Team> Teams { get; set; } = [];
[Parameter]
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
[Parameter]
public IEnumerable<Student> AbsentStudents { get; set; } = [];
[Parameter]
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
[Parameter]
public EventCallback<Team> OnToggleTeam { get; set; }
private string GetStudentSuffix(bool overlap, bool isAbsent)
{
var suffix = overlap ? "*" : "";
suffix += isAbsent ? " (absent)" : "";
return suffix;
}
}
@@ -0,0 +1,57 @@
@using Core.Calculation
@if (UnscheduledStudents.Any())
{
<MudText Typo="Typo.body1" HtmlTag="strong">Unscheduled</MudText>
<MudStack>
@foreach (var student in UnscheduledStudents)
{
var isAbsent = AbsentStudents.Contains(student);
<MudItem>
<MudText Typo="Typo.body1" HtmlTag="i">
@student.FirstName@(isAbsent ? " (absent)" : "")&nbsp;
</MudText>
@foreach (var unassignedTeam in UnassignedTeams(student))
{
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
var color = isPossibleAddition ? Color.Success : Color.Default;
if (unassignedTeam != UnassignedTeams(student).First())
{
<span>, </span>
}
<MudLink Typo="Typo.body2"
Color="@color"
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
<MudIcon Icon="@Icons.Material.Filled.Check"
Size="Size.Small"
Class="@(isScheduled ? "" : "d-none")">
</MudIcon>
@unassignedTeam
</MudLink>
}
</MudItem>
}
</MudStack>
}
@code {
[Parameter]
public IEnumerable<Student> UnscheduledStudents { get; set; } = [];
[Parameter]
public IEnumerable<Student> AbsentStudents { get; set; } = [];
[Parameter]
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
[Parameter]
public IEnumerable<Team> PossibleAdditions { get; set; } = [];
[Parameter]
public Func<Student, IEnumerable<Team>> UnassignedTeams { get; set; } = null!;
[Parameter]
public EventCallback<Team> OnToggleTeam { get; set; }
}
@@ -0,0 +1,49 @@
@if (Title != null)
{
<MudText Typo="Typo.h4">@Title</MudText>
}
<MudToggleGroup T="Team"
SelectionMode="SelectionMode.MultiSelection"
Values="@SelectedTeams"
ValuesChanged="@OnSelectedTeamsChanged"
Vertical="true"
CheckMark>
@foreach (var team in Teams.OrderBy(e => e.Event.Name))
{
<MudToggleItem Value="@team" Style="font-size: .75rem;">
<MudTooltip Text="@team.StudentsFirstNames">
<div class="d-flex align-center justify-space-between flex-wrap">
<MudText Class="ellipsis">@team.ToString()</MudText>
@if (ShowEventAttributes)
{
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
}
</div>
</MudTooltip>
</MudToggleItem>
}
</MudToggleGroup>
@code {
[Parameter]
public IEnumerable<Team> Teams { get; set; } = [];
[Parameter]
public IEnumerable<Team> SelectedTeams { get; set; } = [];
[Parameter]
public EventCallback<IEnumerable<Team>> SelectedTeamsChanged { get; set; }
[Parameter]
public string? Title { get; set; }
[Parameter]
public bool ShowEventAttributes { get; set; } = true;
private async Task OnSelectedTeamsChanged(IEnumerable<Team> value)
{
SelectedTeams = value;
await SelectedTeamsChanged.InvokeAsync(value);
}
}