diff --git a/Core/Utility/TeamClipboardMatcher.cs b/Core/Utility/TeamClipboardMatcher.cs
new file mode 100644
index 0000000..8b0b0ef
--- /dev/null
+++ b/Core/Utility/TeamClipboardMatcher.cs
@@ -0,0 +1,91 @@
+using Core.Entities;
+using FuzzySharp;
+
+namespace Core.Utility;
+
+///
+/// Utility class for matching team names from clipboard text using fuzzy matching.
+///
+public static class TeamClipboardMatcher
+{
+ ///
+ /// Matches team names from clipboard text against available teams using fuzzy matching.
+ ///
+ /// The text content from the clipboard.
+ /// The collection of available teams to match against.
+ /// The minimum fuzzy match score threshold (0-100). Default is 85.
+ /// A list of teams that match the clipboard text, ordered by match quality.
+ public static List MatchTeamsFromClipboard(
+ string clipboardText,
+ IEnumerable availableTeams,
+ int matchThreshold = 85)
+ {
+ var matchedTeams = new List();
+ var teamsList = availableTeams.ToList();
+
+ if (string.IsNullOrWhiteSpace(clipboardText) || !teamsList.Any())
+ {
+ return matchedTeams;
+ }
+
+ // Split clipboard text by newlines
+ var lines = clipboardText.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var line in lines)
+ {
+ // Extract team name portion (before " - " if present, as clipboard format includes student lists)
+ var teamName = ExtractTeamNameFromLine(line);
+
+ // Skip empty lines or lines that look like headers/metadata
+ if (ShouldSkipLine(teamName))
+ {
+ continue;
+ }
+
+ // Find best match using fuzzy matching
+ var matchedTeam = FindBestMatch(teamName, teamsList, matchThreshold);
+
+ if (matchedTeam != null && !matchedTeams.Any(t => t.Id == matchedTeam.Id))
+ {
+ matchedTeams.Add(matchedTeam);
+ }
+ }
+
+ return matchedTeams;
+ }
+
+ private static string ExtractTeamNameFromLine(string line)
+ {
+ var dashIndex = line.IndexOf(" - ");
+ if (dashIndex > 0)
+ {
+ return line.Substring(0, dashIndex).Trim();
+ }
+ return line.Trim();
+ }
+
+ private static bool ShouldSkipLine(string teamName)
+ {
+ return string.IsNullOrWhiteSpace(teamName) ||
+ teamName.StartsWith("--") ||
+ teamName.Equals("Unscheduled", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static Team? FindBestMatch(string teamName, List availableTeams, int matchThreshold)
+ {
+ // Normalize both strings to lowercase for case-insensitive comparison
+ var normalizedTeamName = teamName.ToLowerInvariant();
+
+ var bestMatch = availableTeams
+ .Select(team => new
+ {
+ Team = team,
+ Score = Fuzz.Ratio(normalizedTeamName, team.ToString().ToLowerInvariant())
+ })
+ .Where(x => x.Score >= matchThreshold)
+ .OrderByDescending(x => x.Score)
+ .FirstOrDefault();
+
+ return bestMatch?.Team;
+ }
+}
diff --git a/Tests/Utility/TeamClipboardMatcher_Tests.cs b/Tests/Utility/TeamClipboardMatcher_Tests.cs
new file mode 100644
index 0000000..8b0bbfb
--- /dev/null
+++ b/Tests/Utility/TeamClipboardMatcher_Tests.cs
@@ -0,0 +1,313 @@
+using Core.Entities;
+using Core.Utility;
+using Tests.Builders;
+
+namespace Tests.Utility;
+
+[TestFixture]
+public class TeamClipboardMatcher_Tests
+{
+ private List _availableTeams = [];
+
+ [SetUp]
+ public void SetUp()
+ {
+ BuilderExtensions.ResetAllBuilders();
+
+ // Create test teams
+ var construction = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
+ var flight = EventDefinitionBuilder.Individual("Flight").Build();
+ var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).Build();
+ var coding = EventDefinitionBuilder.Individual("Coding").Build();
+ var medicalTech = EventDefinitionBuilder.Team("Medical Technology", 2, 3).Build();
+
+ _availableTeams =
+ [
+ TeamBuilder.Create(construction).WithIdentifier("2").Build(),
+ TeamBuilder.Create(flight).Build(),
+ TeamBuilder.Create(robotics).Build(),
+ TeamBuilder.Create(coding).Build(),
+ TeamBuilder.Create(medicalTech).Build()
+ ];
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_ExactMatch_ReturnsMatchingTeam()
+ {
+ // Arrange
+ var clipboardText = "Construction Challenge (2)";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
+ Assert.That(result[0].Identifier, Is.EqualTo("2"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_ExactMatchWithoutIdentifier_ReturnsMatchingTeam()
+ {
+ // Arrange
+ var clipboardText = "Flight";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_ClipboardFormatWithStudentList_ExtractsTeamName()
+ {
+ // Arrange
+ var clipboardText = "Construction Challenge (2) - John Doe, Jane Smith";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_MultipleTeams_ReturnsAllMatches()
+ {
+ // Arrange
+ var clipboardText = "Flight\r\nRobotics\r\nCoding";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(3));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Coding"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_FuzzyMatch_ReturnsBestMatch()
+ {
+ // Arrange
+ var clipboardText = "Construcion Challange"; // Intentional typos
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_CaseInsensitive_MatchesCorrectly()
+ {
+ // Arrange
+ var clipboardText = "FLIGHT\r\nconstruction challenge (2)";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Construction Challenge"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_SkipsEmptyLines_IgnoresEmptyEntries()
+ {
+ // Arrange
+ var clipboardText = "Flight\r\n\r\nRobotics\r\n \r\nCoding";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(3));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_SkipsMetadataHeaders_IgnoresSpecialMarkers()
+ {
+ // Arrange
+ var clipboardText = "--Unscheduled\r\nFlight\r\n--Another Header\r\nRobotics\r\nUnscheduled";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_NoMatches_ReturnsEmptyList()
+ {
+ // Arrange
+ var clipboardText = "NonExistent Team Name";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_BelowThreshold_ReturnsEmptyList()
+ {
+ // Arrange
+ var clipboardText = "XYZ"; // Very different from any team name
+ var highThreshold = 95; // Very high threshold
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, highThreshold);
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_CustomThreshold_RespectsThreshold()
+ {
+ // Arrange
+ var clipboardText = "Construction Challeng"; // Close match, should work with lower threshold
+ var lowThreshold = 70;
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams, lowThreshold);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_EmptyClipboard_ReturnsEmptyList()
+ {
+ // Arrange
+ var clipboardText = "";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_NullClipboard_ReturnsEmptyList()
+ {
+ // Arrange
+ string? clipboardText = null;
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText!, _availableTeams);
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_EmptyTeamsList_ReturnsEmptyList()
+ {
+ // Arrange
+ var clipboardText = "Flight";
+ var emptyTeams = new List();
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, emptyTeams);
+
+ // Assert
+ Assert.That(result, Is.Empty);
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_DuplicateTeamNames_ReturnsSingleInstance()
+ {
+ // Arrange
+ var clipboardText = "Flight\r\nFlight\r\nFlight";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0].Event.Name, Is.EqualTo("Flight"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_MultipleLinesWithDifferentFormats_HandlesAllFormats()
+ {
+ // Arrange
+ var clipboardText = "Flight\r\nRobotics - Student List\r\nMedical Technology (1) - More Students";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(3));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Medical Technology"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_WhitespaceAroundTeamName_TrimsCorrectly()
+ {
+ // Arrange
+ var clipboardText = " Flight \r\n Robotics ";
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(2));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Flight"));
+ Assert.That(result.Select(t => t.Event.Name), Contains.Item("Robotics"));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_ReturnsTeamsFromInputCollection_MaintainsReferenceEquality()
+ {
+ // Arrange
+ var clipboardText = "Flight";
+ var inputTeam = _availableTeams.First(t => t.Event.Name == "Flight");
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, _availableTeams);
+
+ // Assert
+ Assert.That(result[0], Is.SameAs(inputTeam));
+ }
+
+ [Test]
+ public void MatchTeamsFromClipboard_BestMatchSelected_ReturnsHighestScoringMatch()
+ {
+ // Arrange
+ // Create teams with similar names
+ var construction1 = EventDefinitionBuilder.Team("Construction Challenge", 2, 4).Build();
+ var construction2 = EventDefinitionBuilder.Team("Construction Challenge Advanced", 2, 4).Build();
+ var teams = new[]
+ {
+ TeamBuilder.Create(construction1).Build(),
+ TeamBuilder.Create(construction2).Build()
+ };
+ var clipboardText = "Construction Challenge"; // Should match the first one exactly
+
+ // Act
+ var result = TeamClipboardMatcher.MatchTeamsFromClipboard(clipboardText, teams);
+
+ // Assert
+ Assert.That(result, Has.Count.EqualTo(1));
+ // The exact match should be selected (higher score)
+ Assert.That(result[0].Event.Name, Is.EqualTo("Construction Challenge"));
+ }
+}
diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor
index 473627c..4b87c5d 100644
--- a/WebApp/Components/Features/MeetingSchedule/Index.razor
+++ b/WebApp/Components/Features/MeetingSchedule/Index.razor
@@ -47,46 +47,75 @@
Time Slots
-
-
-
-
-
-
- Add High Effort
-
+
+
+ Time Slots
+
+
+
+
- Add Regionals
+
- Remove Individual
+
- Remove Low Effort
+
+
+
+
Invert
+
+
+
+ Load from Clipboard
+
+
Reset
-
- Solve
-
-
-
-
+
+
+
+
+
+
+ Solve
+
+
-
@@ -149,6 +178,7 @@
private TeamSchedulerSolution _solution = null!;
private TeamSchedulerOptions _parameters = null!;
bool _isSolving;
+ private bool _isLoadingClipboard = false;
private IEnumerable _scheduledTeams = [];
private IEnumerable _absentStudents = [];
private IEnumerable _possibleAdditions = [];
@@ -195,12 +225,40 @@
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 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();
@@ -512,6 +570,63 @@
// 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 ex)
+ {
+ 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();
+ }
+ }
}
\ No newline at end of file
diff --git a/WebApp/Components/Shared/Components/AddRemoveFilter.razor b/WebApp/Components/Shared/Components/AddRemoveFilter.razor
new file mode 100644
index 0000000..0bb43c6
--- /dev/null
+++ b/WebApp/Components/Shared/Components/AddRemoveFilter.razor
@@ -0,0 +1,54 @@
+@namespace WebApp.Components.Shared.Components
+
+
+
+
+
+
+ @Label
+
+
+
+
+
+
+@code {
+ [Parameter]
+ public required string Label { get; set; }
+
+ [Parameter]
+ public EventCallback OnAdd { get; set; }
+
+ [Parameter]
+ public EventCallback OnRemove { get; set; }
+
+ [Parameter]
+ public string? AddTooltip { get; set; }
+
+ [Parameter]
+ public string? RemoveTooltip { get; set; }
+
+ private async Task HandleAdd()
+ {
+ if (OnAdd.HasDelegate)
+ {
+ await OnAdd.InvokeAsync();
+ }
+ }
+
+ private async Task HandleRemove()
+ {
+ if (OnRemove.HasDelegate)
+ {
+ await OnRemove.InvokeAsync();
+ }
+ }
+}