diff --git a/Core/Calculation/TeamScheduler.cs b/Core/Calculation/TeamScheduler.cs index 2a29fe5..d700359 100644 --- a/Core/Calculation/TeamScheduler.cs +++ b/Core/Calculation/TeamScheduler.cs @@ -82,10 +82,11 @@ public class TeamScheduler var model = new CpModel(); // Build membership matrix: m[i,t] = 1 if student i is on team t, else 0 + // Use team.Students to support PartialTeam objects with omitted students var m = new int[_students.Length,_teams.Length]; foreach (var i in _students) foreach (var t in _teams) - m[i, t] = _studentObjects[i].Teams.Any(team => team.Id == _teamObjects[t].Id) ? 1 : 0; + m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0; // Decision variables: // x[t,s] = 1 if meeting of team t takes place at time slot s, else 0 diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index 42ba979..dcf91b9 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -63,11 +63,14 @@ + ExcludedStudents="@_excludedStudents" + OnToggleTeam="@ToggleRequiredTeam" + OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" /> _absentStudents = []; private IEnumerable _possibleAdditions = []; private IEnumerable _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 teams) { @@ -180,20 +185,26 @@ { _scheduledTeams = []; _extendedTeams = []; + _excludedStudents.Clear(); await SaveScheduledTeams(); await SaveExtendedTeams(); + await SaveExcludedStudents(); } - private async void ToggleRequiredTeam(Team unassignedTeam) + private async Task ToggleRequiredTeam(Team unassignedTeam) { var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet(); + IEnumerable newScheduledTeams; + if (scheduledTeamIds.Contains(unassignedTeam.Id)) - _scheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id); + newScheduledTeams = _scheduledTeams.Where(t => t.Id != unassignedTeam.Id); else { - _scheduledTeams = _scheduledTeams.Concat([unassignedTeam]); + newScheduledTeams = _scheduledTeams.Concat([unassignedTeam]); } - await SaveScheduledTeams(); + + // Update state and notify component to re-render + await OnScheduledTeamsChanged(newScheduledTeams); } protected override async Task OnInitializedAsync() @@ -252,6 +263,7 @@ await LoadAbsentStudents(); await LoadTimeSlotCount(); await LoadExtendedTeams(); + await LoadExcludedStudents(); } private async Task SaveScheduledTeams() @@ -313,6 +325,90 @@ } } + 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("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; + } + + /// + /// Creates teams with excluded students filtered out for overlap calculation. + /// + 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> SolveSchedule(TableState arg1, CancellationToken arg2) { _isSolving = true; @@ -345,9 +441,60 @@ .Select(t => t.Event!.Name) .ToArray(); - var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents); + // 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 + var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex); + slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation); + slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(slot.Teams, availableStudents); + } + // Post-process: extend teams to next consecutive time slot if (_extendedTeams.Any()) { @@ -439,7 +586,9 @@ } previousSlot.Teams = previousSlotTeamsList.ToArray(); - previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlot.Teams); + var previousSlotIndex = slotIndex - 1; + var previousSlotTeamsForOverlap = GetTeamsWithoutExcludedStudents(previousSlot.Teams, previousSlotIndex); + previousSlot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(previousSlotTeamsForOverlap); previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlot.Teams, allStudents); } } @@ -467,6 +616,7 @@ 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(); @@ -477,17 +627,37 @@ } else { - var studentsList = FormatStudentList(scheduledTeam, timeslot); + var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex); sb.Append($"{teamName} - {studentsList}"); } sb.Append(Environment.NewLine); } } - private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot) + 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( - team, + teamForFormatting, new TeamStudentNameFormatter.FormatOptions { Ordering = TeamStudentNameFormatter.OrderingStyle.CaptainFirst, diff --git a/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor b/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor index 70045ce..b46cd17 100644 --- a/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor +++ b/WebApp/Components/Features/MeetingSchedule/ScheduledTeamsList.razor @@ -36,10 +36,14 @@ + @{ + var nonExcludedStudentCount = GetNonExcludedStudentCount(team); + } @foreach (var student in team.Students) { var overlap = StudentHasOverlaps(student); var chipColor = overlap ? Color.Warning : Color.Default; + var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id); var formattedName = TeamStudentNameFormatter.FormatStudentName( student, team, @@ -51,9 +55,29 @@ AbsentStudents = AbsentStudents.ToList() }); - - @formattedName - + + + + @formattedName + @if (_hoveredStudent == (team.Id, student.Id) && nonExcludedStudentCount > 1) + { + + } + + + } @@ -64,6 +88,9 @@ [Parameter] public string TimeSlotName { get; set; } = string.Empty; + [Parameter] + public int TimeSlotIndex { get; set; } + [Parameter] public IEnumerable Teams { get; set; } = []; @@ -76,6 +103,25 @@ [Parameter] public Func StudentHasOverlaps { get; set; } = null!; + [Parameter] + public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new(); + [Parameter] public EventCallback OnToggleTeam { get; set; } + + [Parameter] + public EventCallback<(int teamId, int timeSlotIndex, int studentId)> OnToggleStudentExclusion { get; set; } + + private (int? teamId, int? studentId) _hoveredStudent = (null, null); + + private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId) + { + var key = (teamId, timeSlotIndex, studentId); + return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key]; + } + + private int GetNonExcludedStudentCount(Team team) + { + return team.Students.Count(s => !IsStudentExcluded(team.Id, TimeSlotIndex, s.Id)); + } } diff --git a/WebApp/LocalStorageService.cs b/WebApp/LocalStorageService.cs index cc0d4fe..e049ce0 100644 --- a/WebApp/LocalStorageService.cs +++ b/WebApp/LocalStorageService.cs @@ -147,6 +147,49 @@ public sealed class LocalStorageService } } + /// + /// Gets a JSON-serialized object from localStorage. + /// + /// The type to deserialize to. + /// The storage key. + /// The deserialized object or default value if not found. + public async Task GetJsonAsync(string key) + { + try + { + var json = await _jsRuntime.InvokeAsync("localStorage.getItem", key); + if (!string.IsNullOrEmpty(json)) + { + return JsonSerializer.Deserialize(json); + } + return default; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load JSON from localStorage [{Key}]", key); + return default; + } + } + + /// + /// Sets a JSON-serialized object in localStorage. + /// + /// The type to serialize. + /// The storage key. + /// The object to store. + public async Task SetJsonAsync(string key, T value) + { + try + { + var json = JsonSerializer.Serialize(value); + await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save JSON to localStorage [{Key}]", key); + } + } + /// /// Clears all items from localStorage. ///