From 34658e969711cc58c16bf8a69ed434ac2e2b90d9 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Tue, 13 Jan 2026 12:50:34 -0500 Subject: [PATCH] Implement state management for MeetingSchedule component with local storage support This commit enhances the Index.razor component of the MeetingSchedule feature by introducing a new MeetingScheduleState class to manage the scheduling state. The component now tracks the last saved state for dirty/clean comparisons and saves the current state to local storage after solving the schedule. Additionally, the UI has been updated to reflect changes in the button's appearance based on the dirty state, improving user feedback and interaction. These changes contribute to a more robust and user-friendly scheduling experience. --- .../Features/MeetingSchedule/Index.razor | 192 ++++++++++++++++-- 1 file changed, 172 insertions(+), 20 deletions(-) diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index 17de2ad..0581bd0 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -46,7 +46,13 @@ Reset - Solve + + Solve + @@ -120,75 +126,75 @@ private IEnumerable _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 async Task OnScheduledTeamsChanged(IEnumerable teams) { _scheduledTeams = teams; - await SaveScheduledTeams(); + StateHasChanged(); } private async Task OnAbsentStudentsChanged(IEnumerable students) { _absentStudents = students; - await SaveAbsentStudents(); + StateHasChanged(); } private async Task OnTimeSlotCountChanged(int timeSlots) { _parameters.TimeSlots = timeSlots; - await SaveTimeSlotCount(); + StateHasChanged(); } private async Task OnExtendedTeamsChanged(IEnumerable teams) { _extendedTeams = teams; - await SaveExtendedTeams(); + StateHasChanged(); } - private async void AddRegionals() + private void AddRegionals() { _scheduledTeams = _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct(); - await SaveScheduledTeams(); + StateHasChanged(); } - private async void AddHighLevelOfEffort() + private void AddHighLevelOfEffort() { _scheduledTeams = _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct(); - await SaveScheduledTeams(); + StateHasChanged(); } - private async void RemoveIndividual() + private void RemoveIndividual() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual); - await SaveScheduledTeams(); + StateHasChanged(); } - private async void RemoveLowLevelOfEffort() + private void RemoveLowLevelOfEffort() { _scheduledTeams = _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1); - await SaveScheduledTeams(); + StateHasChanged(); } - private async void Invert() + private void Invert() { var rt = _scheduledTeams.ToArray(); _scheduledTeams = _teams.Where(t => !rt.Contains(t)); - await SaveScheduledTeams(); + StateHasChanged(); } - private async void Reset() + private void Reset() { _scheduledTeams = []; _extendedTeams = []; _excludedStudents.Clear(); - await SaveScheduledTeams(); - await SaveExtendedTeams(); - await SaveExcludedStudents(); + StateHasChanged(); } private async Task ToggleRequiredTeam(Team unassignedTeam) @@ -264,6 +270,19 @@ 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() @@ -368,7 +387,6 @@ { _excludedStudents[key] = true; } - await SaveExcludedStudents(); StateHasChanged(); } @@ -409,6 +427,21 @@ 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> SolveSchedule(TableState arg1, CancellationToken arg2) { _isSolving = true; @@ -516,6 +549,16 @@ .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; @@ -726,4 +769,113 @@ } } + 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