Add meeting schedule state management services and related models

This commit introduces several new services and models to manage the meeting schedule state within localStorage. The MeetingScheduleState class is created to track scheduled teams, absent students, time slot counts, extended teams, and excluded students. Additionally, the IMeetingScheduleStateService interface and its implementation, MeetingScheduleStateService, are added to handle loading and saving state data. The MeetingScheduleClipboardService and MeetingScheduleDataService are also introduced to facilitate clipboard operations and data loading from the database, respectively. These enhancements improve the overall functionality and user experience of the meeting scheduling feature.
This commit is contained in:
2026-01-19 23:02:55 -05:00
parent 649a0061cf
commit ddb743847d
15 changed files with 766 additions and 467 deletions
@@ -0,0 +1,21 @@
using Core.Calculation;
using Core.Entities;
using Core.Utility;
namespace WebApp.Services;
/// <summary>
/// Service for formatting meeting schedule data for clipboard operations.
/// </summary>
public interface IMeetingScheduleClipboardService
{
/// <summary>
/// Formats the entire schedule solution as text for clipboard.
/// </summary>
string FormatScheduleForClipboard(
TeamSchedulerSolution solution,
Team[] allTeams,
IEnumerable<Student> absentStudents,
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
Func<string, int> getTimeSlotIndex);
}
@@ -0,0 +1,20 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Service for loading meeting schedule data from the database.
/// </summary>
public interface IMeetingScheduleDataService
{
/// <summary>
/// Loads all teams with their events and students.
/// </summary>
Task<Team[]> LoadTeamsAsync();
/// <summary>
/// Loads all students with their teams, event rankings, and related data.
/// </summary>
Task<Student[]> LoadStudentsAsync();
}
@@ -0,0 +1,59 @@
using Core.Entities;
namespace WebApp.Services;
/// <summary>
/// Service for managing meeting schedule state persistence in localStorage.
/// </summary>
public interface IMeetingScheduleStateService
{
/// <summary>
/// Loads scheduled teams from localStorage.
/// </summary>
Task<IEnumerable<Team>> LoadScheduledTeamsAsync(Team[] allTeams);
/// <summary>
/// Saves scheduled teams to localStorage.
/// </summary>
Task SaveScheduledTeamsAsync(IEnumerable<Team> scheduledTeams);
/// <summary>
/// Loads absent students from localStorage.
/// </summary>
Task<IEnumerable<Student>> LoadAbsentStudentsAsync(Student[] allStudents);
/// <summary>
/// Saves absent students to localStorage.
/// </summary>
Task SaveAbsentStudentsAsync(IEnumerable<Student> absentStudents);
/// <summary>
/// Loads time slot count from localStorage.
/// </summary>
Task<int> LoadTimeSlotCountAsync(int defaultValue = 2);
/// <summary>
/// Saves time slot count to localStorage.
/// </summary>
Task SaveTimeSlotCountAsync(int timeSlotCount);
/// <summary>
/// Loads extended teams from localStorage.
/// </summary>
Task<IEnumerable<Team>> LoadExtendedTeamsAsync(Team[] allTeams);
/// <summary>
/// Saves extended teams to localStorage.
/// </summary>
Task SaveExtendedTeamsAsync(IEnumerable<Team> extendedTeams);
/// <summary>
/// Loads excluded students from localStorage.
/// </summary>
Task<Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>> LoadExcludedStudentsAsync();
/// <summary>
/// Saves excluded students to localStorage.
/// </summary>
Task SaveExcludedStudentsAsync(Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents);
}
@@ -0,0 +1,123 @@
using System.Text;
using Core.Calculation;
using Core.Entities;
using Core.Utility;
namespace WebApp.Services;
/// <summary>
/// Service for formatting meeting schedule data for clipboard operations.
/// </summary>
public class MeetingScheduleClipboardService : IMeetingScheduleClipboardService
{
public string FormatScheduleForClipboard(
TeamSchedulerSolution solution,
Team[] allTeams,
IEnumerable<Student> absentStudents,
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
Func<string, int> 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<Student> absentStudents,
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
Func<string, int> 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<Student> 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<Student> 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);
}
}
}
@@ -0,0 +1,43 @@
using Core.Entities;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Service for loading meeting schedule data from the database.
/// </summary>
public class MeetingScheduleDataService : IMeetingScheduleDataService
{
private readonly AppDbContext _context;
public MeetingScheduleDataService(AppDbContext context)
{
_context = context;
}
public async Task<Team[]> 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<Student[]> 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();
}
}
@@ -0,0 +1,105 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Services;
internal record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
/// <summary>
/// Service for managing meeting schedule state persistence in localStorage.
/// </summary>
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<IEnumerable<Team>> 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<Team> scheduledTeams)
{
var teamIds = scheduledTeams.Select(t => t.Id).ToArray();
await _localStorage.SetIntArrayAsync(ScheduledTeamsKey, teamIds);
}
public async Task<IEnumerable<Student>> 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<Student> absentStudents)
{
var studentIds = absentStudents.Select(s => s.Id).ToArray();
await _localStorage.SetIntArrayAsync(AbsentStudentsKey, studentIds);
}
public async Task<int> 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<IEnumerable<Team>> 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<Team> extendedTeams)
{
var teamIds = extendedTeams.Select(t => t.Id).ToArray();
await _localStorage.SetIntArrayAsync(ExtendedTeamsKey, teamIds);
}
public async Task<Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>> LoadExcludedStudentsAsync()
{
var exclusions = await _localStorage.GetJsonAsync<ExcludedStudent[]>(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);
}
}