diff --git a/Core/Calculation/OverlapCalculationHelper.cs b/Core/Calculation/OverlapCalculationHelper.cs new file mode 100644 index 0000000..0d27c8a --- /dev/null +++ b/Core/Calculation/OverlapCalculationHelper.cs @@ -0,0 +1,58 @@ +using Core.Entities; + +namespace Core.Calculation; + +/// +/// Helper methods for calculating overlaps in team scheduling solutions. +/// +public static class OverlapCalculationHelper +{ + /// + /// Creates teams with excluded students and absent students filtered out for overlap calculation. + /// + /// The teams to filter + /// The time slot index for exclusion lookups + /// Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded + /// Set of absent student IDs to exclude from overlap calculations + /// Teams with excluded and absent students removed + public static Team[] GetTeamsWithoutExcludedStudents( + Team[] teams, + int timeSlotIndex, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents, + HashSet absentStudentIds) + { + return teams.Select(team => + { + // Find excluded students for this team in this time slot + // Also exclude absent students from overlap calculations + var includedStudents = team.Students + .Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id, excludedStudents) && + !absentStudentIds.Contains(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 and absent students removed + return new Team + { + Id = team.Id, + Event = team.Event, + Students = includedStudents, + Captain = team.Captain, + Identifier = team.Identifier + }; + }).ToArray(); + } + + private static bool IsStudentExcluded( + int teamId, + int timeSlotIndex, + int studentId, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents) + { + var key = (teamId, timeSlotIndex, studentId); + return excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded; + } +} diff --git a/Core/Calculation/TeamSchedulerPostProcessor.cs b/Core/Calculation/TeamSchedulerPostProcessor.cs new file mode 100644 index 0000000..ba841ea --- /dev/null +++ b/Core/Calculation/TeamSchedulerPostProcessor.cs @@ -0,0 +1,89 @@ +using Core.Entities; + +namespace Core.Calculation; + +/// +/// Post-processing utilities for team scheduler solutions. +/// +public static class TeamSchedulerPostProcessor +{ + /// + /// Extends teams to adjacent time slots (both forward and backward). + /// Teams marked as extended will appear in consecutive time slots. + /// + /// The scheduler solution to modify + /// Teams that should be extended to adjacent slots + /// All available students for overlap calculations + /// Function to filter teams for overlap calculation + public static void ExtendTeamsInSolution( + TeamSchedulerSolution solution, + IEnumerable extendedTeams, + Student[] allStudents, + Func getTeamsWithoutExcludedStudents) + { + 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); + 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); + previousSlot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(previousSlotTeamsForOverlap, allStudents); + } + } + } +} diff --git a/Core/Models/PartialTeam.cs b/Core/Models/PartialTeam.cs index 75c30b1..4606bec 100644 --- a/Core/Models/PartialTeam.cs +++ b/Core/Models/PartialTeam.cs @@ -12,5 +12,37 @@ public class PartialTeam : Team var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList(); return new PartialTeam{Identifier = Identifier, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents }; } + + /// + /// Creates PartialTeam instances from teams with excluded students. + /// Aggregates exclusions across all time slots for each team. + /// + /// The teams to process + /// Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded + /// Array of teams, with PartialTeam instances for teams with exclusions + public static Team[] CreatePartialTeamsFromExclusions( + IEnumerable teams, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents) + { + return teams.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 excludedStudentsList = team.Students.Where(s => excludedStudentIds.Contains(s.Id)).ToList(); + if (excludedStudentsList.Count == 0) + return team; + + // Create PartialTeam with excluded students + return team.CloneWithOmittedStudents(excludedStudentsList); + }).ToArray(); + } } diff --git a/Core/Utility/TeamFilterExtensions.cs b/Core/Utility/TeamFilterExtensions.cs new file mode 100644 index 0000000..b2bbad1 --- /dev/null +++ b/Core/Utility/TeamFilterExtensions.cs @@ -0,0 +1,50 @@ +using Core.Entities; + +namespace Core.Utility; + +/// +/// Extension methods for filtering teams based on various criteria. +/// +public static class TeamFilterExtensions +{ + /// + /// Adds teams that are regional events to the collection. + /// + public static IEnumerable AddRegionals(this IEnumerable currentTeams, IEnumerable allTeams) + { + return allTeams.Where(e => e.Event.RegionalEvent).Concat(currentTeams).Distinct(); + } + + /// + /// Adds teams with high level of effort (>= 3) to the collection. + /// + public static IEnumerable AddHighLevelOfEffort(this IEnumerable currentTeams, IEnumerable allTeams) + { + return allTeams.Where(e => e.Event.LevelOfEffort >= 3).Concat(currentTeams).Distinct(); + } + + /// + /// Removes individual event teams from the collection. + /// + public static IEnumerable RemoveIndividual(this IEnumerable teams) + { + return teams.Where(t => t.Event.EventFormat != EventFormat.Individual); + } + + /// + /// Removes teams with low level of effort (<= 1) from the collection. + /// + public static IEnumerable RemoveLowLevelOfEffort(this IEnumerable teams) + { + return teams.Where(t => t.Event.LevelOfEffort > 1); + } + + /// + /// Inverts the selection - returns all teams not in the current selection. + /// + public static IEnumerable Invert(this IEnumerable currentTeams, IEnumerable allTeams) + { + var currentTeamIds = currentTeams.Select(t => t.Id).ToHashSet(); + return allTeams.Where(t => !currentTeamIds.Contains(t.Id)); + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index d1e83c5..473627c 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -2,16 +2,21 @@ @attribute [Authorize] @using System.Text @using Core.Calculation +@using Core.Models +@using Core.Utility @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components @using WebApp.Components.Features.MeetingSchedule -@using Core.Utility +@using WebApp.Models +@using WebApp.Services @inject IConfiguration Configuration -@inject AppDbContext Context @inject ClipboardService ClipboardService -@inject LocalStorageService LocalStorage @inject IDialogService DialogService @inject ISnackbar Snackbar +@inject IMeetingScheduleStateService StateService +@inject IMeetingScheduleDataService DataService +@inject IMeetingScheduleClipboardService ClipboardFormatService +@inject LocalStorageService LocalStorage @@ -180,37 +185,31 @@ private void AddRegionals() { - _scheduledTeams - = _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct(); + _scheduledTeams = _scheduledTeams.AddRegionals(_teams); StateHasChanged(); } private void AddHighLevelOfEffort() { - _scheduledTeams - = _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct(); + _scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams); StateHasChanged(); } private void RemoveIndividual() { - _scheduledTeams - = _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual); + _scheduledTeams = _scheduledTeams.RemoveIndividual(); StateHasChanged(); } private void RemoveLowLevelOfEffort() { - _scheduledTeams - = _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1); + _scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort(); StateHasChanged(); } private void Invert() { - var rt = _scheduledTeams.ToArray(); - _scheduledTeams - = _teams.Where(t => !rt.Contains(t)); + _scheduledTeams = _scheduledTeams.Invert(_teams); StateHasChanged(); } @@ -273,32 +272,15 @@ ] ); - _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(); + _teams = await DataService.LoadTeamsAsync(); + _students = await DataService.LoadStudentsAsync(); // Load saved selections from localStorage - await LoadScheduledTeams(); - await LoadAbsentStudents(); - await LoadTimeSlotCount(); - await LoadExtendedTeams(); - await LoadExcludedStudents(); + _scheduledTeams = await StateService.LoadScheduledTeamsAsync(_teams); + _absentStudents = await StateService.LoadAbsentStudentsAsync(_students); + _parameters.TimeSlots = await StateService.LoadTimeSlotCountAsync(2); + _extendedTeams = await StateService.LoadExtendedTeamsAsync(_teams); + _excludedStudents = await StateService.LoadExcludedStudentsAsync(); // Initialize last saved state from loaded values _lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students); @@ -314,84 +296,6 @@ } } - 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("MeetingSchedule_ExcludedStudents"); - if (exclusions != null && exclusions.Length > 0) - { - _excludedStudents = exclusions.ToDictionary( - e => (e.TeamId, e.TimeSlotIndex, e.StudentId), - _ => true); - } - } private int GetTimeSlotIndex(string timeSlotName) { @@ -425,40 +329,12 @@ return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded; } - /// - /// Creates teams with excluded students and absent students filtered out for overlap calculation. - /// private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex) { var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet(); - - return teams.Select(team => - { - // Find excluded students for this team in this time slot - // Also exclude absent students from overlap calculations - // More efficient: iterate through team students and check exclusions - var includedStudents = team.Students - .Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, s.Id) && !absentStudentIds.Contains(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 and absent students removed - return new Team - { - Id = team.Id, - Event = team.Event, - Students = includedStudents, - Captain = team.Captain, - Identifier = team.Identifier - }; - }).ToArray(); + return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds); } - private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId); - private bool IsDirty() { if (_lastSavedState == null) @@ -507,26 +383,7 @@ .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 teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents); var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents); _solution = teamScheduler.Solve(); @@ -564,7 +421,11 @@ // Post-process: extend teams to next consecutive time slot if (_extendedTeams.Any()) { - ExtendTeamsInSolution(_solution, _extendedTeams, availableStudents); + TeamSchedulerPostProcessor.ExtendTeamsInSolution( + _solution, + _extendedTeams, + availableStudents, + GetTeamsWithoutExcludedStudents); } // Try recommendation strategies in priority order @@ -590,6 +451,13 @@ _excludedStudents); await currentState.SaveToLocalStorage(LocalStorage); _lastSavedState = currentState; + + // Also save via state service for consistency + await StateService.SaveScheduledTeamsAsync(_scheduledTeams); + await StateService.SaveAbsentStudentsAsync(_absentStudents); + await StateService.SaveTimeSlotCountAsync(_parameters.TimeSlots); + await StateService.SaveExtendedTeamsAsync(_extendedTeams); + await StateService.SaveExcludedStudentsAsync(_excludedStudents); await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found @@ -602,91 +470,18 @@ _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 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()); + var text = ClipboardFormatService.FormatScheduleForClipboard( + _solution, + _teams, + _absentStudents, + _excludedStudents, + GetTimeSlotIndex); + await ClipboardService.WriteTextAsync(text); } catch { @@ -717,220 +512,6 @@ // Note: Success message is already shown in the dialog, no need to show another here } - 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 - { - public HashSet ScheduledTeamIds { get; set; } = []; - public HashSet AbsentStudentIds { get; set; } = []; - public int TimeSlotCount { get; set; } - public HashSet 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 scheduledTeams, - IEnumerable absentStudents, - int timeSlotCount, - IEnumerable 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 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("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); - } - } } \ No newline at end of file diff --git a/WebApp/Components/Pages/NoteEditDialog.razor b/WebApp/Components/Pages/NoteEditDialog.razor index 2178fef..357b759 100644 --- a/WebApp/Components/Pages/NoteEditDialog.razor +++ b/WebApp/Components/Pages/NoteEditDialog.razor @@ -101,6 +101,6 @@ private void Cancel() { - MudDialog.Cancel(); + MudDialog.Close(DialogResult.Cancel()); } } diff --git a/WebApp/Components/Shared/Components/PageNoteDialog.razor b/WebApp/Components/Shared/Components/PageNoteDialog.razor index 0cfa969..7ee69bf 100644 --- a/WebApp/Components/Shared/Components/PageNoteDialog.razor +++ b/WebApp/Components/Shared/Components/PageNoteDialog.razor @@ -60,7 +60,7 @@ Disabled="@_isLoading"> Edit - Close + Cancel } @@ -242,14 +242,12 @@ if (_isDisposed) return; if (_isEditMode) { - // If in edit mode, cancel goes back to view mode - _isEditMode = false; - // Reload the note to discard changes - _ = LoadPageNote(); + // If in edit mode, cancel closes the dialog (discarding changes) + MudDialog.Close(DialogResult.Cancel()); } else { - MudDialog.Cancel(); + MudDialog.Close(DialogResult.Cancel()); } } diff --git a/WebApp/Models/MeetingScheduleState.cs b/WebApp/Models/MeetingScheduleState.cs new file mode 100644 index 0000000..3b4a572 --- /dev/null +++ b/WebApp/Models/MeetingScheduleState.cs @@ -0,0 +1,117 @@ +using Core.Entities; + +namespace WebApp.Models; + +/// +/// Represents the state of the meeting schedule for dirty/clean tracking and persistence. +/// +public class MeetingScheduleState : IEquatable +{ + public HashSet ScheduledTeamIds { get; set; } = []; + public HashSet AbsentStudentIds { get; set; } = []; + public int TimeSlotCount { get; set; } + public HashSet 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 scheduledTeams, + IEnumerable absentStudents, + int timeSlotCount, + IEnumerable 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 FromLocalStorage( + WebApp.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("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(WebApp.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); + } + + private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId); +} diff --git a/WebApp/Program.cs b/WebApp/Program.cs index c1ebfba..fb683c8 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -197,6 +197,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); diff --git a/WebApp/Services/IMeetingScheduleClipboardService.cs b/WebApp/Services/IMeetingScheduleClipboardService.cs new file mode 100644 index 0000000..9320753 --- /dev/null +++ b/WebApp/Services/IMeetingScheduleClipboardService.cs @@ -0,0 +1,21 @@ +using Core.Calculation; +using Core.Entities; +using Core.Utility; + +namespace WebApp.Services; + +/// +/// Service for formatting meeting schedule data for clipboard operations. +/// +public interface IMeetingScheduleClipboardService +{ + /// + /// Formats the entire schedule solution as text for clipboard. + /// + string FormatScheduleForClipboard( + TeamSchedulerSolution solution, + Team[] allTeams, + IEnumerable absentStudents, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents, + Func getTimeSlotIndex); +} diff --git a/WebApp/Services/IMeetingScheduleDataService.cs b/WebApp/Services/IMeetingScheduleDataService.cs new file mode 100644 index 0000000..388c32a --- /dev/null +++ b/WebApp/Services/IMeetingScheduleDataService.cs @@ -0,0 +1,20 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; + +namespace WebApp.Services; + +/// +/// Service for loading meeting schedule data from the database. +/// +public interface IMeetingScheduleDataService +{ + /// + /// Loads all teams with their events and students. + /// + Task LoadTeamsAsync(); + + /// + /// Loads all students with their teams, event rankings, and related data. + /// + Task LoadStudentsAsync(); +} diff --git a/WebApp/Services/IMeetingScheduleStateService.cs b/WebApp/Services/IMeetingScheduleStateService.cs new file mode 100644 index 0000000..f5c205b --- /dev/null +++ b/WebApp/Services/IMeetingScheduleStateService.cs @@ -0,0 +1,59 @@ +using Core.Entities; + +namespace WebApp.Services; + +/// +/// Service for managing meeting schedule state persistence in localStorage. +/// +public interface IMeetingScheduleStateService +{ + /// + /// Loads scheduled teams from localStorage. + /// + Task> LoadScheduledTeamsAsync(Team[] allTeams); + + /// + /// Saves scheduled teams to localStorage. + /// + Task SaveScheduledTeamsAsync(IEnumerable scheduledTeams); + + /// + /// Loads absent students from localStorage. + /// + Task> LoadAbsentStudentsAsync(Student[] allStudents); + + /// + /// Saves absent students to localStorage. + /// + Task SaveAbsentStudentsAsync(IEnumerable absentStudents); + + /// + /// Loads time slot count from localStorage. + /// + Task LoadTimeSlotCountAsync(int defaultValue = 2); + + /// + /// Saves time slot count to localStorage. + /// + Task SaveTimeSlotCountAsync(int timeSlotCount); + + /// + /// Loads extended teams from localStorage. + /// + Task> LoadExtendedTeamsAsync(Team[] allTeams); + + /// + /// Saves extended teams to localStorage. + /// + Task SaveExtendedTeamsAsync(IEnumerable extendedTeams); + + /// + /// Loads excluded students from localStorage. + /// + Task> LoadExcludedStudentsAsync(); + + /// + /// Saves excluded students to localStorage. + /// + Task SaveExcludedStudentsAsync(Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents); +} diff --git a/WebApp/Services/MeetingScheduleClipboardService.cs b/WebApp/Services/MeetingScheduleClipboardService.cs new file mode 100644 index 0000000..248096b --- /dev/null +++ b/WebApp/Services/MeetingScheduleClipboardService.cs @@ -0,0 +1,123 @@ +using System.Text; +using Core.Calculation; +using Core.Entities; +using Core.Utility; + +namespace WebApp.Services; + +/// +/// Service for formatting meeting schedule data for clipboard operations. +/// +public class MeetingScheduleClipboardService : IMeetingScheduleClipboardService +{ + public string FormatScheduleForClipboard( + TeamSchedulerSolution solution, + Team[] allTeams, + IEnumerable absentStudents, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents, + Func getTimeSlotIndex) + { + var sb = new StringBuilder(); + foreach (var timeslot in solution.TimeSlots) + { + AppendScheduledTeams(sb, timeslot, allTeams, absentStudents, excludedStudents, getTimeSlotIndex); + AppendUnscheduledStudents(sb, timeslot, solution, absentStudents); + sb.Append(Environment.NewLine); + } + return sb.ToString(); + } + + private void AppendScheduledTeams( + StringBuilder sb, + TeamScheduleTimeSlot timeslot, + Team[] allTeams, + IEnumerable absentStudents, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents, + Func getTimeSlotIndex) + { + 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, absentStudents, excludedStudents); + sb.Append($"{teamName} - {studentsList}"); + } + sb.Append(Environment.NewLine); + } + } + + private string FormatStudentList( + Team team, + TeamScheduleTimeSlot timeslot, + int timeSlotIndex, + IEnumerable absentStudents, + Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents) + { + // 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 void AppendUnscheduledStudents( + StringBuilder sb, + TeamScheduleTimeSlot timeslot, + TeamSchedulerSolution solution, + IEnumerable absentStudents) + { + 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); + } + } +} diff --git a/WebApp/Services/MeetingScheduleDataService.cs b/WebApp/Services/MeetingScheduleDataService.cs new file mode 100644 index 0000000..f632a65 --- /dev/null +++ b/WebApp/Services/MeetingScheduleDataService.cs @@ -0,0 +1,43 @@ +using Core.Entities; +using Data; +using Microsoft.EntityFrameworkCore; + +namespace WebApp.Services; + +/// +/// Service for loading meeting schedule data from the database. +/// +public class MeetingScheduleDataService : IMeetingScheduleDataService +{ + private readonly AppDbContext _context; + + public MeetingScheduleDataService(AppDbContext context) + { + _context = context; + } + + public async Task LoadTeamsAsync() + { + return await _context.Teams + .AsNoTracking() + .Include(e => e.Event) + .Include(e => e.Students) + .OrderBy(e => e.Event.Name) + .ThenBy(e => e.Identifier) + .ToArrayAsync(); + } + + public async Task LoadStudentsAsync() + { + return 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(); + } +} diff --git a/WebApp/Services/MeetingScheduleStateService.cs b/WebApp/Services/MeetingScheduleStateService.cs new file mode 100644 index 0000000..d7e1cbd --- /dev/null +++ b/WebApp/Services/MeetingScheduleStateService.cs @@ -0,0 +1,105 @@ +using Core.Entities; +using WebApp.Models; + +namespace WebApp.Services; + +internal record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId); + +/// +/// Service for managing meeting schedule state persistence in localStorage. +/// +public class MeetingScheduleStateService : IMeetingScheduleStateService +{ + private readonly LocalStorageService _localStorage; + private const string ScheduledTeamsKey = "MeetingSchedule_ScheduledTeams"; + private const string AbsentStudentsKey = "MeetingSchedule_AbsentStudents"; + private const string TimeSlotCountKey = "MeetingSchedule_TimeSlotCount"; + private const string ExtendedTeamsKey = "MeetingSchedule_ExtendedTeams"; + private const string ExcludedStudentsKey = "MeetingSchedule_ExcludedStudents"; + + public MeetingScheduleStateService(LocalStorageService localStorage) + { + _localStorage = localStorage; + } + + public async Task> LoadScheduledTeamsAsync(Team[] allTeams) + { + var teamIds = await _localStorage.GetIntArrayAsync(ScheduledTeamsKey); + if (teamIds.Length > 0) + { + return allTeams.Where(t => teamIds.Contains(t.Id)).ToArray(); + } + return []; + } + + public async Task SaveScheduledTeamsAsync(IEnumerable scheduledTeams) + { + var teamIds = scheduledTeams.Select(t => t.Id).ToArray(); + await _localStorage.SetIntArrayAsync(ScheduledTeamsKey, teamIds); + } + + public async Task> LoadAbsentStudentsAsync(Student[] allStudents) + { + var studentIds = await _localStorage.GetIntArrayAsync(AbsentStudentsKey); + if (studentIds.Length > 0) + { + return allStudents.Where(s => studentIds.Contains(s.Id)).ToArray(); + } + return []; + } + + public async Task SaveAbsentStudentsAsync(IEnumerable absentStudents) + { + var studentIds = absentStudents.Select(s => s.Id).ToArray(); + await _localStorage.SetIntArrayAsync(AbsentStudentsKey, studentIds); + } + + public async Task LoadTimeSlotCountAsync(int defaultValue = 2) + { + var timeSlots = await _localStorage.GetIntAsync(TimeSlotCountKey, defaultValue); + return timeSlots > 0 ? timeSlots : defaultValue; + } + + public async Task SaveTimeSlotCountAsync(int timeSlotCount) + { + await _localStorage.SetIntAsync(TimeSlotCountKey, timeSlotCount); + } + + public async Task> LoadExtendedTeamsAsync(Team[] allTeams) + { + var teamIds = await _localStorage.GetIntArrayAsync(ExtendedTeamsKey); + if (teamIds.Length > 0) + { + return allTeams.Where(t => teamIds.Contains(t.Id)).ToArray(); + } + return []; + } + + public async Task SaveExtendedTeamsAsync(IEnumerable extendedTeams) + { + var teamIds = extendedTeams.Select(t => t.Id).ToArray(); + await _localStorage.SetIntArrayAsync(ExtendedTeamsKey, teamIds); + } + + public async Task> LoadExcludedStudentsAsync() + { + var exclusions = await _localStorage.GetJsonAsync(ExcludedStudentsKey); + if (exclusions != null && exclusions.Length > 0) + { + return exclusions.ToDictionary( + e => (e.TeamId, e.TimeSlotIndex, e.StudentId), + _ => true); + } + return new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>(); + } + + public async Task SaveExcludedStudentsAsync(Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents) + { + var exclusions = excludedStudents.Keys + .Where(k => excludedStudents[k]) + .Select(k => new ExcludedStudent(k.teamId, k.timeSlotIndex, k.studentId)) + .ToArray(); + await _localStorage.SetJsonAsync(ExcludedStudentsKey, exclusions); + } + +}