Files
chapter-organizer/WebApp/Components/Features/MeetingSchedule/Index.razor
T
poprhythmandCursor 3f50d6e635 feat: add a Tools page printer for note merge and print presets
Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:38:43 -04:00

683 lines
28 KiB
Plaintext

@page "/meeting-schedule"
@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 WebApp.Models
@using WebApp.Services
@inject IConfiguration Configuration
@inject ClipboardService ClipboardService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IMeetingScheduleStateService StateService
@inject IMeetingScheduleDataService DataService
@inject IMeetingScheduleClipboardService ClipboardFormatService
@inject LocalStorageService LocalStorage
<PageHeader Title="@($"Meeting Scheduler Planner")">
<ActionButtons>
<MudTooltip Text="View meeting history">
<MudButton StartIcon="@Icons.Material.Filled.History"
Variant="Variant.Outlined"
Color="Color.Default"
Href="/meeting-schedule/history">
View History
</MudButton>
</MudTooltip>
<MudTooltip Text="Save current schedule as meeting history">
<MudButton StartIcon="@Icons.Material.Filled.Save"
Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="OpenSaveHistoryDialog"
Disabled="@(!_scheduledTeams.Any())">
Save to History
</MudButton>
</MudTooltip>
<PageNoteButton PageIdentifier="Meeting Schedule" />
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
<MudGrid>
<MudItem xs="12" sm="8" lg="9">
<MudText Typo="Typo.h4">Time Slots</MudText>
<MudPaper Class="pa-2 ma-2" Elevation="3">
<MudGrid>
<MudItem xs="12" sm="6" lg="4">
<MudPaper Elevation="0" Class="pa-1" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">Time Slots</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0" Style="border: 1px solid var(--mud-palette-lines-default); border-radius: var(--mud-default-borderradius);">
<MudIconButton Icon="@Icons.Material.Filled.Remove"
OnClick="DecrementTimeSlots"
Disabled="@(_parameters.TimeSlots <= 1)"
Size="Size.Small"
Variant="Variant.Text"
Style="border-radius: var(--mud-default-borderradius) 0 0 var(--mud-default-borderradius);" />
<MudButton Disabled="true"
Variant="Variant.Text"
Style="min-width: 50px; width: 50px; pointer-events: none; border-left: 1px solid var(--mud-palette-lines-default); border-right: 1px solid var(--mud-palette-lines-default); border-radius: 0;">
@_parameters.TimeSlots
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Add"
OnClick="IncrementTimeSlots"
Disabled="@(_parameters.TimeSlots >= 4)"
Size="Size.Small"
Variant="Variant.Text"
Style="border-radius: 0 var(--mud-default-borderradius) var(--mud-default-borderradius) 0;" />
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="High Effort"
OnAdd="AddHighLevelOfEffort"
OnRemove="RemoveHighLevelOfEffort"
AddTooltip="Schedule teams with Level of Effort >= 3"
RemoveTooltip="Remove teams with Level of Effort >= 3" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Regionals"
OnAdd="AddRegionals"
OnRemove="RemoveRegionals"
AddTooltip="Add regional event teams"
RemoveTooltip="Remove regional event teams" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Presubmission"
OnAdd="AddPresubmission"
OnRemove="RemovePresubmission"
AddTooltip="Add teams whose events require presubmission"
RemoveTooltip="Remove teams whose events require presubmission" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Individual"
OnAdd="AddIndividual"
OnRemove="RemoveIndividual"
AddTooltip="Add individual event teams"
RemoveTooltip="Remove individual event teams" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<AddRemoveFilter Label="Low Effort"
OnAdd="AddLowLevelOfEffort"
OnRemove="RemoveLowLevelOfEffort"
AddTooltip="Add teams with Level of Effort <= 1"
RemoveTooltip="Remove teams with Level of Effort <= 1" />
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" OnClick="Invert" FullWidth="true">Invert</MudButton>
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<MudTooltip Text="Load teams from clipboard text by matching team names">
<MudButton Variant="Variant.Outlined" OnClick="LoadTeamsFromClipboard" FullWidth="true" Disabled="@_isLoadingClipboard">Load from Clipboard</MudButton>
</MudTooltip>
</MudItem>
<MudItem xs="12" sm="6" lg="4">
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
</MudItem>
<MudItem xs="12">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudSpacer />
<MudTooltip Text="Copy to Clipboard">
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
</MudTooltip>
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
OnClick="Solve"
Color="Color.Primary"
Disabled="@_isSolving">
Solve
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
<MudTable T="TeamScheduleTimeSlot" ServerData="SolveSchedule" @ref="_solutionData">
<HeaderContent>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudGrid>
<MudItem xs="12" lg="6">
<ScheduledTeamsList TimeSlotName="@context.Name"
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
Teams="@context.Teams"
ScheduledTeams="@_scheduledTeams"
AbsentStudents="@_absentStudents"
StudentHasOverlaps="@context.StudentHasOverlaps"
ExcludedStudents="@_excludedStudents"
OnToggleTeam="@ToggleRequiredTeam"
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
</MudItem>
<MudItem xs="12" lg="6">
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
AbsentStudents="@_absentStudents"
ScheduledTeams="@_scheduledTeams"
PossibleAdditions="@_possibleAdditions"
UnassignedTeams="@_solution.StudentUnassignedTeams"
OnToggleTeam="@ToggleRequiredTeam" />
</MudItem>
</MudGrid>
</MudTd>
</RowTemplate>
</MudTable>
</MudItem>
<MudItem xs="12" sm="4" lg="3">
<MudStack>
<StudentTextBoxSelector Students="@_students"
SelectedStudents="_absentStudents"
SelectedStudentsChanged="OnAbsentStudentsChanged"
Title="Absent Students"
Label="Search for absent students"
ShowFullName="true"/>
<MudDivider Class="my-4"/>
<TeamMeetingToggleSelector Teams="@_teams"
SelectedTeams="_scheduledTeams"
SelectedTeamsChanged="OnScheduledTeamsChanged"
ExtendedTeams="_extendedTeams"
ExtendedTeamsChanged="OnExtendedTeamsChanged"
Title="Scheduled Teams"
ShowEventAttributes="false" />
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@code {
private Team[] _teams = null!;
private Student[] _students = null!;
MudTable<TeamScheduleTimeSlot> _solutionData = null!;
private TeamSchedulerSolution _solution = null!;
private TeamSchedulerOptions _parameters = null!;
bool _isSolving;
private bool _isLoadingClipboard = false;
private IEnumerable<Team> _scheduledTeams = [];
private IEnumerable<Student> _absentStudents = [];
private IEnumerable<Team> _possibleAdditions = [];
private IEnumerable<Team> _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 void OnScheduledTeamsChanged(IEnumerable<Team> teams)
{
_scheduledTeams = teams;
StateHasChanged();
}
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
{
_absentStudents = students;
StateHasChanged();
}
private async Task OnTimeSlotCountChanged(int timeSlots)
{
_parameters.TimeSlots = timeSlots;
StateHasChanged();
await Task.CompletedTask;
}
private async Task IncrementTimeSlots()
{
if (_parameters.TimeSlots < 4)
{
await OnTimeSlotCountChanged(_parameters.TimeSlots + 1);
}
}
private async Task DecrementTimeSlots()
{
if (_parameters.TimeSlots > 1)
{
await OnTimeSlotCountChanged(_parameters.TimeSlots - 1);
}
}
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
{
_extendedTeams = teams;
StateHasChanged();
}
private void AddRegionals()
{
_scheduledTeams = _scheduledTeams.AddRegionals(_teams);
StateHasChanged();
}
private void AddPresubmission()
{
var presubmissionTeams = _teams.Where(t => t.Event?.Presubmission == true);
_scheduledTeams = _scheduledTeams.Concat(presubmissionTeams).Distinct();
StateHasChanged();
}
private void AddHighLevelOfEffort()
{
_scheduledTeams = _scheduledTeams.AddHighLevelOfEffort(_teams);
StateHasChanged();
}
private void RemoveHighLevelOfEffort()
{
var highEffortTeamIds = _teams.Where(t => t.Event.LevelOfEffort >= 3).Select(t => t.Id).ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !highEffortTeamIds.Contains(t.Id));
StateHasChanged();
}
private void RemoveRegionals()
{
var regionalTeamIds = _teams.Where(t => t.Event.RegionalEvent).Select(t => t.Id).ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !regionalTeamIds.Contains(t.Id));
StateHasChanged();
}
private void RemovePresubmission()
{
var presubmissionTeamIds = _teams
.Where(t => t.Event?.Presubmission == true)
.Select(t => t.Id)
.ToHashSet();
_scheduledTeams = _scheduledTeams.Where(t => !presubmissionTeamIds.Contains(t.Id));
StateHasChanged();
}
private void AddIndividual()
{
var individualTeams = _teams.Where(t => t.Event.EventFormat == EventFormat.Individual);
_scheduledTeams = _scheduledTeams.Concat(individualTeams).Distinct();
StateHasChanged();
}
private void RemoveIndividual()
{
_scheduledTeams = _scheduledTeams.RemoveIndividual();
StateHasChanged();
}
private void AddLowLevelOfEffort()
{
var lowEffortTeams = _teams.Where(t => t.Event.LevelOfEffort <= 1);
_scheduledTeams = _scheduledTeams.Concat(lowEffortTeams).Distinct();
StateHasChanged();
}
private void RemoveLowLevelOfEffort()
{
_scheduledTeams = _scheduledTeams.RemoveLowLevelOfEffort();
StateHasChanged();
}
private void Invert()
{
_scheduledTeams = _scheduledTeams.Invert(_teams);
StateHasChanged();
}
private void Reset()
{
_scheduledTeams = [];
_extendedTeams = [];
_excludedStudents.Clear();
StateHasChanged();
}
private void ToggleRequiredTeam(Team unassignedTeam)
{
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
if (matchingTeam == null) return;
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
IEnumerable<Team> newScheduledTeams;
if (scheduledTeamIds.Contains(matchingTeam.Id))
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
else
{
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
}
// Update state and notify component to re-render
OnScheduledTeamsChanged(newScheduledTeams);
}
protected override async Task OnInitializedAsync()
{
_parameters =
new TeamSchedulerOptions(
2,
mustIncludeEvents:
[
// "Medical Technology", "Electrical Applications" , "RegionalTeam",
// ,"Dragster", "Flight"
],
extended:
[
// "Invention", "Construction Challenge", "Mechanical", "Mass", "Micro"
//"STEM"
//"Community", "Vlogging"// "Microcontroller"
],
omittedEvents:
[
// "Vlogging", "Junior", "Community Service Video", "Digital Photography",
// "STEM"
//"Leadership",// "Electrical", //"Construction"
// "Forensic",
//"CAD"
//"I&I Team 1", "I&I Team 2"//, "Website Design",
],
absentStudents:
[
]
);
_teams = await DataService.LoadTeamsAsync();
_students = await DataService.LoadStudentsAsync();
// Load saved selections from localStorage
_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);
if (_lastSavedState == null)
{
// If no saved state exists, create initial state from current values
_lastSavedState = MeetingScheduleState.FromCurrent(
_scheduledTeams,
_absentStudents,
_parameters.TimeSlots,
_extendedTeams,
_excludedStudents);
}
}
private int GetTimeSlotIndex(string timeSlotName)
{
if (_solution?.TimeSlots == null)
return 0; // Default to first slot if solution not available
for (int i = 0; i < _solution.TimeSlots.Length; i++)
{
if (_solution.TimeSlots[i].Name == timeSlotName)
return i;
}
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
}
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
{
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
{
_excludedStudents.Remove(key);
}
else
{
_excludedStudents[key] = true;
}
StateHasChanged();
}
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
{
var key = (teamId, timeSlotIndex, studentId);
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
}
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
{
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
}
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<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
{
_isSolving = true;
// Check if there are any teams to schedule
if (!_scheduledTeams.Any())
{
_isSolving = false;
_solution = new TeamSchedulerSolution([], [], "No teams selected");
return new TableData<TeamScheduleTimeSlot> { Items = [] };
}
// Filter out absent students
var availableStudents = _students.Where(s => !_absentStudents.Contains(s)).ToArray();
// Check if there are any available students
if (availableStudents.Length == 0)
{
_isSolving = false;
_solution = new TeamSchedulerSolution([], [], "No available students");
return new TableData<TeamScheduleTimeSlot> { Items = [] };
}
// Update parameters with absent student names
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
// Update parameters with extended team event names
_parameters.ExtendedTeams = _extendedTeams
.Where(t => t.Event != null)
.Select(t => t.Event!.Name)
.ToArray();
// Create PartialTeam instances for teams with excluded students
var teamsForScheduling = PartialTeam.CreatePartialTeamsFromExclusions(_scheduledTeams, _excludedStudents);
var teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
_solution = teamScheduler.Solve();
// Restore full teams (with all students) in the solution so excluded students still appear (dimmed)
// Create a mapping from PartialTeam to original Team
var teamMapping = _scheduledTeams.ToDictionary(t => t.Id, t => t);
for (int slotIndex = 0; slotIndex < _solution.TimeSlots.Length; slotIndex++)
{
var slot = _solution.TimeSlots[slotIndex];
if (slot.Teams == null)
continue;
var restoredTeams = slot.Teams.Select(team =>
{
// If this is a PartialTeam or we have the original, restore it
if (teamMapping.TryGetValue(team.Id, out var originalTeam))
{
return originalTeam;
}
return team;
}).ToArray();
slot.Teams = restoredTeams;
// Recalculate overlaps and unscheduled students with full teams
// Filter out excluded students when calculating overlaps and unscheduled students
var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex);
slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation);
// Use teams without excluded students so students excluded from all teams appear as unscheduled
slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(teamsForOverlapCalculation, availableStudents);
}
// Post-process: extend teams to next consecutive time slot
if (_extendedTeams.Any())
{
TeamSchedulerPostProcessor.ExtendTeamsInSolution(
_solution,
_extendedTeams,
availableStudents,
GetTeamsWithoutExcludedStudents);
}
// Try recommendation strategies in priority order
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
UnassignedScheduleStrategy[] strategies =
[
UnassignedScheduleStrategy.LevelOfEffort,
UnassignedScheduleStrategy.BiggestGroup,
UnassignedScheduleStrategy.AnyNotMeetingAlready,
UnassignedScheduleStrategy.IndividualEvents
];
_possibleAdditions = strategies
.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;
// 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
_isSolving = false;
return new TableData<TeamScheduleTimeSlot> { Items = _solution.TimeSlots };
}
private void Solve()
{
_solutionData.ReloadServerData();
}
async Task CopyToClipboard()
{
try
{
var text = ClipboardFormatService.FormatScheduleForClipboard(
_solution,
_teams,
_absentStudents,
_excludedStudents,
GetTimeSlotIndex);
await ClipboardService.WriteTextAsync(text);
}
catch
{
Console.WriteLine("Cannot write text to clipboard");
}
}
private async Task OpenSaveHistoryDialog()
{
var parameters = new DialogParameters
{
["ScheduledTeams"] = _scheduledTeams,
["AbsentStudents"] = _absentStudents,
["AllTeams"] = _teams,
["AllStudents"] = _students
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Save Meeting History", parameters, options);
var result = await dialog.Result;
// Note: Success message is already shown in the dialog, no need to show another here
}
private async Task LoadTeamsFromClipboard()
{
if (_isLoadingClipboard) return;
try
{
_isLoadingClipboard = true;
StateHasChanged();
var clipboardText = await ClipboardService.ReadTextAsync();
if (string.IsNullOrWhiteSpace(clipboardText))
{
Snackbar.Add("Clipboard is empty", Severity.Warning);
return;
}
var matchedTeams = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _teams);
if (!matchedTeams.Any())
{
Snackbar.Add("No matching teams found in clipboard text", Severity.Info);
return;
}
// Combine with existing scheduled teams, avoiding duplicates
var existingTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
var newTeams = matchedTeams.Where(t => !existingTeamIds.Contains(t.Id));
var updatedTeams = _scheduledTeams.Concat(newTeams).ToList();
OnScheduledTeamsChanged(updatedTeams);
var newCount = newTeams.Count();
var totalCount = matchedTeams.Count();
if (newCount == totalCount)
{
Snackbar.Add($"Selected {totalCount} team(s) from clipboard", Severity.Success);
}
else
{
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
}
}
catch (JSException)
{
Snackbar.Add("Unable to access clipboard. Please ensure clipboard permissions are granted.", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading teams from clipboard: {ex.Message}", Severity.Error);
}
finally
{
_isLoadingClipboard = false;
StateHasChanged();
}
}
}