Files
chapter-organizer/WebApp/Components/Features/MeetingSchedule/Index.razor
T

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