Files
chapter-organizer/WebApp/Components/Features/MeetingSchedule/Index.razor
T
poprhythm 1601610226 Implement TeamMeetingToggleSelector component and extend team management functionality
This commit introduces the TeamMeetingToggleSelector component, which allows for the selection and management of teams within the meeting schedule. The Index.razor component has been updated to utilize this new selector, enhancing the user interface for managing scheduled and extended teams. Additionally, new methods for saving and loading extended teams have been added, improving the overall functionality and user experience in team scheduling. These changes contribute to better organization and management of team events in the application.
2026-01-13 09:32:45 -05:00

537 lines
20 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"
Teams="@context.Teams"
ScheduledTeams="@_scheduledTeams"
AbsentStudents="@_absentStudents"
StudentHasOverlaps="@context.StudentHasOverlaps"
OnToggleTeam="@ToggleRequiredTeam" />
</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 = [];
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 = [];
await SaveScheduledTeams();
}
private async void ToggleRequiredTeam(Team unassignedTeam)
{
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
if (scheduledTeamIds.Contains(unassignedTeam.Id))
_scheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id);
else
{
_scheduledTeams = _scheduledTeams.Concat([unassignedTeam]);
}
await SaveScheduledTeams();
}
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();
}
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<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();
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
_solution = teamScheduler.Solve();
// 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();
}
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
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;
// Check if there's a next time slot
if (slotIndex + 1 >= solution.TimeSlots.Length)
{
// Cannot extend - this is the last time slot
continue;
}
var nextSlot = solution.TimeSlots[slotIndex + 1];
// Add extended teams to the next time slot
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);
}
}
// Update the next slot with the extended teams
nextSlot.Teams = nextSlotTeamsList.ToArray();
// Recalculate overlaps and unscheduled students for the next slot
nextSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(nextSlot.Teams);
nextSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(nextSlot.Teams, 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)
{
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 TeamStudentNameFormatter.FormatStudentList(
team,
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);
}
}
}