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