Compare commits

...
28 Commits
Author SHA1 Message Date
poprhythm 432caa0fe8 Refactor StateScheduleHandout component to improve event occurrence filtering and display 2026-04-08 22:11:31 -04:00
poprhythm 8d7c6b103c Enhance EventOccurrenceDetailsDialog and StateScheduleHandout components for improved event display and styling 2026-04-08 15:10:45 -04:00
poprhythm 5d2d019e87 Refactor Printout component to enhance student ordering and include captain information 2026-04-08 14:22:47 -04:00
poprhythm ea1bb70740 Update Printout component to improve student ordering logic 2026-04-07 14:01:51 -04:00
poprhythm 4401e4a3ec Refactor StateScheduleHandout component for improved print layout and styling 2026-04-07 08:39:14 -04:00
poprhythm a9036d5d04 Add state schedule handout feature and configuration options
This commit introduces a new StateScheduleHandout component for generating printable schedules for students, including a combined master list of events. It adds configuration options in appsettings.json for state abbreviations and special event filters, enhancing the scheduling functionality. The Program.cs file is updated to register the new StateScheduleHandoutOptions, and the Calendar and Teams components are modified to include links to the new handout feature. Additionally, utility methods for filtering event occurrences are implemented to support the new functionality, improving the overall user experience in managing state schedules.
2026-04-06 23:33:57 -04:00
poprhythm 4dcd9e5aab Add presubmission filter functionality to MeetingSchedule component
This commit introduces a new filter for presubmission teams in the MeetingSchedule component. It adds an AddRemoveFilter for managing teams whose events require presubmission, allowing users to add or remove these teams from the scheduled list. The corresponding methods for adding and removing presubmission teams are implemented, enhancing the component's functionality and improving user experience in managing event teams.
2026-03-10 11:55:06 -04:00
poprhythm 336bbb1dec Refactor Event components for improved data handling and null safety
This commit updates the Edit.razor and EventAttributes.razor components to enhance data handling and prevent potential null reference exceptions. In Edit.razor, comments are added to clarify the tracking of related careers, ensuring that the same instances are used to avoid tracking conflicts. The EventAttributes.razor component is modified to handle null EventDefinition gracefully, preventing errors when the parameter is not set. Additionally, the EventDefinitionService is updated to ensure existing careers are retrieved with tracking, improving data consistency. These changes collectively enhance the robustness and reliability of the event management features.
2026-03-10 11:12:28 -04:00
poprhythm 675f04afec Add CalendarService and integrate calendar functionality into components
This commit introduces the CalendarService, which provides methods for retrieving all calendar items and upcoming events. The service is integrated into various components, including the Calendar and Home pages, enhancing the calendar functionality by allowing users to view upcoming events and meeting histories. Additionally, the Index.razor component is updated to parse query parameters for date selection, improving user experience. The layout of the MeetingHistoryDetailDialog and SaveMeetingHistoryDialog components is also refined for better organization and responsiveness. These changes collectively enhance the calendar feature and improve the overall user interface in managing events and meetings.
2026-01-27 22:38:52 -05:00
poprhythm 84eaf338a9 Enhance TeamMeetingHistoryDialog to display team members' attendance
This commit updates the TeamMeetingHistoryDialog component to show the attendance status of team members during meetings. It replaces the previous status display with a list of team members, indicating their presence or absence using MudBlazor chips. Additionally, the TeamMeetingHistoryService is modified to include student data for accurate attendance tracking. These changes improve the clarity and usability of the meeting history dialog, enhancing the overall user experience in managing team meetings.
2026-01-27 20:10:40 -05:00
poprhythm 15d7edec8f Add Team Meeting History functionality with dialog and badge components
This commit introduces a new feature to display meeting history for teams. It adds a `TeamMeetingHistoryDialog` component that shows the meeting history in a dialog format, including a loading state and error handling. Additionally, a `TeamMeetingHistoryBadge` component is created to display the count of meetings for each team, enhancing the user interface with tooltips for better interaction. The `Details` and `Index` components are updated to integrate these new features, allowing users to view meeting history directly from the team details and index pages. These changes improve the overall functionality and user experience in managing team meetings.
2026-01-26 23:31:59 -05:00
poprhythm 46836fde2e Refactor Calendar component to adjust default meeting times and improve event item styling
This commit updates the Calendar component by changing the default start time for meetings to 9:00 AM and the end time to 5:01 PM, ensuring better alignment with typical work hours. Additionally, the styling of calendar event items is refined by removing fixed width and height properties, allowing for more flexible rendering. These changes enhance the usability and visual presentation of the calendar feature, contributing to an improved user experience.
2026-01-26 22:15:03 -05:00
poprhythm d0ce71397b Update Calendar component to use two-way binding for view and current day properties
This commit modifies the Calendar component to implement two-way binding for the View and CurrentDay properties of the MudCalendar. By using `@bind-View` and `@bind-CurrentDay`, the component enhances data synchronization between the UI and underlying state, improving user interaction and responsiveness. This change aligns with ongoing efforts to refine the user experience in the calendar feature.
2026-01-26 21:47:06 -05:00
poprhythm 840a8edbf1 Enhance Calendar component with improved event tooltip functionality and styling
This commit updates the Calendar component to include a new method for generating tooltips for both events and meetings, enhancing user interaction by providing contextual information. Additionally, CSS styles are introduced for calendar event items, improving their appearance and hover effects. These changes contribute to a more informative and visually appealing calendar experience, aligning with the ongoing efforts to refine the user interface.
2026-01-26 21:31:18 -05:00
poprhythm 680f61241a Refactor Calendar component to simplify view selection UI
This commit removes the Month and Day button group from the Calendar component, streamlining the user interface. The change enhances the overall layout and focuses on the MudCalendar display, improving user experience by reducing visual clutter. This update aligns with the ongoing efforts to refine component interactions and maintain a clean UI.
2026-01-25 21:59:43 -05:00
poprhythm 083e81aa25 Refactor PageNoteButton component to include popover for note content display
This commit enhances the PageNoteButton component by wrapping the MudButton in a div that manages mouse events to control the visibility of a MudPopover. The popover displays detailed note content when the button is hovered over, improving user interaction and accessibility of page notes. Additionally, the component's state management is updated to handle the new popover functionality, ensuring a seamless user experience.
2026-01-25 21:59:31 -05:00
poprhythm bba0f5f618 Fix formatting issue in Team entity and improve ToString method for null safety
This commit addresses a formatting issue in the Team.cs file by removing an invisible character at the beginning of the file. Additionally, the ToString method is updated to safely handle null values for the Event property, ensuring that it returns a default message when no event is associated with the team. These changes enhance code readability and prevent potential null reference exceptions.
2026-01-25 21:59:16 -05:00
poprhythm 9ab241ed77 Update MeetingSchedule and related components for improved UI and data handling
This commit modifies the MeetingSchedule component to change the page header title to "Meeting Scheduler Planner" for better clarity. Additionally, it refines the MeetingHistoryDetailDialog by removing unnecessary UI elements to streamline the display of meeting history details. In the Printout component, null checks are added to ensure safe access to LevelOfEffort properties, enhancing data integrity. The Home component's title is also updated to "Meeting Schedule Planner" for consistency across the application. These changes collectively enhance the user experience and maintain data accuracy in the meeting scheduling feature.
2026-01-25 21:40:04 -05:00
poprhythm 804e12ca22 Refactor History component in MeetingSchedule to enhance table structure and styling
This commit updates the History component by replacing the MudTable with a custom HTML table structure for improved styling and responsiveness. The table now features enhanced column definitions, including vertical text orientation for headers and improved row styling for better readability. Additionally, CSS styles are introduced to manage table appearance, ensuring a more user-friendly interface for displaying meeting histories. This enhancement contributes to a better overall user experience in the meeting schedule feature.
2026-01-25 21:16:26 -05:00
poprhythm cc6e0d71a7 Integrate TeamMeetingHistoryService into Calendar component for enhanced meeting management
This commit updates the Calendar component to include the ITeamMeetingHistoryService, allowing for the loading and display of meeting histories alongside event occurrences. The calendar items are now wrapped in CalendarItemWrapper to accommodate both events and meetings. Additionally, error handling and logging are implemented to ensure robustness during the loading process. This enhancement improves the overall functionality and user experience of the calendar feature by providing a comprehensive view of both events and meetings.
2026-01-25 19:07:43 -05:00
poprhythm a503655f97 Enhance MeetingSchedule and MeetingHistoryDetailDialog with load into planner functionality
This commit adds a new feature to the MeetingSchedule and MeetingHistoryDetailDialog components, allowing users to load meeting details into the planner. The LoadMeetingIntoPlanner method is implemented in both components, which retrieves team and student data, calculates absent students, and saves the state to localStorage before navigating to the planner. Additionally, a confirmation dialog is introduced in the SaveMeetingHistoryDialog to handle overwriting existing meeting histories, improving user experience and data management in the meeting scheduling feature.
2026-01-25 18:58:02 -05:00
poprhythm 6e2834f2be Refactor MeetingSchedule component to enhance time slot selection UI
This commit updates the MeetingSchedule component by replacing the numeric field for time slot selection with a more user-friendly interface using MudIconButton components for incrementing and decrementing time slots. The new design improves usability by providing clear visual controls for adjusting the number of time slots, while maintaining the existing functionality of tracking time slot counts. This enhancement contributes to a better user experience in managing meeting schedules.
2026-01-21 21:36:39 -05:00
poprhythm 48861eb6a6 Add TeamClipboardMatcher utility and corresponding tests for fuzzy team name matching
This commit introduces the TeamClipboardMatcher class, which provides functionality to match team names from clipboard text using fuzzy matching techniques. The class includes methods for extracting team names and finding the best match based on a specified threshold. Additionally, comprehensive unit tests are added in TeamClipboardMatcher_Tests to validate various matching scenarios, including exact matches, fuzzy matches, and handling of different clipboard formats. This enhancement improves the application's ability to efficiently match teams from user input.
2026-01-20 22:49:09 -05:00
poprhythm 455be30821 Enhance MeetingHistoryDetailDialog and SaveMeetingHistoryDialog with navigation and editing features
This commit introduces navigation buttons for previous and next meeting histories in the MeetingHistoryDetailDialog, improving user experience by allowing easy access to related meetings. Additionally, the SaveMeetingHistoryDialog is updated to support editing existing meeting histories, with logic to load existing data and prevent duplicate entries for the same date. The integration of TeamToggleSelector and StudentToggleSelector components streamlines team and student selection, enhancing the overall functionality of the meeting history management feature.
2026-01-20 21:55:38 -05:00
poprhythm ddb743847d 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.
2026-01-19 23:02:55 -05:00
poprhythm 649a0061cf Enhance team overlap calculation by excluding absent students
This commit updates the GetTeamsWithoutExcludedStudents method to filter out both excluded and absent students during overlap calculations. A new HashSet is introduced to store absent student IDs, improving the accuracy of team compositions. This change ensures that the team formation logic accounts for student availability, enhancing the overall functionality of the meeting schedule feature.
2026-01-19 22:42:44 -05:00
poprhythm 6bc4c2e7f2 Add TeamMeetingHistory entity, service, and UI components for meeting history management
This commit introduces the TeamMeetingHistory entity, including its configuration and database migrations. A new ITeamMeetingHistoryService interface and its implementation, TeamMeetingHistoryService, are added to handle CRUD operations for meeting histories. Additionally, UI components such as History.razor, MeetingHistoryDetailDialog, and SaveMeetingHistoryDialog are created to facilitate viewing and saving meeting histories. The integration of INoteNamingService enhances note management for meeting records, improving overall functionality and user experience in the application.
2026-01-19 22:02:59 -05:00
poprhythm 9ed9c93540 Refactor InteractiveChip component to improve touch event handling
This commit updates the InteractiveChip component by refining touch event handling for better user interaction on touch devices. The `HandleClick` method has been replaced with `HandleTouchStart`, which now toggles the `_isTouched` state and prevents event propagation. This change enhances the responsiveness of the component, ensuring a smoother experience for users on mobile devices while maintaining existing hover functionality for desktop users.
2026-01-19 12:09:57 -05:00
61 changed files with 5666 additions and 700 deletions
@@ -0,0 +1,58 @@
using Core.Entities;
namespace Core.Calculation;
/// <summary>
/// Helper methods for calculating overlaps in team scheduling solutions.
/// </summary>
public static class OverlapCalculationHelper
{
/// <summary>
/// Creates teams with excluded students and absent students filtered out for overlap calculation.
/// </summary>
/// <param name="teams">The teams to filter</param>
/// <param name="timeSlotIndex">The time slot index for exclusion lookups</param>
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
/// <param name="absentStudentIds">Set of absent student IDs to exclude from overlap calculations</param>
/// <returns>Teams with excluded and absent students removed</returns>
public static Team[] GetTeamsWithoutExcludedStudents(
Team[] teams,
int timeSlotIndex,
Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> excludedStudents,
HashSet<int> 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;
}
}
@@ -0,0 +1,89 @@
using Core.Entities;
namespace Core.Calculation;
/// <summary>
/// Post-processing utilities for team scheduler solutions.
/// </summary>
public static class TeamSchedulerPostProcessor
{
/// <summary>
/// Extends teams to adjacent time slots (both forward and backward).
/// Teams marked as extended will appear in consecutive time slots.
/// </summary>
/// <param name="solution">The scheduler solution to modify</param>
/// <param name="extendedTeams">Teams that should be extended to adjacent slots</param>
/// <param name="allStudents">All available students for overlap calculations</param>
/// <param name="getTeamsWithoutExcludedStudents">Function to filter teams for overlap calculation</param>
public static void ExtendTeamsInSolution(
TeamSchedulerSolution solution,
IEnumerable<Team> extendedTeams,
Student[] allStudents,
Func<Team[], int, Team[]> 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);
}
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Core.Models;
namespace Core.Entities;
@@ -61,6 +61,6 @@ public class Team
public override string ToString()
{
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
return $"{Event?.Name ?? "(no event)"} {(Identifier != null ? $"({Identifier})" : "")}";
}
}
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Core.Entities;
public class TeamMeetingHistory
{
public int Id { get; set; }
[Required]
[Display(Name = "Meeting Date")]
public DateTime MeetingDate { get; set; }
// Navigation properties
public List<Team> Teams { get; set; } = [];
public List<Student> Students { get; set; } = [];
}
+32
View File
@@ -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 };
}
/// <summary>
/// Creates PartialTeam instances from teams with excluded students.
/// Aggregates exclusions across all time slots for each team.
/// </summary>
/// <param name="teams">The teams to process</param>
/// <param name="excludedStudents">Dictionary of excluded students: key is (teamId, timeSlotIndex, studentId), value is true if excluded</param>
/// <returns>Array of teams, with PartialTeam instances for teams with exclusions</returns>
public static Team[] CreatePartialTeamsFromExclusions(
IEnumerable<Team> 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();
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace Core.Services;
/// <summary>
/// Service for managing note naming conventions throughout the system.
/// Centralizes the logic for generating note titles for meeting notes and page notes.
/// </summary>
public interface INoteNamingService
{
/// <summary>
/// Gets the title for a meeting note based on the meeting date.
/// Format: "#Meeting Notes MM/dd/yyyy"
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The formatted meeting note title</returns>
string GetMeetingNoteTitle(DateTime meetingDate);
/// <summary>
/// Gets the title for a page note based on the page identifier.
/// Format: "#{pageIdentifier}"
/// </summary>
/// <param name="pageIdentifier">The page identifier (e.g., "students", "teams")</param>
/// <returns>The formatted page note title</returns>
string GetPageNoteTitle(string pageIdentifier);
/// <summary>
/// Checks if a note title represents a page note (starts with "#").
/// </summary>
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a page note, false otherwise</returns>
bool IsPageNote(string noteTitle);
/// <summary>
/// Checks if a note title represents a meeting note (starts with "#Meeting Notes").
/// </summary>
/// <param name="noteTitle">The note title to check</param>
/// <returns>True if the note is a meeting note, false otherwise</returns>
bool IsMeetingNote(string noteTitle);
}
+50
View File
@@ -0,0 +1,50 @@
namespace Core.Services;
/// <summary>
/// Implementation of INoteNamingService that provides note naming conventions.
/// Uses "#" as the prefix for page notes and meeting notes.
/// </summary>
public class NoteNamingService : INoteNamingService
{
private const string PageNotePrefix = "#";
private const string MeetingNotePrefix = "#Meeting Notes";
/// <inheritdoc/>
public string GetMeetingNoteTitle(DateTime meetingDate)
{
return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}";
}
/// <inheritdoc/>
public string GetPageNoteTitle(string pageIdentifier)
{
if (string.IsNullOrWhiteSpace(pageIdentifier))
{
throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier));
}
return $"{PageNotePrefix}{pageIdentifier}";
}
/// <inheritdoc/>
public bool IsPageNote(string noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
{
return false;
}
return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal);
}
/// <inheritdoc/>
public bool IsMeetingNote(string noteTitle)
{
if (string.IsNullOrWhiteSpace(noteTitle))
{
return false;
}
return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal);
}
}
+91
View File
@@ -0,0 +1,91 @@
using Core.Entities;
using FuzzySharp;
namespace Core.Utility;
/// <summary>
/// Utility class for matching team names from clipboard text using fuzzy matching.
/// </summary>
public static class TeamClipboardMatcher
{
/// <summary>
/// Matches team names from clipboard text against available teams using fuzzy matching.
/// </summary>
/// <param name="clipboardText">The text content from the clipboard.</param>
/// <param name="availableTeams">The collection of available teams to match against.</param>
/// <param name="matchThreshold">The minimum fuzzy match score threshold (0-100). Default is 85.</param>
/// <returns>A list of teams that match the clipboard text, ordered by match quality.</returns>
public static List<Team> MatchTeamsFromClipboard(
string clipboardText,
IEnumerable<Team> availableTeams,
int matchThreshold = 85)
{
var matchedTeams = new List<Team>();
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<Team> 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;
}
}
+50
View File
@@ -0,0 +1,50 @@
using Core.Entities;
namespace Core.Utility;
/// <summary>
/// Extension methods for filtering teams based on various criteria.
/// </summary>
public static class TeamFilterExtensions
{
/// <summary>
/// Adds teams that are regional events to the collection.
/// </summary>
public static IEnumerable<Team> AddRegionals(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
{
return allTeams.Where(e => e.Event.RegionalEvent).Concat(currentTeams).Distinct();
}
/// <summary>
/// Adds teams with high level of effort (>= 3) to the collection.
/// </summary>
public static IEnumerable<Team> AddHighLevelOfEffort(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
{
return allTeams.Where(e => e.Event.LevelOfEffort >= 3).Concat(currentTeams).Distinct();
}
/// <summary>
/// Removes individual event teams from the collection.
/// </summary>
public static IEnumerable<Team> RemoveIndividual(this IEnumerable<Team> teams)
{
return teams.Where(t => t.Event.EventFormat != EventFormat.Individual);
}
/// <summary>
/// Removes teams with low level of effort (<= 1) from the collection.
/// </summary>
public static IEnumerable<Team> RemoveLowLevelOfEffort(this IEnumerable<Team> teams)
{
return teams.Where(t => t.Event.LevelOfEffort > 1);
}
/// <summary>
/// Inverts the selection - returns all teams not in the current selection.
/// </summary>
public static IEnumerable<Team> Invert(this IEnumerable<Team> currentTeams, IEnumerable<Team> allTeams)
{
var currentTeamIds = currentTeams.Select(t => t.Id).ToHashSet();
return allTeams.Where(t => !currentTeamIds.Contains(t.Id));
}
}
+1
View File
@@ -14,6 +14,7 @@ namespace Data
public DbSet<Career> Careers { get; set; }
public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; }
public DbSet<TeamMeetingHistory> TeamMeetingHistories { get; set; }
public AppDbContext()
{
@@ -0,0 +1,29 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration<TeamMeetingHistory>
{
public void Configure(EntityTypeBuilder<TeamMeetingHistory> builder)
{
builder.HasKey(tmh => tmh.Id);
// Indexes
builder.HasIndex(tmh => tmh.MeetingDate);
// Constraints
builder.Property(tmh => tmh.MeetingDate)
.IsRequired();
builder.HasMany(tmh => tmh.Teams)
.WithMany()
.UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams"));
builder.HasMany(tmh => tmh.Students)
.WithMany()
.UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents"));
}
}
}
@@ -0,0 +1,567 @@
// <auto-generated />
using System;
using Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260120024048_AddTeamMeetingHistory")]
partial class AddTeamMeetingHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("RelatedCareersId")
.HasColumnType("INTEGER");
b.HasKey("EventDefinitionId", "RelatedCareersId");
b.HasIndex("RelatedCareersId");
b.ToTable("EventDefinitionCareers", (string)null);
});
modelBuilder.Entity("Core.Entities.Career", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Careers");
});
modelBuilder.Entity("Core.Entities.EventDefinition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountRegionals")
.HasColumnType("INTEGER");
b.Property<int>("ChapterEligibilityCountState")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Documentation")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Eligibility")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("EventFormat")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("LevelOfEffort")
.HasColumnType("INTEGER");
b.Property<int>("MaxTeamSize")
.HasColumnType("INTEGER");
b.Property<int>("MinTeamSize")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("OnSiteActivity")
.HasColumnType("INTEGER");
b.Property<bool>("Presubmission")
.HasColumnType("INTEGER");
b.Property<string>("SemifinalistActivity")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ShortName")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Theme")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventFormat");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Events");
});
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Date")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<int?>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<string>("Location")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SpecialEventType")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<string>("Time")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventDefinitionId");
b.HasIndex("SpecialEventType");
b.HasIndex("StartTime");
b.ToTable("EventOccurrences");
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.Property<bool>("IsPinned")
.HasColumnType("INTEGER");
b.Property<string>("LastModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("IsDeleted");
b.HasIndex("IsPinned");
b.HasIndex("Title");
b.ToTable("Notes");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Content")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("NoteId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ModifiedAt");
b.HasIndex("NoteId");
b.HasIndex("NoteId", "ModifiedAt");
b.ToTable("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Grade")
.HasColumnType("INTEGER");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("NationalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("OfficerRole")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("RegionalId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("StateId")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("TsaYear")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Email");
b.HasIndex("Grade");
b.HasIndex("FirstName", "LastName");
b.ToTable("Students");
});
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{
b.Property<int>("StudentId")
.HasColumnType("INTEGER");
b.Property<int>("EventDefinitionId")
.HasColumnType("INTEGER");
b.Property<int>("Rank")
.HasColumnType("INTEGER");
b.HasKey("StudentId", "EventDefinitionId");
b.HasIndex("EventDefinitionId");
b.HasIndex("Rank");
b.HasIndex("StudentId");
b.ToTable("StudentEventRanking");
});
modelBuilder.Entity("Core.Entities.Team", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CaptainId")
.HasColumnType("INTEGER");
b.Property<int>("EventId")
.HasColumnType("INTEGER");
b.Property<string>("Identifier")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CaptainId");
b.HasIndex("EventId");
b.HasIndex("EventId", "Identifier");
b.ToTable("Teams");
});
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("MeetingDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MeetingDate");
b.ToTable("TeamMeetingHistories");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamStudents", (string)null);
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamMeetingHistoryId");
b.HasIndex("TeamMeetingHistoryId");
b.ToTable("TeamMeetingHistoryStudents", (string)null);
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("TeamMeetingHistoryId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamMeetingHistoryTeams", (string)null);
});
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.HasOne("Core.Entities.EventDefinition", null)
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Career", null)
.WithMany()
.HasForeignKey("RelatedCareersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Core.Entities.EventOccurrence", b =>
{
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("EventDefinition");
});
modelBuilder.Entity("Core.Entities.NoteHistory", b =>
{
b.HasOne("Core.Entities.Note", "Note")
.WithMany("NoteHistories")
.HasForeignKey("NoteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Note");
});
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
{
b.HasOne("Core.Entities.EventDefinition", "EventDefinition")
.WithMany()
.HasForeignKey("EventDefinitionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Student", "Student")
.WithMany("EventRankings")
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("EventDefinition");
b.Navigation("Student");
});
modelBuilder.Entity("Core.Entities.Team", b =>
{
b.HasOne("Core.Entities.Student", "Captain")
.WithMany()
.HasForeignKey("CaptainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Core.Entities.EventDefinition", "Event")
.WithMany()
.HasForeignKey("EventId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Captain");
b.Navigation("Event");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.HasOne("Core.Entities.Student", null)
.WithMany()
.HasForeignKey("StudentsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Team", null)
.WithMany()
.HasForeignKey("TeamsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.Student", null)
.WithMany()
.HasForeignKey("StudentsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Team", null)
.WithMany()
.HasForeignKey("TeamsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Navigation("EventRankings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
public partial class AddTeamMeetingHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMeetingHistories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MeetingDate = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TeamMeetingHistoryStudents",
columns: table => new
{
StudentsId = table.Column<int>(type: "INTEGER", nullable: false),
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId });
table.ForeignKey(
name: "FK_TeamMeetingHistoryStudents_Students_StudentsId",
column: x => x.StudentsId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId",
column: x => x.TeamMeetingHistoryId,
principalTable: "TeamMeetingHistories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TeamMeetingHistoryTeams",
columns: table => new
{
TeamMeetingHistoryId = table.Column<int>(type: "INTEGER", nullable: false),
TeamsId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId });
table.ForeignKey(
name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId",
column: x => x.TeamMeetingHistoryId,
principalTable: "TeamMeetingHistories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId",
column: x => x.TeamsId,
principalTable: "Teams",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistories_MeetingDate",
table: "TeamMeetingHistories",
column: "MeetingDate");
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId",
table: "TeamMeetingHistoryStudents",
column: "TeamMeetingHistoryId");
migrationBuilder.CreateIndex(
name: "IX_TeamMeetingHistoryTeams_TeamsId",
table: "TeamMeetingHistoryTeams",
column: "TeamsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMeetingHistoryStudents");
migrationBuilder.DropTable(
name: "TeamMeetingHistoryTeams");
migrationBuilder.DropTable(
name: "TeamMeetingHistories");
}
}
}
@@ -370,6 +370,22 @@ namespace Data.Migrations
b.ToTable("Teams");
});
modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("MeetingDate")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MeetingDate");
b.ToTable("TeamMeetingHistories");
});
modelBuilder.Entity("StudentTeam", b =>
{
b.Property<int>("StudentsId")
@@ -385,6 +401,36 @@ namespace Data.Migrations
b.ToTable("TeamStudents", (string)null);
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.Property<int>("StudentsId")
.HasColumnType("INTEGER");
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.HasKey("StudentsId", "TeamMeetingHistoryId");
b.HasIndex("TeamMeetingHistoryId");
b.ToTable("TeamMeetingHistoryStudents", (string)null);
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.Property<int>("TeamMeetingHistoryId")
.HasColumnType("INTEGER");
b.Property<int>("TeamsId")
.HasColumnType("INTEGER");
b.HasKey("TeamMeetingHistoryId", "TeamsId");
b.HasIndex("TeamsId");
b.ToTable("TeamMeetingHistoryTeams", (string)null);
});
modelBuilder.Entity("CareerEventDefinition", b =>
{
b.HasOne("Core.Entities.EventDefinition", null)
@@ -473,6 +519,36 @@ namespace Data.Migrations
.IsRequired();
});
modelBuilder.Entity("StudentTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.Student", null)
.WithMany()
.HasForeignKey("StudentsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeamTeamMeetingHistory", b =>
{
b.HasOne("Core.Entities.TeamMeetingHistory", null)
.WithMany()
.HasForeignKey("TeamMeetingHistoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Core.Entities.Team", null)
.WithMany()
.HasForeignKey("TeamsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
+313
View File
@@ -0,0 +1,313 @@
using Core.Entities;
using Core.Utility;
using Tests.Builders;
namespace Tests.Utility;
[TestFixture]
public class TeamClipboardMatcher_Tests
{
private List<Team> _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<Team>();
// 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"));
}
}
@@ -1 +1,72 @@
@namespace WebApp.Components.Features.Calendar
@using Core.Entities
<MudDialog>
<DialogContent>
@if (EventOccurrence == null)
{
<MudAlert Severity="Severity.Warning">
Event details are unavailable.
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudText Typo="Typo.h6">
@(EventDefinition?.Name ?? EventOccurrence.Name)
</MudText>
<MudDivider />
<MudText Typo="Typo.body1">
<strong>Occurrence:</strong> @EventOccurrence.Name
</MudText>
<MudText Typo="Typo.body1">
<strong>Start:</strong> @EventOccurrence.StartTime.ToString("f")
</MudText>
@if (EventOccurrence.EndTime != null)
{
<MudText Typo="Typo.body1">
<strong>End:</strong> @EventOccurrence.EndTime.Value.ToString("f")
</MudText>
}
@if (!string.IsNullOrWhiteSpace(EventOccurrence.Location))
{
<MudText Typo="Typo.body1">
<strong>Location:</strong> @EventOccurrence.Location
</MudText>
}
@if (StudentFirstNames.Any())
{
<MudText Typo="Typo.body1">
<strong>Students:</strong> @string.Join(", ", StudentFirstNames)
</MudText>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
public IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public EventOccurrence? EventOccurrence { get; set; }
[Parameter]
public EventDefinition? EventDefinition { get; set; }
[Parameter]
public List<string> StudentFirstNames { get; set; } = [];
private void Close()
{
MudDialog.Close();
}
}
+77 -124
View File
@@ -5,8 +5,7 @@
@using Heron.MudCalendar
@using Microsoft.Extensions.Logging
@using WebApp.Authentication
@using Core.Utility
@inject IEventOccurrenceService EventOccurrenceService
@inject ICalendarService CalendarService
@inject ILogger<Index> Logger
@inject IDialogService DialogService
@@ -15,6 +14,9 @@
<MudTooltip Text="Import">
<MudButton StartIcon="@Icons.Material.Filled.ImportExport" Href="calendar/event-occurrences/import" Variant="Variant.Filled" Color="Color.Primary">Import</MudButton>
</MudTooltip>
<MudTooltip Text="Schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">Schedule handout</MudButton>
</MudTooltip>
<AuthorizeView Roles="@AuthRoles.Administrator">
<MudTooltip Text="Admin">
<MudButton StartIcon="@Icons.Material.Filled.AdminPanelSettings" Href="calendar/admin" Variant="Variant.Outlined" Color="Color.Default">Admin</MudButton>
@@ -33,42 +35,33 @@
else
{
<MudStack Spacing="2">
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small">
<MudButton Color="@(_currentView == CalendarView.Month ? Color.Primary : Color.Default)"
OnClick="() => { _currentView = CalendarView.Month; StateHasChanged(); }">
Month
</MudButton>
<MudButton Color="@(_currentView == CalendarView.Day ? Color.Primary : Color.Default)"
OnClick="() => { _currentView = CalendarView.Day; StateHasChanged(); }">
Day
</MudButton>
</MudButtonGroup>
<MudCalendar T="CalendarEventItem"
<MudCalendar T="CalendarItemWrapper"
Items="_calendarItems"
View="_currentView"
CurrentDay="@_calendarDate"
@bind-View="_currentView"
@bind-CurrentDay="_calendarDate"
Class="event-calendar"
ItemClicked="OnItemClicked">
<MonthTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
<div class="d-flex gap-1">
<MudIcon Icon="@Icons.Material.Filled.Circle" Color="Color.Secondary" Size="Size.Small"/>
<div>@context.Text</div>
</div>
@* </MudTooltip> *@
</MonthTemplate>
<WeekTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
<div style="width: 100%; height: 100%;">
<MudTooltip Text="@GetEventTooltip(context)">
<div class="calendar-event-item">
@context.Text
</div>
@* </MudTooltip> *@
</MudTooltip>
</MonthTemplate>
<WeekTemplate>
<MudTooltip Text="@GetEventTooltip(context)">
<div class="calendar-event-item">
@context.Text
</div>
</MudTooltip>
</WeekTemplate>
<DayTemplate>
@* <MudTooltip Text="@GetEventTooltip(context)"> *@
<div>@context.Text</div>
@* </MudTooltip> *@
<MudTooltip Text="@GetEventTooltip(context)">
<div class="calendar-event-item">
@context.Text
</div>
</MudTooltip>
</DayTemplate>
</MudCalendar>
</MudStack>
@@ -76,12 +69,21 @@
</MudPaper>
@code {
private List<CalendarEventItem>? _calendarItems;
private List<CalendarItemWrapper>? _calendarItems;
private DateTime _calendarDate = DateTime.Today;
private CalendarView _currentView = CalendarView.Month;
[SupplyParameterFromQuery]
private string? Date { get; set; }
protected override async Task OnInitializedAsync()
{
// Parse date from query parameter if provided
if (!string.IsNullOrEmpty(Date) && DateTime.TryParse(Date, out var parsedDate))
{
_calendarDate = parsedDate.Date;
}
await LoadCalendarEvents();
}
@@ -90,60 +92,8 @@
try
{
Logger.LogInformation("Loading calendar events");
var occurrences = await EventOccurrenceService.GetEventOccurrencesAsync();
var eventOccurrences = occurrences as EventOccurrence[] ?? occurrences.ToArray();
Logger.LogDebug("Received {Count} occurrences from service", eventOccurrences.Count());
// Get all unique event definition IDs that have occurrences
var eventDefinitionIds = eventOccurrences
.Where(occ => occ?.EventDefinition?.Id != null)
.Select(occ => occ!.EventDefinition!.Id)
.Distinct()
.ToList();
// Load teams for all event definitions
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
List<CalendarEventItem> items = [];
foreach (var occ in eventOccurrences)
{
try
{
if (string.IsNullOrEmpty(occ.Name))
{
Logger.LogWarning("Occurrence with Id={Id} has null or empty Name", occ.Id);
}
// Get student first names for this event definition
var studentFirstNames = occ.EventDefinition != null && teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
? TeamStudentNameFormatter.FormatStudentListForEvent(
occ.EventDefinition,
teams,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
})
: [];
var calendarItem = new CalendarEventItem(occ, studentFirstNames);
items.Add(calendarItem);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error creating CalendarEventItem for occurrence Id={Id}, Name={Name}",
occ?.Id, occ?.Name);
// Continue processing other items
}
}
_calendarItems = items;
Logger.LogInformation("Created {Count} calendar items from {OccurrenceCount} occurrences",
_calendarItems.Count, eventOccurrences.Count());
// Find the next date with events
_calendarDate = GetNextDateWithEvents();
_calendarItems = await CalendarService.GetAllCalendarItemsAsync();
Logger.LogInformation("Loaded {Count} calendar items", _calendarItems.Count);
}
catch (Exception ex)
{
@@ -157,50 +107,18 @@
}
private DateTime GetNextDateWithEvents()
{
try
{
if (_calendarItems == null || !_calendarItems.Any())
{
Logger.LogDebug("No calendar items available, returning today's date");
return DateTime.Today;
}
var today = DateTime.Today;
var nextEvent = _calendarItems
.Where(item =>
private string GetEventTooltip(CalendarItemWrapper wrapper)
{
try
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
{
return item.Start.Date >= today;
return GetEventTooltip(wrapper.EventItem);
}
catch (Exception ex)
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
{
Logger.LogWarning(ex, "Error checking item date, skipping item");
return false;
}
})
.OrderBy(item => item.Start)
.FirstOrDefault();
if (nextEvent != null)
{
return nextEvent.Start.Date;
}
// Fallback to first event if no future events
var firstEvent = _calendarItems
.OrderBy(item => item.Start)
.FirstOrDefault();
return firstEvent != null ? firstEvent.Start.Date : DateTime.Today;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error in GetNextDateWithEvents");
return DateTime.Today;
return GetMeetingTooltip(wrapper.MeetingItem);
}
return wrapper.Text;
}
private string GetEventTooltip(CalendarEventItem item)
@@ -235,12 +153,27 @@
return string.Join("\n", parts);
}
private async Task OnItemClicked(CalendarEventItem calendarEventItem)
private string GetMeetingTooltip(CalendarMeetingItem item)
{
if (item.MeetingHistoryData == null)
return "Team Meeting";
return $"Team Meeting\nDate: {item.MeetingHistoryData.MeetingDate:g}";
}
private async Task OnItemClicked(CalendarItemWrapper wrapper)
{
if (_calendarItems == null)
return;
await ShowEventDetails(calendarEventItem);
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
{
await ShowEventDetails(wrapper.EventItem);
}
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
{
await ShowMeetingDetails(wrapper.MeetingItem);
}
}
private async Task ShowEventDetails(CalendarEventItem item)
@@ -264,5 +197,25 @@
await DialogService.ShowAsync<EventOccurrenceDetailsDialog>("Event Details", parameters, options);
}
private async Task ShowMeetingDetails(CalendarMeetingItem item)
{
if (item.MeetingHistoryData == null) return;
var parameters = new DialogParameters
{
["MeetingHistoryId"] = item.MeetingHistoryData.Id
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Large,
FullWidth = true
};
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
}
}
@@ -0,0 +1,422 @@
@page "/calendar/state-schedule-handout"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Options
@using System.Globalization
@using WebApp.Models
@using WebApp.Utility
@using WebApp.Services
@inject AppDbContext Context
@inject IConfiguration Configuration
@inject IOptionsMonitor<StateScheduleHandoutOptions> HandoutOptionsMonitor
@inject IEventOccurrenceService EventOccurrenceService
<div class="no-print">
<PageHeader
Title="State schedule handout"
Description="Print per-student schedules and the combined master list."
Icon="@Icons.Material.Filled.Print"
ShowBackButton="true"
BackButtonUrl="/calendar" />
</div>
@if (_students == null || _allOccurrences == null)
{
<p><em>Loading...</em></p>
}
else
{
var opts = HandoutOptionsMonitor.CurrentValue;
<MudContainer Class="state-schedule-handout">
@foreach (var student in _students)
{
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5">
@if (string.IsNullOrWhiteSpace(student.StateId))
{
@student.Name
}
else
{
@($"{student.Name} - {student.StateId}")
}
</MudText>
<MudText Typo="Typo.h6" Class="mb-3">
TSA @_competitionYear @_stateAbbrev State Schedule
</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-1">Events</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-4 nobrk">
<thead>
<tr>
<th>State ID</th>
<th>Event</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
@foreach (var eventRow in GetEventSummaryRows(student))
{
<tr>
<td>@eventRow.StateRegistrationId</td>
<td>@eventRow.EventName</td>
<td>@eventRow.Activity</td>
</tr>
}
</tbody>
</MudSimpleTable>
@{
var scheduleRows = BuildStudentSchedule(student, opts).ToList();
}
<MudText Typo="Typo.subtitle1" Class="mb-1">Schedule</MudText>
@if (scheduleRows.Count == 0)
{
<MudText Class="mud-text-secondary">No schedule entries for imported occurrences.</MudText>
}
else
{
@foreach (var dateGroup in scheduleRows.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var occ in dateGroup.OrderBy(o => o.StartTime))
{
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatEventColumn(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudContainer>
}
<MudContainer Class="pagebreak">
<MudText Typo="Typo.h5" Class="mb-2">Combined schedule</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">Imported occurrences relevant to this chapter.</MudText>
@{
var combinedOccurrences = GetCombinedScheduleOccurrences().ToList();
}
@if (combinedOccurrences.Count == 0)
{
<MudText Class="mud-text-secondary">No relevant event occurrences found for your current team registrations.</MudText>
}
@foreach (var dateGroup in combinedOccurrences.GroupBy(o => o.StartTime.Date))
{
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">@FormatDateHeading(dateGroup.Key)</MudText>
<MudSimpleTable Dense="true" Class="state-schedule-table mb-3">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Location</th>
</tr>
</thead>
<tbody>
@foreach (var tlGroup in dateGroup
.OrderBy(o => o.StartTime)
.GroupBy(o => (FormatTimeDisplay(o), o.Location ?? ""))
.Select(g => g.ToList()))
{
if (tlGroup.Count == 1)
{
var occ = tlGroup[0];
<tr>
<td>@FormatTimeDisplay(occ)</td>
<td>@FormatCombinedScheduleEventCell(occ)</td>
<td>@(occ.Location ?? "")</td>
</tr>
}
else
{
var genericOcc = tlGroup.FirstOrDefault(o => !o.EventDefinitionId.HasValue);
var specificOccs = tlGroup
.Where(o => o.EventDefinitionId.HasValue)
.OrderBy(o => FormatEventColumn(o), StringComparer.OrdinalIgnoreCase)
.ToList();
var rowCount = (genericOcc != null ? 1 : 0) + specificOccs.Count;
var representative = genericOcc ?? specificOccs[0];
if (genericOcc != null)
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td>@FormatCombinedScheduleEventCell(genericOcc)</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs)
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
else
{
<tr>
<td rowspan="@rowCount">@FormatTimeDisplay(representative)</td>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(specificOccs[0])</td>
<td rowspan="@rowCount">@(representative.Location ?? "")</td>
</tr>
@foreach (var sub in specificOccs.Skip(1))
{
<tr>
<td class="combined-sub-event">@FormatCombinedScheduleEventCell(sub)</td>
</tr>
}
}
}
}
</tbody>
</MudSimpleTable>
}
</MudContainer>
</MudContainer>
}
@code {
private Student[]? _students;
private List<EventOccurrence>? _allOccurrences;
private Dictionary<int, List<Team>> _teamsByEventDefinitionId = new();
private string _competitionYear = "";
private string _stateAbbrev = "";
private string? _chapterStateId;
protected override async Task OnInitializedAsync()
{
_competitionYear = Configuration["ChapterSettings:CompetitionYear"] ?? "";
_stateAbbrev = Configuration["ChapterSettings:StateAbbrev"] ?? "ST";
_chapterStateId = Configuration["ChapterSettings:StateId"];
_allOccurrences = await Context.EventOccurrences
.AsNoTracking()
.Include(eo => eo.EventDefinition)
.OrderBy(eo => eo.StartTime)
.ToListAsync();
var eventDefIds = _allOccurrences
.Where(o => o.EventDefinitionId.HasValue)
.Select(o => o.EventDefinitionId!.Value)
.Distinct()
.ToList();
_teamsByEventDefinitionId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefIds);
// Tracking required: Include Teams->Students creates a graph cycle (StudentTeamStudent) that EF disallows with AsNoTracking().
_students = await Context.Students
.Include(s => s.Teams)
.ThenInclude(t => t!.Event)
.Include(s => s.Teams)
.ThenInclude(t => t!.Captain)
.Include(s => s.Teams)
.ThenInclude(t => t!.Students)
.OrderBy(s => s.FirstName)
.ThenBy(s => s.LastName)
.ToArrayAsync();
}
private IEnumerable<EventOccurrence> BuildStudentSchedule(Student student, StateScheduleHandoutOptions opts)
{
var eventIds = student.Teams.Select(t => t.Event.Id).ToHashSet();
var competition = _allOccurrences!
.Where(o => o.EventDefinitionId.HasValue && eventIds.Contains(o.EventDefinitionId.Value))
.Where(o => StateScheduleOccurrenceFilter.IncludeCompetitionOccurrenceForStudent(o, opts));
var special = _allOccurrences!
.Where(o => o.EventDefinitionId == null)
.Where(o => StateScheduleOccurrenceFilter.IncludeSpecialOccurrenceForStudent(o, student, opts));
return competition
.Concat(special)
.OrderBy(o => o.StartTime)
.DistinctBy(o => (o.StartTime, o.Name ?? ""));
}
private IEnumerable<EventOccurrence> GetCombinedScheduleOccurrences()
{
return _allOccurrences!
.Where(o =>
{
// Keep chapter-wide/special schedule rows.
if (!o.EventDefinitionId.HasValue)
return true;
// Keep only competition events where this chapter has registered teams.
return _teamsByEventDefinitionId.TryGetValue(o.EventDefinitionId.Value, out var teams) && teams.Count > 0;
})
.OrderBy(o => o.StartTime);
}
private IEnumerable<EventSummaryRow> GetEventSummaryRows(Student student)
{
foreach (var team in student.Teams.OrderBy(t => t.Event.Name))
{
yield return new EventSummaryRow(
StateRegistrationId: FormatStateRegistrationId(team, student),
EventName: team.Event.Name,
Activity: FormatActivitySummary(team, student));
}
}
/// <summary>
/// Team events: chapter <c>ChapterSettings:StateId</c> + <see cref="Team.Identifier"/> (e.g. 12227-1).
/// Individual events: competitor's <see cref="Student.StateId"/>.
/// </summary>
private string FormatStateRegistrationId(Team team, Student student)
{
if (team.Event.EventFormat == EventFormat.Individual)
{
return string.IsNullOrWhiteSpace(student.StateId)
? "—"
: student.StateId.Trim();
}
var chap = _chapterStateId?.Trim();
var ident = team.Identifier?.Trim();
if (string.IsNullOrEmpty(chap) && string.IsNullOrEmpty(ident))
return "—";
// Already a full registration id (e.g. "12227-1" or state id stored on team)
if (!string.IsNullOrEmpty(ident))
{
if (ident.Contains('-', StringComparison.Ordinal))
return ident;
if (!string.IsNullOrEmpty(chap) && ident.StartsWith(chap, StringComparison.Ordinal))
return ident;
}
if (!string.IsNullOrEmpty(chap) && !string.IsNullOrEmpty(ident))
return $"{chap}-{ident}";
return !string.IsNullOrEmpty(chap) ? chap : ident!;
}
// Activity line comes from event SemifinalistActivity (interview/presentation limits), not Min/MaxTeamSize.
private static string FormatActivitySummary(Team team, Student student)
{
var parts = new List<string>();
if (team.Captain?.Id == student.Id)
parts.Add("(Cpt.)");
if (!string.IsNullOrWhiteSpace(team.Event.SemifinalistActivity))
parts.Add(team.Event.SemifinalistActivity!);
return string.Join(" ", parts).Trim();
}
private static string FormatDateHeading(DateTime date) =>
date.ToString("MMMM d, dddd", CultureInfo.GetCultureInfo("en-US"));
private static string FormatTimeDisplay(EventOccurrence o)
{
if (!string.IsNullOrWhiteSpace(o.Time))
return o.Time.Trim();
return o.StartTime.ToString("g", CultureInfo.GetCultureInfo("en-US"));
}
private static string FormatEventColumn(EventOccurrence o)
{
if (o.EventDefinition != null)
{
var ev = !string.IsNullOrWhiteSpace(o.EventDefinition.ShortName)
? o.EventDefinition.ShortName
: o.EventDefinition.Name;
if (string.IsNullOrWhiteSpace(o.Name))
return ev;
if (o.Name.Contains(ev, StringComparison.OrdinalIgnoreCase))
return o.Name.Trim();
return $"{ev} {o.Name}".Trim();
}
return string.IsNullOrWhiteSpace(o.Name) ? (o.SpecialEventType ?? "") : o.Name.Trim();
}
private string FormatCombinedScheduleEventCell(EventOccurrence occ)
{
var baseText = FormatEventColumn(occ);
if (!occ.EventDefinitionId.HasValue)
return baseText;
if (!_teamsByEventDefinitionId.TryGetValue(occ.EventDefinitionId.Value, out var teams) || teams.Count == 0)
return baseText;
var isIndividual = occ.EventDefinition?.EventFormat == EventFormat.Individual;
var orderedTeams = teams
.OrderBy(t => t, Comparer<Team>.Create((a, b) =>
{
var cmp = CombinedScheduleTeamSortOrder(a, b);
return cmp != 0 ? cmp : a.Id.CompareTo(b.Id);
}))
.ToList();
var rosterStrings = orderedTeams
.Select(t => FormatCombinedScheduleTeamRoster(t, isIndividual))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (rosterStrings.Count == 0)
return baseText;
var suffix = rosterStrings.Count == 1
? rosterStrings[0]
: string.Join(" ", rosterStrings.Select(r => $"[{r}]"));
return $"{baseText} — {suffix}";
}
private static int CombinedScheduleTeamSortOrder(Team a, Team b)
{
var ka = a.Identifier?.Trim() ?? "";
var kb = b.Identifier?.Trim() ?? "";
if (int.TryParse(ka, out var na) && int.TryParse(kb, out var nb))
return na.CompareTo(nb);
return string.Compare(ka, kb, StringComparison.OrdinalIgnoreCase);
}
private static string FormatCombinedScheduleTeamRoster(Team team, bool isIndividual)
{
var students = team.Students?.ToList() ?? [];
if (students.Count == 0)
return "";
if (isIndividual)
{
var ordered = students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", ordered.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
var cap = team.Captain;
var capInRoster = cap != null && students.Exists(s => s.Id == cap.Id);
IEnumerable<Student> orderedTeam = capInRoster
? students.Where(s => s.Id != cap!.Id).OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase).Prepend(cap!)
: students.OrderBy(s => s.FirstName, StringComparer.OrdinalIgnoreCase);
return string.Join(", ", orderedTeam.Select(s => FormatCombinedScheduleStudentSegment(s, team, isIndividual)));
}
private static string FormatCombinedScheduleStudentSegment(Student student, Team team, bool isIndividual)
{
if (isIndividual)
{
var sid = student.StateId?.Trim();
return !string.IsNullOrEmpty(sid)
? $"{student.FirstName} ({sid})"
: student.FirstName;
}
var isCpt = team.Captain?.Id == student.Id;
return isCpt ? $"{student.FirstName} (Cpt.)" : student.FirstName;
}
private sealed record EventSummaryRow(string StateRegistrationId, string EventName, string Activity);
}
@@ -1,5 +1,9 @@
@using WebApp.Models
@using WebApp.Models
@if (EventDefinition is null)
{
return;
}
@* @if (EventDefinition.LevelOfEffort.HasValue)
{
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
@@ -27,12 +31,17 @@
@code {
[Parameter]
public required EventDefinition EventDefinition { get; set; }
public EventDefinition? EventDefinition { get; set; }
private string _attributes = string.Empty;
protected override void OnParametersSet()
{
if (EventDefinition is null)
{
_attributes = string.Empty;
return;
}
_attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " ";
_attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " ";
_attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " ";
+6 -3
View File
@@ -1,4 +1,4 @@
@page "/events/edit"
@page "/events/edit"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@@ -163,9 +163,10 @@
{
try
{
// Get the tracked entity from the database
// Get the tracked entity from the database (do not Include RelatedCareers:
// the same context may already be tracking those Career instances from the initial load,
// which would cause "another instance with the same key value is already being tracked").
var trackedEntity = await context.Events
.Include(e => e.RelatedCareers)
.FirstOrDefaultAsync(e => e.Id == EventDefinition!.Id);
if (trackedEntity == null)
@@ -177,6 +178,8 @@
// Update scalar properties from the form-bound entity
context.Entry(trackedEntity).CurrentValues.SetValues(EventDefinition!);
// RelatedCareersText is not mapped; copy it so ProcessRelatedCareersAsync can use it
trackedEntity.RelatedCareersText = EventDefinition!.RelatedCareersText;
// Normalize and process related careers
await EventDefinitionService.ProcessRelatedCareersAsync(trackedEntity);
@@ -0,0 +1,397 @@
@page "/meeting-schedule/history"
@attribute [Authorize]
@using Core.Entities
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@using WebApp.Services
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IConfiguration Configuration
@inject IMeetingScheduleDataService DataService
@inject IMeetingScheduleStateService StateService
@inject NavigationManager NavigationManager
@implements IAsyncDisposable
<PageHeader Title="Team Meeting Schedule History">
<ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Add"
Variant="Variant.Outlined"
Color="Color.Primary"
Href="/meeting-schedule">
Back to Schedule
</MudButton>
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6 mt-4">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else
{
<MudStack Spacing="3">
@if (_meetingHistories.Any())
{
<MudPaper Elevation="1" Class="pa-3" Style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
<table class="history-grid-table" style="min-width: max-content; width: 100%; border-collapse: collapse;">
<colgroup>
<col style="width: 72px;" />
<col style="width: 40px;" />
@for (var c = 0; c < _allTeams.Count; c++)
{
<col style="width: 28px;" />
}
</colgroup>
<thead>
<tr>
<th class="history-cell history-cell-date">
<span class="mud-typography mud-typography-caption">Date</span>
</th>
<th class="history-cell history-cell-actions">
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">Actions</span>
</th>
@{
var teamIndex = 0;
}
@foreach (var team in _allTeams)
{
var isEven = teamIndex % 2 == 0;
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
<th class="@colClass">
<span class="mud-typography mud-typography-caption" style="writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);">@team.ToString()</span>
</th>
teamIndex++;
}
</tr>
<tr>
<th class="history-cell history-cell-date history-cell-times-met">Times Met</th>
<th class="history-cell history-cell-actions "></th>
@{
var summaryTeamIndex = 0;
}
@foreach (var team in _allTeams)
{
var timesMet = GetTimesMetForTeam(team);
var isEven = summaryTeamIndex % 2 == 0;
var colClass = isEven ? "history-cell history-cell-col-even history-cell-times-met" : "history-cell history-cell-col-odd history-cell-times-met";
<th class="@colClass">@timesMet</th>
summaryTeamIndex++;
}
</tr>
</thead>
<tbody>
@{
var rowIndex = 0;
}
@foreach (var history in _meetingHistories)
{
var rowTeamIndex = 0;
var rowClass = rowIndex % 2 == 0 ? "history-row-even" : "history-row-odd";
<tr class="@rowClass">
<td class="history-cell history-cell-date ">
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => ViewMeetingDetails(history))"
Style="text-transform: none; padding: 0 2px; min-width: unset;">
@history.MeetingDate.ToString("MM/dd/yy")
</MudButton>
</td>
<td class="history-cell history-cell-actions">
<MudTooltip Text="Load into Planner">
<MudIconButton Icon="@Icons.Material.Filled.Upload"
Size="Size.Small"
Color="Color.Secondary"
OnClick="@(() => LoadMeetingIntoPlanner(history))"
Variant="Variant.Text"
Style="padding: 2px;" />
</MudTooltip>
</td>
@foreach (var team in _allTeams)
{
var met = TeamMetOnDate(history, team);
var isEven = rowTeamIndex % 2 == 0;
var colClass = isEven ? "history-cell history-cell-col-even" : "history-cell history-cell-col-odd";
<td class="@colClass">
@if (met)
{
<span class="mud-typography mud-typography-body1" style="font-weight: bold;">×</span>
}
</td>
rowTeamIndex++;
}
</tr>
rowIndex++;
}
</tbody>
</table>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Info">No meeting history found. Save a meeting schedule to get started.</MudAlert>
}
</MudStack>
}
</MudPaper>
<style>
.history-grid-table {
--history-border: 1px solid var(--mud-palette-divider);
--history-shade: var(--mud-palette-background-grey);
}
.history-grid-table th,
.history-grid-table td {
padding: 2px 4px;
border-right: var(--history-border);
border-bottom: var(--history-border);
vertical-align: middle;
}
.history-grid-table thead th {
border-top: var(--history-border);
background-color: var(--mud-palette-surface);
}
.history-grid-table thead .history-cell-col-odd {
background-color: var(--mud-palette-surface);
}
.history-grid-table thead th:first-child,
.history-grid-table tbody td:first-child {
border-left: var(--history-border);
}
.history-grid-table .history-cell-date {
padding: 2px 4px;
text-align: left;
min-width: 72px;
}
.history-grid-table .history-cell-actions {
padding: 2px;
text-align: center;
min-width: 40px;
}
.history-grid-table thead .history-cell-col-even {
background-color: var(--history-shade);
}
.history-grid-table .history-cell-times-met {
font-weight: bold;
background-color: var(--history-shade);
}
.history-grid-table tbody .history-row-odd td {
background-color: var(--history-shade);
}
.history-grid-table tbody .history-row-even td.history-cell-col-even,
.history-grid-table tbody .history-row-odd td.history-cell-col-even {
background-color: var(--history-shade);
}
.history-grid-table tbody .history-row-odd td.history-cell-col-odd {
background-color: var(--history-shade);
}
</style>
@code {
private List<TeamMeetingHistory> _meetingHistories = [];
private List<Team> _allTeams = [];
private Dictionary<int, int> _timesMetDict = new();
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadData();
}
private async Task LoadData()
{
if (_isDisposed) return;
try
{
_isLoading = true;
StateHasChanged();
// Load all teams for columns
_allTeams = await Context.Teams
.AsNoTracking()
.Include(t => t.Event)
.OrderBy(t => t.Event.Name)
.ThenBy(t => t.Identifier)
.ToListAsync();
// Load meeting histories
await RefreshMeetingHistories();
// Calculate times met
CalculateTimesMet();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private async Task RefreshMeetingHistories()
{
_meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList();
// Extract all unique teams from meeting histories and merge with all teams
var teamsInHistory = _meetingHistories
.SelectMany(mh => mh.Teams)
.DistinctBy(t => t.Id)
.ToList();
// Merge with all teams, ensuring all teams are included
var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet();
var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList();
_allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList();
}
private void CalculateTimesMet()
{
_timesMetDict.Clear();
foreach (var team in _allTeams)
{
_timesMetDict[team.Id] = 0;
}
foreach (var history in _meetingHistories)
{
var teamIds = history.Teams.Select(t => t.Id).ToHashSet();
foreach (var teamId in teamIds)
{
if (_timesMetDict.ContainsKey(teamId))
{
_timesMetDict[teamId]++;
}
}
}
}
private int GetTimesMetForTeam(Team team)
{
return _timesMetDict.GetValueOrDefault(team.Id, 0);
}
private bool TeamMetOnDate(TeamMeetingHistory history, Team team)
{
return history.Teams.Any(t => t.Id == team.Id);
}
private async Task ViewMeetingDetails(TeamMeetingHistory history)
{
var parameters = new DialogParameters
{
["MeetingHistoryId"] = history.Id
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
var result = await dialog.Result;
if (!result.Canceled)
{
// Refresh data if meeting was updated or deleted
await RefreshMeetingHistories();
CalculateTimesMet();
if (!_isDisposed)
{
StateHasChanged();
}
}
}
private async Task LoadMeetingIntoPlanner(TeamMeetingHistory history)
{
if (_isDisposed) return;
try
{
// Get all teams and students from database
var allTeams = await DataService.LoadTeamsAsync();
var allStudents = await DataService.LoadStudentsAsync();
// Match teams from history to all teams by ID for reference equality
var historyTeamIds = history.Teams.Select(t => t.Id).ToHashSet();
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
// Calculate absent students (all students not in the meeting history's student list)
var presentStudentIds = history.Students.Select(s => s.Id).ToHashSet();
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
// Save state to localStorage
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
await StateService.SaveAbsentStudentsAsync(absentStudents);
// Clear extended teams and excluded students when loading from history
await StateService.SaveExtendedTeamsAsync([]);
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
// Navigate to planner
NavigationManager.NavigateTo("/meeting-schedule");
if (!_isDisposed)
{
Snackbar.Add($"Loaded meeting from {history.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
}
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -2,16 +2,41 @@
@attribute [Authorize]
@using System.Text
@using Core.Calculation
@using Core.Models
@using Core.Utility
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@using Core.Utility
@using WebApp.Components.Features.MeetingSchedule
@using WebApp.Models
@using WebApp.Services
@inject IConfiguration Configuration
@inject AppDbContext Context
@inject ClipboardService ClipboardService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject IMeetingScheduleStateService StateService
@inject IMeetingScheduleDataService DataService
@inject IMeetingScheduleClipboardService ClipboardFormatService
@inject LocalStorageService LocalStorage
<PageHeader Title="@($"{Configuration["ChapterSettings:Shortname"]} TSA Schedule {Configuration["ChapterSettings:CompetitionYear"]}")">
<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>
@@ -22,46 +47,93 @@
<MudText Typo="Typo.h4">Time Slots</MudText>
<MudPaper Class="pa-2 ma-2" Elevation="3">
<MudGrid>
<MudItem xs="6" sm="3" lg="2">
<MudNumericField Value="_parameters.TimeSlots"
ValueChanged="async (int val) => await OnTimeSlotCountChanged(val)"
Label="Time Slots" Min="1" Max="4">
</MudNumericField>
</MudItem>
<MudFlexBreak/>
<MudItem xs="12" sm="6" lg="4">
<MudTooltip Text="Schedule teams with Level of Effort >= 3" Inline="false">
<MudButton Variant="Variant.Outlined" OnClick="AddHighLevelOfEffort" FullWidth="true">Add High Effort</MudButton>
</MudTooltip>
<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">
<MudButton Variant="Variant.Outlined" OnClick="AddRegionals" FullWidth="true">Add Regionals</MudButton>
<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">
<MudButton Variant="Variant.Outlined" OnClick="RemoveIndividual" FullWidth="true">Remove Individual</MudButton>
<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">
<MudButton Variant="Variant.Outlined" OnClick="RemoveLowLevelOfEffort" FullWidth="true">Remove Low Effort</MudButton>
<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)"
Class="ma-3"
OnClick="Solve"
Color="Color.Primary"
Disabled="@_isSolving">
Solve
</MudButton>
<MudTooltip Text="Copy to Clipboard">
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
</MudTooltip>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@@ -124,6 +196,7 @@
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 = [];
@@ -152,6 +225,22 @@
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;
@@ -160,37 +249,76 @@
private void AddRegionals()
{
_scheduledTeams
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
_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
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
_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.Where(t => t.Event.EventFormat != EventFormat.Individual);
_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.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();
}
@@ -253,32 +381,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);
@@ -294,84 +405,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<ExcludedStudent[]>("MeetingSchedule_ExcludedStudents");
if (exclusions != null && exclusions.Length > 0)
{
_excludedStudents = exclusions.ToDictionary(
e => (e.TeamId, e.TimeSlotIndex, e.StudentId),
_ => true);
}
}
private int GetTimeSlotIndex(string timeSlotName)
{
@@ -405,37 +438,12 @@
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
}
/// <summary>
/// Creates teams with excluded students filtered out for overlap calculation.
/// </summary>
private Team[] GetTeamsWithoutExcludedStudents(Team[] teams, int timeSlotIndex)
{
return teams.Select(team =>
{
// Find excluded students for this team in this time slot
// More efficient: iterate through team students and check exclusions
var includedStudents = team.Students
.Where(s => !IsStudentExcluded(team.Id, timeSlotIndex, 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 students removed
return new Team
{
Id = team.Id,
Event = team.Event,
Students = includedStudents,
Captain = team.Captain,
Identifier = team.Identifier
};
}).ToArray();
var absentStudentIds = _absentStudents.Select(s => s.Id).ToHashSet();
return OverlapCalculationHelper.GetTeamsWithoutExcludedStudents(teams, timeSlotIndex, _excludedStudents, absentStudentIds);
}
private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
private bool IsDirty()
{
if (_lastSavedState == null)
@@ -484,26 +492,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();
@@ -541,7 +530,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
@@ -568,6 +561,13 @@
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;
@@ -579,91 +579,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<Team> 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
{
@@ -671,220 +598,86 @@
}
}
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
private async Task OpenSaveHistoryDialog()
{
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
var parameters = new DialogParameters
{
var teamName = scheduledTeam.ToString();
["ScheduledTeams"] = _scheduledTeams,
["AbsentStudents"] = _absentStudents,
["AllTeams"] = _teams,
["AllStudents"] = _students
};
if (scheduledTeam.Event.EventFormat is EventFormat.Individual)
var options = new DialogOptions
{
sb.Append(teamName);
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
{
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex);
sb.Append($"{teamName} - {studentsList}");
Snackbar.Add($"Selected {newCount} new team(s) from clipboard ({totalCount - newCount} already selected)", Severity.Success);
}
sb.Append(Environment.NewLine);
}
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();
}
}
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<MeetingScheduleState>
{
public HashSet<int> ScheduledTeamIds { get; set; } = [];
public HashSet<int> AbsentStudentIds { get; set; } = [];
public int TimeSlotCount { get; set; }
public HashSet<int> 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<Team> scheduledTeams,
IEnumerable<Student> absentStudents,
int timeSlotCount,
IEnumerable<Team> 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<MeetingScheduleState?> 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<ExcludedStudent[]>("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);
}
}
}
@@ -0,0 +1,501 @@
@namespace WebApp.Components.Features.MeetingSchedule
@using Core.Services
@using WebApp.Models
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IMeetingScheduleDataService DataService
@inject IMeetingScheduleStateService StateService
@inject NavigationManager NavigationManager
@implements IAsyncDisposable
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (_meetingHistory == null)
{
<MudAlert Severity="Severity.Error">Meeting history not found.</MudAlert>
}
else
{
<MudStack Spacing="3">
@* Navigation Header *@
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Spacing="2">
<MudTooltip Text="@(_previousMeetingHistory != null ? $"Previous: {_previousMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No previous meeting")">
<span>
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
OnClick="NavigateToPrevious"
Disabled="@(_previousMeetingHistory == null || IsActionDisabled)"
Color="Color.Primary"
Size="Size.Medium" />
</span>
</MudTooltip>
<MudText Typo="Typo.h6" Class="flex-grow-1" Style="text-align: center;">@_meetingHistory.MeetingDate.ToString("MM/dd/yyyy")</MudText>
<MudTooltip Text="@(_nextMeetingHistory != null ? $"Next: {_nextMeetingHistory.MeetingDate:MM/dd/yyyy}" : "No next meeting")">
<span>
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
OnClick="NavigateToNext"
Disabled="@(_nextMeetingHistory == null || IsActionDisabled)"
Color="Color.Primary"
Size="Size.Medium" />
</span>
</MudTooltip>
</MudStack>
<MudGrid>
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle1">Teams That Met (@_meetingHistory.Teams.Count)</MudText>
<MudPaper Elevation="1" Class="pa-2">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
{
<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="@AppIcons.TeamChipVariant()" Class="mx-1 my-1">@team.ToString()</MudChip>
}
</MudStack>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle1">Students (@GetAllStudentsFromTeams().Count)</MudText>
<MudPaper Elevation="1" Class="pa-2">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@{
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName);
}
@foreach (var student in allStudents)
{
var isPresent = presentStudentIds.Contains(student.Id);
<MudChip T="string"
Size="Size.Small"
Color="Color.Default"
Variant="@AppIcons.StudentChipVariant()"
Class="mx-1 my-1"
Style="@(!isPresent ? "opacity: 0.5;" : "")">
@student.FirstNameLastName
</MudChip>
}
</MudStack>
</MudPaper>
</MudItem>
</MudGrid>
@if (_meetingNote != null)
{
<MudDivider />
<MudText Typo="Typo.subtitle1">Meeting Notes</MudText>
<MudPaper Elevation="1" Class="pa-3">
<MudText Typo="Typo.body2"><strong>@_meetingNote.Title</strong></MudText>
@if (!string.IsNullOrWhiteSpace(_meetingNote.Content))
{
<MudText Typo="Typo.body2" Class="mt-2">
@((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content))
</MudText>
}
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Edit"
OnClick="ViewNote"
Class="mt-2">
Edit Note
</MudButton>
</MudPaper>
}
</MudStack>
}
</DialogContent>
<DialogActions>
@if (_meetingHistory != null)
{
<MudButton Variant="Variant.Text"
Color="Color.Error"
OnClick="ConfirmDelete"
Disabled="@IsActionDisabled">
Delete
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Text"
Color="Color.Secondary"
OnClick="LoadIntoPlanner"
Disabled="@IsActionDisabled"
StartIcon="@Icons.Material.Filled.Upload">
Load into Planner
</MudButton>
<MudButton Variant="Variant.Text"
Color="Color.Primary"
OnClick="OpenEditDialog"
Disabled="@IsActionDisabled"
StartIcon="@Icons.Material.Filled.Edit">
Edit
</MudButton>
<MudButton OnClick="Close" Disabled="@IsActionDisabled">Close</MudButton>
}
else
{
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int MeetingHistoryId { get; set; }
private TeamMeetingHistory? _meetingHistory;
private Note? _meetingNote;
private bool _isLoading = true;
private bool _isDeleting = false;
private Team[] _allTeams = [];
private Student[] _allStudents = [];
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
private bool IsActionDisabled => _isLoading || _isDeleting;
private TeamMeetingHistory? _previousMeetingHistory;
private TeamMeetingHistory? _nextMeetingHistory;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
// Load all teams and students for the edit dialog
_allTeams = await DataService.LoadTeamsAsync();
_allStudents = await DataService.LoadStudentsAsync();
await LoadMeetingHistory();
}
private async Task LoadMeetingHistory()
{
if (_isDisposed) return;
try
{
_meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId);
// Load note by title if meeting history exists
if (_meetingHistory != null)
{
_meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate);
// Load all meeting histories to find previous/next
await LoadNavigationMeetings();
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error);
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private async Task LoadNavigationMeetings()
{
if (_isDisposed || _meetingHistory == null) return;
try
{
// Get all meeting histories ordered by date
var allMeetings = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync())
.OrderBy(m => m.MeetingDate)
.ThenBy(m => m.Id)
.ToList();
var currentIndex = allMeetings.FindIndex(m => m.Id == _meetingHistory.Id);
if (currentIndex >= 0)
{
_previousMeetingHistory = currentIndex > 0 ? allMeetings[currentIndex - 1] : null;
_nextMeetingHistory = currentIndex < allMeetings.Count - 1 ? allMeetings[currentIndex + 1] : null;
}
else
{
_previousMeetingHistory = null;
_nextMeetingHistory = null;
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
// Log error but don't show snackbar - navigation is not critical
System.Diagnostics.Debug.WriteLine($"Error loading navigation meetings: {ex.Message}");
}
}
}
private async Task NavigateToPrevious()
{
if (_previousMeetingHistory == null || _isDisposed) return;
// Close current dialog and open new one with previous meeting ID
var parameters = new DialogParameters
{
["MeetingHistoryId"] = _previousMeetingHistory.Id
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true,
CloseButton = true
};
MudDialog.Close();
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
}
private async Task NavigateToNext()
{
if (_nextMeetingHistory == null || _isDisposed) return;
// Close current dialog and open new one with next meeting ID
var parameters = new DialogParameters
{
["MeetingHistoryId"] = _nextMeetingHistory.Id
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true,
CloseButton = true
};
MudDialog.Close();
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting History", parameters, options);
}
private async Task ConfirmDelete()
{
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
var result = await DialogService.ShowMessageBox(
"Confirm Delete",
$"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
if (result == true)
{
await DeleteMeetingHistory();
}
}
private async Task DeleteMeetingHistory()
{
if (_isDisposed || _isDeleting || _meetingHistory == null) return;
try
{
_isDeleting = true;
StateHasChanged();
await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId);
if (!_isDisposed)
{
Snackbar.Add("Meeting history deleted successfully", Severity.Success);
MudDialog.Close(DialogResult.Ok(true));
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error);
_isDeleting = false;
StateHasChanged();
}
}
}
private async Task ViewNote()
{
if (_meetingNote == null) return;
var parameters = new DialogParameters
{
["NoteId"] = _meetingNote.Id
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<NoteViewDialog>("Meeting Notes", parameters, options);
await dialog.Result;
// Refresh meeting history to get updated note
await LoadMeetingHistory();
}
private async Task OpenEditDialog()
{
if (_meetingHistory == null || _isDisposed) return;
var parameters = new DialogParameters
{
["MeetingHistoryId"] = _meetingHistory.Id,
["AllTeams"] = _allTeams,
["AllStudents"] = _allStudents,
["ScheduledTeams"] = new List<Team>(), // Not used when editing
["AbsentStudents"] = new List<Student>() // Not used when editing
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Medium,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<SaveMeetingHistoryDialog>("Edit Meeting History", parameters, options);
var result = await dialog.Result;
// Refresh meeting history if dialog was saved
if (!result.Canceled && !_isDisposed)
{
await LoadMeetingHistory();
}
}
private List<Student> GetAllStudentsFromTeams()
{
if (_meetingHistory == null)
return [];
var allStudents = new List<Student>();
var studentIds = new HashSet<int>();
foreach (var team in _meetingHistory.Teams)
{
foreach (var student in team.Students)
{
if (!studentIds.Contains(student.Id))
{
studentIds.Add(student.Id);
allStudents.Add(student);
}
}
}
return allStudents;
}
private async Task LoadIntoPlanner()
{
if (_isDisposed || _meetingHistory == null) return;
try
{
// Get all teams and students from database
var allTeams = await DataService.LoadTeamsAsync();
var allStudents = await DataService.LoadStudentsAsync();
// Match teams from history to all teams by ID for reference equality
var historyTeamIds = _meetingHistory.Teams.Select(t => t.Id).ToHashSet();
var scheduledTeams = allTeams.Where(t => historyTeamIds.Contains(t.Id));
// Calculate absent students (all students not in the meeting history's student list)
var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet();
var absentStudents = allStudents.Where(s => !presentStudentIds.Contains(s.Id));
// Save state to localStorage
await StateService.SaveScheduledTeamsAsync(scheduledTeams);
await StateService.SaveAbsentStudentsAsync(absentStudents);
// Clear extended teams and excluded students when loading from history
await StateService.SaveExtendedTeamsAsync([]);
await StateService.SaveExcludedStudentsAsync(new Dictionary<(int teamId, int timeSlotIndex, int studentId), bool>());
// Close dialog and navigate to planner
MudDialog.Close();
NavigationManager.NavigateTo("/meeting-schedule");
if (!_isDisposed)
{
Snackbar.Add($"Loaded meeting from {_meetingHistory.MeetingDate:MM/dd/yyyy} into planner", Severity.Success);
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading meeting into planner: {ex.Message}", Severity.Error);
}
}
}
private void Close()
{
if (_isDisposed) return;
MudDialog.Close();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -0,0 +1,454 @@
@namespace WebApp.Components.Features.MeetingSchedule
@using Core.Entities
@using Core.Calculation
@using Core.Services
@using WebApp.Services
@using WebApp.Components.Shared.Components
@using WebApp.Components.Features.Teams.Components
@using WebApp.Components.Features.Students.Components
@using WebApp.Models
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@implements IAsyncDisposable
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else
{
<MudStack Spacing="3">
<MudText Typo="Typo.h6">@(_isEditMode ? "Edit Meeting History" : "Save Meeting History")</MudText>
<MudDatePicker Label="Meeting Date"
Date="_meetingDate"
DateChanged="OnMeetingDateChanged"
Variant="Variant.Outlined"
Disabled="@_isEditMode" />
<MudDivider />
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
<TeamToggleSelector Teams="@AllTeams"
SelectedTeams="_selectedTeams"
SelectedTeamsChanged="OnTeamsChanged"
Title="Teams That Met"
ShowEventAttributes="false" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Elevation="1" Class="pa-2" Style="max-height: 300px; overflow-y: auto;">
<StudentToggleSelector Students="@AllStudents"
SelectedStudents="_selectedStudents"
SelectedStudentsChanged="OnStudentsChanged"
Title="Students Present"
ShowFullName="true" />
</MudPaper>
</MudItem>
</MudGrid>
<MudDivider />
<MudExpansionPanels MultiExpansion="false">
<MudExpansionPanel Text="Meeting Notes (Optional)">
<MudStack Spacing="2">
<MudTextField T="string"
Label="Note Title"
@bind-Value="_noteTitle"
Variant="Variant.Outlined"
ReadOnly="true"
HelperText="Title is automatically generated based on meeting date" />
<MudTextField T="string"
Label="Note Content"
@bind-Value="_noteContent"
Variant="Variant.Outlined"
Lines="5"
Placeholder="Enter meeting notes..."
HelperText="Optional markdown content for meeting notes" />
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="@_isSaving">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save" Disabled="@IsSaveDisabled">
@if (_isSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>Saving...</span>
}
else
{
<span>Save</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public IEnumerable<Team> ScheduledTeams { get; set; } = [];
[Parameter]
public IEnumerable<Student> AbsentStudents { get; set; } = [];
[Parameter]
public IEnumerable<Team> AllTeams { get; set; } = [];
[Parameter]
public IEnumerable<Student> AllStudents { get; set; } = [];
[Parameter]
public int? MeetingHistoryId { get; set; }
private DateTime? _meetingDate = DateTime.Today;
private IEnumerable<Team> _selectedTeams = [];
private IEnumerable<Student> _selectedStudents = [];
private string _noteTitle = "";
private string _noteContent = "";
private bool _isLoading = true;
private bool _isSaving = false;
private bool _isEditMode = false;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadInitialData();
}
private async Task LoadInitialData()
{
if (_isDisposed) return;
try
{
// If editing, load existing meeting history
if (MeetingHistoryId.HasValue)
{
_isEditMode = true;
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
if (existingHistory != null)
{
_meetingDate = existingHistory.MeetingDate;
// Match teams by ID to ensure reference equality with MudToggleGroup
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
var matchedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id)).ToList();
// If we couldn't match all teams from AllTeams, use the teams from existing history
// This can happen if AllTeams is empty or doesn't contain all the teams
if (matchedTeams.Count != existingHistory.Teams.Count && AllTeams.Any())
{
// Try to match what we can, but log a warning
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all teams. Expected {existingHistory.Teams.Count}, matched {matchedTeams.Count}");
}
_selectedTeams = matchedTeams.Any() ? matchedTeams : existingHistory.Teams;
// Match students by ID to ensure reference equality with MudToggleGroup
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
var matchedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id)).ToList();
// If we couldn't match all students from AllStudents, use the students from existing history
if (matchedStudents.Count != existingHistory.Students.Count && AllStudents.Any())
{
// Try to match what we can, but log a warning
System.Diagnostics.Debug.WriteLine($"Warning: Could not match all students. Expected {existingHistory.Students.Count}, matched {matchedStudents.Count}");
}
_selectedStudents = matchedStudents.Any() ? matchedStudents : existingHistory.Students;
// Load existing note if available
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
if (existingNote != null)
{
_noteContent = existingNote.Content ?? "";
}
}
}
else
{
// Initialize selected teams from scheduled teams
_selectedTeams = ScheduledTeams;
// Initialize selected students (all students except absent ones)
var absentStudentIds = AbsentStudents.Select(s => s.Id).ToHashSet();
_selectedStudents = AllStudents.Where(s => !absentStudentIds.Contains(s.Id));
}
// Generate default note title if meeting date is set
if (_meetingDate.HasValue)
{
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading data: {ex.Message}", Severity.Error);
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private bool IsSaveDisabled => _isSaving || _isLoading;
protected override async Task OnParametersSetAsync()
{
if (_meetingDate.HasValue && string.IsNullOrEmpty(_noteTitle))
{
_noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
}
await base.OnParametersSetAsync();
}
private async Task OnMeetingDateChanged(DateTime? date)
{
_meetingDate = date;
if (date.HasValue)
{
_noteTitle = NoteNamingService.GetMeetingNoteTitle(date.Value);
}
await InvokeAsync(StateHasChanged);
}
private void OnTeamsChanged(IEnumerable<Team> teams)
{
_selectedTeams = teams;
StateHasChanged();
}
private void OnStudentsChanged(IEnumerable<Student> students)
{
_selectedStudents = students;
StateHasChanged();
}
private async Task Save()
{
if (_isDisposed || _isSaving) return;
if (!_meetingDate.HasValue)
{
Snackbar.Add("Please select a meeting date", Severity.Warning);
return;
}
// Validate that we have at least one team selected
if (!_selectedTeams.Any())
{
Snackbar.Add("Please select at least one team", Severity.Warning);
return;
}
// Check if a meeting history already exists for this date (only when creating new, not editing)
if (!_isEditMode)
{
var dateOnly = _meetingDate.Value.Date;
// GetMeetingHistoriesAsync: startDate >= date, endDate < (endDate.Date + 1 day)
// To get meetings for a single day, pass dateOnly as startDate and dateOnly as endDate
// This becomes: MeetingDate >= dateOnly AND MeetingDate < (dateOnly + 1 day) = just that day
var existingMeetings = await TeamMeetingHistoryService.GetMeetingHistoriesAsync(dateOnly, dateOnly);
if (existingMeetings.Any())
{
// Show confirmation dialog
var confirmResult = await DialogService.ShowMessageBox(
"Overwrite Meeting?",
"A meeting already exists for this date. Overwrite it?",
yesText: "Overwrite",
cancelText: "Cancel");
if (confirmResult != true)
{
return; // User cancelled
}
// User confirmed, switch to edit mode
var existingMeeting = existingMeetings.First();
_isEditMode = true;
MeetingHistoryId = existingMeeting.Id;
// Load existing meeting data
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(existingMeeting.Id);
if (existingHistory != null)
{
// Match teams by ID to ensure reference equality with MudToggleGroup
var selectedTeamIds = existingHistory.Teams.Select(t => t.Id).ToHashSet();
_selectedTeams = AllTeams.Where(t => selectedTeamIds.Contains(t.Id));
// Match students by ID to ensure reference equality with MudToggleGroup
var selectedStudentIds = existingHistory.Students.Select(s => s.Id).ToHashSet();
_selectedStudents = AllStudents.Where(s => selectedStudentIds.Contains(s.Id));
// Load existing note if available
var existingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(existingHistory.MeetingDate);
if (existingNote != null)
{
_noteContent = existingNote.Content ?? "";
}
}
}
}
try
{
_isSaving = true;
StateHasChanged();
// Create or update note if note content is provided
Note? note = null;
if (!string.IsNullOrWhiteSpace(_noteContent))
{
// Use the naming service to get the meeting note title
var noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value);
// Check if note already exists
var existingNote = await NotesService.GetNotesAsync(includeDeleted: false);
note = existingNote.FirstOrDefault(n => n.Title == noteTitle);
if (note != null)
{
// Update existing note
note.Content = _noteContent;
note = await NotesService.UpdateNoteAsync(note);
}
else
{
// Create new note
note = new Note
{
Title = noteTitle,
Content = _noteContent
};
note = await NotesService.CreateNoteAsync(note);
}
}
// Create or update meeting history
TeamMeetingHistory meetingHistory;
if (_isEditMode && MeetingHistoryId.HasValue)
{
// Update existing meeting history
var existingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId.Value);
if (existingHistory == null)
{
Snackbar.Add("Meeting history not found", Severity.Error);
_isSaving = false;
StateHasChanged();
return;
}
// Ensure we have teams and students - use existing if selected lists are empty (fallback)
var teamsToSave = _selectedTeams.Any() ? _selectedTeams.ToList() : existingHistory.Teams.ToList();
var studentsToSave = _selectedStudents.Any() ? _selectedStudents.ToList() : existingHistory.Students.ToList();
// Create a new meeting history object with the updated data
// Use IDs to ensure we're working with the correct entities
meetingHistory = new TeamMeetingHistory
{
Id = existingHistory.Id,
MeetingDate = _meetingDate.Value,
Teams = teamsToSave,
Students = studentsToSave
};
await TeamMeetingHistoryService.UpdateMeetingHistoryAsync(meetingHistory);
if (!_isDisposed)
{
Snackbar.Add($"Meeting history updated for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
}
}
else
{
// Create new meeting history
meetingHistory = new TeamMeetingHistory
{
MeetingDate = _meetingDate.Value,
Teams = _selectedTeams.ToList(),
Students = _selectedStudents.ToList()
};
await TeamMeetingHistoryService.CreateMeetingHistoryAsync(meetingHistory);
if (!_isDisposed)
{
Snackbar.Add($"Meeting history saved for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success);
}
}
if (!_isDisposed)
{
MudDialog.Close(DialogResult.Ok(true));
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error saving meeting history: {ex.Message}", Severity.Error);
_isSaving = false;
StateHasChanged();
}
}
}
private void Cancel()
{
if (_isDisposed) return;
MudDialog.Cancel();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -0,0 +1,108 @@
@namespace WebApp.Components.Features.Teams.Components
@using Core.Entities
@using WebApp.Services
@using MudBlazor
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject IDialogService DialogService
@implements IAsyncDisposable
@if (_meetingCount.HasValue && _meetingCount.Value > 0)
{
<MudTooltip Text="View Meeting History">
<MudBadge Content="@_meetingCount.Value.ToString()"
Color="Color.Primary"
Overlap="true">
<MudIconButton Icon="@Icons.Material.Filled.History"
Size="Size.Small"
Color="Color.Default"
OnClick="OpenMeetingHistoryDialog"
Variant="Variant.Text" />
</MudBadge>
</MudTooltip>
}
@code {
[Parameter]
public int TeamId { get; set; }
[Parameter]
public string TeamName { get; set; } = string.Empty;
private int? _meetingCount;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadMeetingCount();
}
private async Task LoadMeetingCount()
{
if (_isDisposed) return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
_meetingCount = await TeamMeetingHistoryService.GetMeetingHistoryCountForTeamAsync(TeamId);
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Ignore errors - just don't show the badge
_meetingCount = null;
}
finally
{
if (!_isDisposed)
{
StateHasChanged();
}
}
}
private async Task OpenMeetingHistoryDialog()
{
if (_isDisposed) return;
var parameters = new DialogParameters
{
["TeamId"] = TeamId,
["TeamName"] = TeamName
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<TeamMeetingHistoryDialog>($"{TeamName} Meeting History", parameters, options);
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -1,5 +1,6 @@
@using WebApp.Models
@using Core.Utility
@using WebApp.Components.Features.Teams.Components
@if (Title != null)
{
@@ -28,6 +29,7 @@
@if (IsSelected(team))
{
var isExtended = IsExtended(team);
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudTooltip Text="@(isExtended ? "Remove from extended teams" : "Extend to 2 time slots")">
<MudIconButton Icon="@(isExtended ? Icons.Material.Filled.AddCircle : Icons.Material.Filled.Add)"
Size="Size.Small"
@@ -36,6 +38,8 @@
OnClick="@(() => ToggleExtended(team))"
Style="margin-left: 4px; padding: 2px;" />
</MudTooltip>
<TeamMeetingHistoryBadge TeamId="@team.Id" TeamName="@team.ToString()" />
</MudStack>
}
@if (ShowEventAttributes)
{
@@ -3,9 +3,11 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@using WebApp.Components.Features.Teams.Components
@using MudBlazor
@inject AppDbContext Context
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
@inject IDialogService DialogService
@if (Team is null)
{
@@ -29,6 +31,11 @@
Href="@($"/teams/edit?id={Team.Id}&returnUrl={ReturnUrl ?? "/teams"}")"
Variant="Variant.Outlined">Edit</MudButton>
</MudTooltip>
<MudTooltip Text="View Meeting History">
<MudButton StartIcon="@Icons.Material.Filled.History"
OnClick="ShowMeetingHistory"
Variant="Variant.Outlined">Meeting History</MudButton>
</MudTooltip>
</div>
</ActionButtons>
</PageHeader>
@@ -86,4 +93,26 @@
{
await JSRuntime.InvokeVoidAsync("window.print");
}
private async Task ShowMeetingHistory()
{
if (Team == null) return;
var teamName = Team.ToString();
var parameters = new DialogParameters
{
["TeamId"] = Team.Id,
["TeamName"] = teamName
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Medium,
FullWidth = true
};
await DialogService.ShowAsync<TeamMeetingHistoryDialog>($"{teamName} Meeting History", parameters, options);
}
}
@@ -19,6 +19,9 @@
<MudTooltip Text="Handout">
<MudButton StartIcon="@Icons.Material.Filled.Print" Href="teams/handout" Variant="Variant.Outlined">Handout</MudButton>
</MudTooltip>
<MudTooltip Text="State schedule handout (print)">
<MudButton StartIcon="@Icons.Material.Filled.CalendarMonth" Href="calendar/state-schedule-handout" Variant="Variant.Outlined">State schedule</MudButton>
</MudTooltip>
<MudTooltip Text="@(_showRegionalOnly ? "Showing Regional Only" : "Show Regional Only")">
<MudButton StartIcon="@Icons.Material.Filled.FilterAlt"
Variant="@(_showRegionalOnly ? Variant.Filled : Variant.Outlined)"
@@ -52,6 +55,7 @@
@context.Item.ToString()
</MudLink>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<TeamMeetingHistoryBadge TeamId="@context.Item.Id" TeamName="@context.Item.ToString()" />
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
TooltipText="Edit"
Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" />
@@ -1,4 +1,4 @@
@page "/teams/printout"
@page "/teams/printout"
@attribute [Authorize]
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@@ -41,9 +41,8 @@ else
@{
var students
= context.Students
.OrderByDescending(s => s == context.Captain)
.ThenBy(s => s.EventRankings.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue)
.ThenByDescending(e => e.Grade)
.OrderByDescending(s => context.Captain != null && context.Captain.Equals(s))
.ThenByDescending(e => e.Grade + e.TsaYear)
.ThenBy(e => e.FirstName)
.ToArray();
}
@@ -110,7 +109,7 @@ else
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Teams.Sum(e => e.Event.LevelOfEffort)</MudTd>
<MudTd>@context.Teams.Sum(e => e.Event?.LevelOfEffort ?? 0)</MudTd>
@{
var teams = context.Teams
.OrderBy(e =>
@@ -169,7 +168,7 @@ else
<MudTd>@context.Name</MudTd>
<MudTd>@AppIcons.GetOrdinal(context.Grade), @context.TsaYear</MudTd>
<MudTd>@context.Teams.Sum(e => e.Event.LevelOfEffort)
<MudTd>@context.Teams.Sum(e => e.Event?.LevelOfEffort ?? 0)
@if (!context.Teams.Select(e => e.Event).Any(re => re.OnSiteActivity))
{
<span>No On-Site Activity</span>
@@ -233,6 +232,7 @@ else
.AsNoTracking()
.Include(e => e.Event)
.Include(e => e.Students)
.Include(e => e.Captain)
.OrderByEventFormatFirst()
.ThenBy(e => e.Event.Name)
.ThenBy(e => e.Identifier ?? "")
@@ -244,6 +244,8 @@ else
.AsNoTracking()
.Include(e => e.Teams)
.ThenInclude(e => e.Captain)
.Include(e => e.Teams)
.ThenInclude(e => e.Event)
.Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName).ToArrayAsync();
@@ -0,0 +1,151 @@
@namespace WebApp.Components.Features.Teams
@using Core.Entities
@using WebApp.Services
@using Microsoft.AspNetCore.Components
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@implements IAsyncDisposable
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">@TeamName Meeting History</MudText>
</TitleContent>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_meetingHistories.Any())
{
<MudAlert Severity="Severity.Info">No meeting history found for this team.</MudAlert>
}
else
{
<MudTable Items="_meetingHistories" Hover="true" Dense="true">
<HeaderContent>
<MudTh>Date</MudTh>
<MudTh>Team Members</MudTh>
</HeaderContent>
<RowTemplate>
@{
var team = context.Teams.FirstOrDefault(t => t.Id == TeamId);
var presentStudentIds = context.Students.Select(s => s.Id).ToHashSet();
var absentStudents = team?.Students.Where(s => !presentStudentIds.Contains(s.Id)).ToList() ?? [];
}
<MudTd>@context.MeetingDate.ToString("MM/dd/yyyy")</MudTd>
<MudTd>
@if (team != null)
{
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
@foreach (var student in team.Students)
{
var isAbsent = absentStudents.Contains(student);
var formattedName = TeamStudentNameFormatter.FormatStudentName(
student,
team,
new TeamStudentNameFormatter.FormatOptions
{
MarkAbsent = true,
AbsentStudents = absentStudents
});
<MudChip T="string"
Size="Size.Small"
Color="Color.Default"
Variant="@AppIcons.StudentChipVariant()"
Style="@(isAbsent ? "opacity: 0.5;" : "")">
<span>@formattedName</span>
</MudChip>
}
</MudStack>
}
</MudTd>
</RowTemplate>
</MudTable>
}
</DialogContent>
<DialogActions>
<MudSpacer />
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int TeamId { get; set; }
[Parameter]
public string TeamName { get; set; } = string.Empty;
private List<TeamMeetingHistory> _meetingHistories = [];
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadMeetingHistories();
}
private async Task LoadMeetingHistories()
{
if (_isDisposed) return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var histories = await TeamMeetingHistoryService.GetMeetingHistoriesForTeamAsync(TeamId);
_meetingHistories = histories.ToList();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
System.Diagnostics.Debug.WriteLine($"Error loading meeting histories: {ex.Message}");
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private void Close()
{
if (_isDisposed) return;
MudDialog.Close();
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
+171 -20
View File
@@ -5,10 +5,14 @@
@using Core.Entities
@using WebApp.Services
@using WebApp.Components.Shared.Components
@using Heron.MudCalendar
@inject IConfiguration Configuration
@inject AppDbContext Context
@inject INotesService NotesService
@inject IDialogService DialogService
@inject ITeamMeetingHistoryService TeamMeetingHistoryService
@inject ICalendarService CalendarService
@inject NavigationManager Navigation
@implements IAsyncDisposable
<PageTitle>@Configuration["ChapterSettings:Name"] - TSA Chapter Organizer</PageTitle>
@@ -123,14 +127,77 @@ else
</MudPaper>
<MudGrid>
<DashboardCard Icon="@AppIcons.Scheduler"
Title="Meeting Schedule"
Caption="Optimize meeting times"
NavigateUrl="/meeting-schedule"/>
Title="Meeting Schedule Planner"
NavigateUrl="/meeting-schedule">
@if (_recentMeetings.Any())
{
<MudGrid Spacing="2">
<MudItem xs="12" sm="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight: 600;">Recent Meetings</MudText>
<MudStack Spacing="1">
@foreach (var meeting in _recentMeetings)
{
<div @onclick="@(() => ShowMeetingDetails(meeting))"
@onclick:stopPropagation="true"
style="cursor: pointer; color: var(--mud-palette-primary);">
<MudText Typo="Typo.body2" Style="text-decoration: underline;">
@meeting.MeetingDate.ToString("MMM d, yyyy")
</MudText>
</div>
}
</MudStack>
</MudItem>
<MudItem xs="12" sm="12" md="5">
<MudStack Spacing="1" AlignItems="AlignItems.Start">
<div @onclick:stopPropagation="true">
<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>
</div>
</MudStack>
</MudItem>
</MudGrid>
}
else
{
<MudText Typo="Typo.caption" Class="mt-2">Optimize meeting times</MudText>
}
</DashboardCard>
<DashboardCard Icon="@AppIcons.EventCalendar"
Title="Event Calendar"
Caption="Conference schedules"
NavigateUrl="/calendar"/>
NavigateUrl="/calendar">
@if (_nextCalendarItems.Any())
{
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="font-weight: 600;">Next Events</MudText>
<MudStack Spacing="1">
@foreach (var item in _nextCalendarItems)
{
var itemDate = item.Start.Date;
var itemName = item.ItemType == CalendarItemType.Event && item.EventItem != null
? (item.EventItem.EventDefinition?.ShortName ?? item.EventItem.EventOccurrenceData?.Name ?? "Event")
: "Team Meeting";
var dateStr = itemDate.ToString("yyyy-MM-dd");
<div @onclick="@(() => NavigateToCalendar(dateStr))"
@onclick:stopPropagation="true"
style="cursor: pointer; color: var(--mud-palette-primary);">
<MudText Typo="Typo.body2" Style="text-decoration: underline;">
@itemName - @itemDate.ToString("MMM d, yyyy")
</MudText>
</div>
}
</MudStack>
}
else
{
<MudText Typo="Typo.caption" Class="mt-2">Conference schedules</MudText>
}
</DashboardCard>
</MudGrid>
<MudPaper Elevation="0" Class="my-4">
@@ -150,21 +217,6 @@ else
NavigateUrl="/students/teams"/>
</MudGrid>
<MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Team Building</MudText>
</MudPaper>
<MudGrid>
<DashboardCard Icon="@AppIcons.EventRank"
Title="Event Ranking"
Caption="Student event preferences"
NavigateUrl="/students/event-ranking"/>
<DashboardCard Icon="@AppIcons.TeamAssignment"
Title="Team Assignment"
Caption="Build optimal teams"
NavigateUrl="/teams/assignment"/>
</MudGrid>
<MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Chapter Data</MudText>
</MudPaper>
@@ -189,6 +241,21 @@ else
Caption="@($"{_teamEventsCount} Team | {_individualEventsCount} Individual")"
NavigateUrl="/events" />
</MudGrid>
<MudPaper Elevation="0" Class="my-4">
<MudText Typo="Typo.h4">Team Building</MudText>
</MudPaper>
<MudGrid>
<DashboardCard Icon="@AppIcons.EventRank"
Title="Event Ranking"
Caption="Student event preferences"
NavigateUrl="/students/event-ranking"/>
<DashboardCard Icon="@AppIcons.TeamAssignment"
Title="Team Assignment"
Caption="Build optimal teams"
NavigateUrl="/teams/assignment"/>
</MudGrid>
}
@code {
@@ -200,6 +267,9 @@ else
private int _teamCount;
private int _individualTeamsCount;
private int _groupTeamsCount;
private int _meetingHistoryCount;
private List<TeamMeetingHistory> _recentMeetings = [];
private List<CalendarItemWrapper> _nextCalendarItems = [];
private List<Note> _pinnedNotes = [];
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
@@ -216,6 +286,8 @@ else
{
await LoadStatistics();
await LoadPinnedNotes();
await LoadMeetingHistoryCount();
await LoadNextCalendarItem();
}
private async Task HandleNoteChanged()
@@ -285,6 +357,85 @@ else
_groupTeamsCount = contextTeams.Count(e => e.Event.EventFormat == EventFormat.Team);
}
private async Task LoadMeetingHistoryCount()
{
if (_isDisposed) return;
try
{
var meetingHistories = await TeamMeetingHistoryService.GetMeetingHistoriesAsync();
var historiesList = meetingHistories.ToList();
_meetingHistoryCount = historiesList.Count;
// Get the 3 most recent meetings in descending order
_recentMeetings = historiesList
.OrderByDescending(m => m.MeetingDate)
.ThenByDescending(m => m.Id)
.Take(3)
.ToList();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Error loading meeting history - ignore
_meetingHistoryCount = 0;
_recentMeetings = [];
}
}
private async Task ShowMeetingDetails(TeamMeetingHistory meeting)
{
var parameters = new DialogParameters
{
["MeetingHistoryId"] = meeting.Id
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
CloseButton = true,
MaxWidth = MaxWidth.Large,
FullWidth = true
};
await DialogService.ShowAsync<MeetingHistoryDetailDialog>("Meeting Details", parameters, options);
}
private async Task LoadNextCalendarItem()
{
if (_isDisposed) return;
try
{
_nextCalendarItems = await CalendarService.GetUpcomingCalendarItemsAsync(3);
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception)
{
// Error loading calendar items - ignore
_nextCalendarItems = [];
}
}
private void NavigateToCalendar(string date)
{
Navigation.NavigateTo($"/calendar?date={date}");
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
+4 -2
View File
@@ -1,8 +1,10 @@
@using Core.Entities
@using Core.Services
@using WebApp.Services
@using PSC.Blazor.Components.MarkdownEditor
@using MudBlazor
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject MarkdownTablePasteService MarkdownTablePasteService
@@ -45,7 +47,7 @@
private Note _note = null!;
private bool _pasteMarkdownInitialized = false;
private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false;
private bool IsPageNote => Note?.Title != null && NoteNamingService.IsPageNote(Note.Title);
protected override void OnInitialized()
{
@@ -99,6 +101,6 @@
private void Cancel()
{
MudDialog.Cancel();
MudDialog.Close(DialogResult.Cancel());
}
}
+4 -2
View File
@@ -2,9 +2,11 @@
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Entities
@using Core.Services
@using WebApp.Services
@using WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager NavigationManager
@@ -67,7 +69,7 @@
@GetNoteHeaderText(note, isExpanded)
</span>
</MudText>
@if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded)
@if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded)
{
<MudButton StartIcon="@(note.IsPinned ? Icons.Material.Filled.PushPin : Icons.Material.Outlined.PushPin)"
OnClick="() => TogglePin(note)"
@@ -386,7 +388,7 @@
return false;
// Can't pin page notes
if (note.Title.StartsWith("@"))
if (NoteNamingService.IsPageNote(note.Title))
return true;
// Can't pin deleted notes
@@ -0,0 +1,54 @@
@namespace WebApp.Components.Shared.Components
<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">
<MudTooltip Text="@AddTooltip">
<MudIconButton Icon="@Icons.Material.Filled.Add"
Variant="Variant.Outlined"
Color="Color.Primary"
OnClick="HandleAdd"
Size="Size.Small" />
</MudTooltip>
<MudText Typo="Typo.body2" Style="flex: 1; text-align: center;">@Label</MudText>
<MudTooltip Text="@RemoveTooltip">
<MudIconButton Icon="@Icons.Material.Filled.Remove"
Variant="Variant.Outlined"
Color="Color.Error"
OnClick="HandleRemove"
Size="Size.Small" />
</MudTooltip>
</MudStack>
</MudPaper>
@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();
}
}
}
@@ -6,9 +6,7 @@
Class="@WrapperClass"
@onmouseenter="@(() => _isHovered = true)"
@onmouseleave="@(() => _isHovered = false)"
@ontouchstart="@(() => _isTouched = true)"
@ontouchend="@(() => { /* Prevent mouse events on touch */ })"
@onclick="@HandleClick">
@ontouchstart="@HandleTouchStart">
<MudChip T="string"
Size="@Size"
Color="@Color"
@@ -69,12 +67,9 @@
private bool _isHovered = false;
private bool _isTouched = false;
private void HandleClick(MouseEventArgs e)
{
// On touch devices, toggle controls on tap (for devices with both touch and mouse)
if (_isTouched)
private void HandleTouchStart(TouchEventArgs e)
{
_isTouched = !_isTouched;
}
StateHasChanged();
}
}
@@ -3,6 +3,10 @@
@inject IDialogService DialogService
@implements IAsyncDisposable
<div @ref="_anchorElement"
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
@onmouseleave="@(() => _popoverOpen = false)"
style="display: inline-block;">
<MudButton StartIcon="@IconValue"
OnClick="OpenDialog"
Variant="@Variant"
@@ -15,6 +19,29 @@
@ButtonText
}
</MudButton>
</div>
<MudPopover @bind-Open="_popoverOpen"
AnchorOrigin="Origin.BottomCenter"
TransformOrigin="Origin.TopCenter"
Elevation="8"
Anchor="@_anchorElement">
<ChildContent>
@if (_hasContent && !string.IsNullOrWhiteSpace(_noteContent))
{
<MudPaper Elevation="0"
Class="pa-3"
Style="max-width: 500px; max-height: 400px; overflow-y: auto;"
@onmouseenter="@(() => _popoverOpen = true)"
@onmouseleave="@(() => _popoverOpen = false)">
<MudText Typo="Typo.subtitle1" Class="mb-2">@PageIdentifier</MudText>
<MudDivider Class="my-2" />
<div class="markdown-content" style="max-height: 350px; overflow-y: auto;">
@((MarkupString)MarkdownHelper.ToHtml(_noteContent))
</div>
</MudPaper>
}
</ChildContent>
</MudPopover>
@code {
[Parameter]
@@ -39,6 +66,9 @@
private string TooltipText => Tooltip ?? $"Page notes for {PageIdentifier}";
private bool _hasContent = false;
private int _noteId = 0;
private string _noteContent = string.Empty;
private bool _popoverOpen = false;
private ElementReference _anchorElement;
private Color ButtonColor => _hasContent ? Color.Success : (Variant == Variant.Filled ? Color.Primary : Color.Default);
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
@@ -62,6 +92,7 @@
var note = await NotesService.GetPageNoteAsync(PageIdentifier);
_hasContent = note != null && !string.IsNullOrWhiteSpace(note.Content);
_noteId = note?.Id ?? 0;
_noteContent = note?.Content ?? string.Empty;
if (!_isDisposed)
{
@@ -1,5 +1,7 @@
@namespace WebApp.Components.Shared.Components
@using Core.Services
@inject INotesService NotesService
@inject INoteNamingService NoteNamingService
@inject ISnackbar Snackbar
@inject MarkdownTablePasteService MarkdownTablePasteService
@implements IAsyncDisposable
@@ -58,7 +60,7 @@
Disabled="@_isLoading">
Edit
</MudButton>
<MudButton OnClick="Cancel">Close</MudButton>
<MudButton OnClick="Cancel">Cancel</MudButton>
}
</DialogActions>
</MudDialog>
@@ -83,7 +85,7 @@
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
_pageNoteTitle = $"@{PageIdentifier}";
_pageNoteTitle = NoteNamingService.GetPageNoteTitle(PageIdentifier);
_note = new Note
{
Title = _pageNoteTitle,
@@ -240,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());
}
}
@@ -18,16 +18,16 @@
<MudNavLink Href="/students/teams" Icon="@AppIcons.Registration">Registration</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Team Building" Icon="@Icons.Material.Filled.GroupAdd" Expanded="false">
<MudNavLink Href="/students/event-ranking" Icon="@AppIcons.EventRank">Event Ranking</MudNavLink>
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Chapter Data" Icon="@Icons.Material.Filled.Storage" Expanded="false">
<MudNavLink Href="/students" Icon="@Icons.Material.Filled.People">Students</MudNavLink>
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Team Building" Icon="@Icons.Material.Filled.GroupAdd" Expanded="false">
<MudNavLink Href="/students/event-ranking" Icon="@AppIcons.EventRank">Event Ranking</MudNavLink>
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
</MudNavGroup>
+71
View File
@@ -0,0 +1,71 @@
using Heron.MudCalendar;
namespace WebApp.Models;
/// <summary>
/// Type discriminator for calendar item types.
/// </summary>
public enum CalendarItemType
{
Event,
Meeting
}
/// <summary>
/// Wrapper class for calendar items that can hold either a CalendarEventItem or CalendarMeetingItem.
/// This allows MudCalendar to display mixed item types while maintaining type safety.
/// </summary>
public class CalendarItemWrapper : CalendarItem
{
/// <summary>
/// Gets the type of calendar item this wrapper contains.
/// </summary>
public CalendarItemType ItemType { get; private set; }
/// <summary>
/// Gets the wrapped CalendarEventItem if ItemType is Event, otherwise null.
/// </summary>
public CalendarEventItem? EventItem { get; private set; }
/// <summary>
/// Gets the wrapped CalendarMeetingItem if ItemType is Meeting, otherwise null.
/// </summary>
public CalendarMeetingItem? MeetingItem { get; private set; }
/// <summary>
/// Parameterless constructor required by Heron.MudCalendar component.
/// </summary>
public CalendarItemWrapper()
{
// Initialize base class properties to avoid null reference issues
Text = string.Empty;
Start = DateTime.MinValue;
End = DateTime.MinValue;
}
/// <summary>
/// Creates a wrapper for a CalendarEventItem.
/// </summary>
public CalendarItemWrapper(CalendarEventItem eventItem)
{
ItemType = CalendarItemType.Event;
EventItem = eventItem;
// Delegate properties to wrapped item
Text = eventItem.Text;
Start = eventItem.Start;
End = eventItem.End;
}
/// <summary>
/// Creates a wrapper for a CalendarMeetingItem.
/// </summary>
public CalendarItemWrapper(CalendarMeetingItem meetingItem)
{
ItemType = CalendarItemType.Meeting;
MeetingItem = meetingItem;
// Delegate properties to wrapped item
Text = meetingItem.Text;
Start = meetingItem.Start;
End = meetingItem.End;
}
}
+38
View File
@@ -0,0 +1,38 @@
using Core.Entities;
using Heron.MudCalendar;
namespace WebApp.Models;
/// <summary>
/// Calendar meeting item model for Heron.MudCalendar component.
/// Maps from TeamMeetingHistory entity to calendar event format.
/// </summary>
public class CalendarMeetingItem : CalendarItem
{
/// <summary>
/// Gets the original TeamMeetingHistory data.
/// </summary>
public TeamMeetingHistory? MeetingHistoryData { get; set; }
/// <summary>
/// Parameterless constructor required by Heron.MudCalendar component.
/// </summary>
public CalendarMeetingItem()
{
// Initialize base class properties to avoid null reference issues
Text = string.Empty;
Start = DateTime.MinValue;
End = DateTime.MinValue;
}
public CalendarMeetingItem(TeamMeetingHistory meetingHistory)
{
MeetingHistoryData = meetingHistory;
// Set base class properties that the calendar component uses
Text = "Team Meeting";
// Set start to 9:00 AM on the meeting date
Start = meetingHistory.MeetingDate.Date.AddHours(9);
// Set end to 5:00 PM on the meeting date (5:01 PM to ensure it displays until 5pm if end is exclusive)
End = meetingHistory.MeetingDate.Date.AddHours(17).AddMinutes(1);
}
}
+5
View File
@@ -37,6 +37,11 @@ public class ChapterSettings
/// </summary>
public string CompetitionYear { get; set; } = "2026";
/// <summary>
/// Postal state abbreviation for printed schedules (example placeholder: "ST").
/// </summary>
public string StateAbbrev { get; set; } = "ST";
/// <summary>
/// School level for the chapter (null = import both MS and HS events)
/// </summary>
+117
View File
@@ -0,0 +1,117 @@
using Core.Entities;
namespace WebApp.Models;
/// <summary>
/// Represents the state of the meeting schedule for dirty/clean tracking and persistence.
/// </summary>
public class MeetingScheduleState : IEquatable<MeetingScheduleState>
{
public HashSet<int> ScheduledTeamIds { get; set; } = [];
public HashSet<int> AbsentStudentIds { get; set; } = [];
public int TimeSlotCount { get; set; }
public HashSet<int> 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<Team> scheduledTeams,
IEnumerable<Student> absentStudents,
int timeSlotCount,
IEnumerable<Team> 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<MeetingScheduleState?> 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<ExcludedStudent[]>("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);
}
@@ -0,0 +1,33 @@
namespace WebApp.Models;
/// <summary>
/// Per-student handout filters; section <see cref="SectionName"/>. Edit in Data/appsettings.json, save, refresh the page (no redeploy).
/// </summary>
public class StateScheduleHandoutOptions
{
public const string SectionName = "ChapterSettings:StateScheduleHandout";
/// <summary>
/// <see cref="Core.Entities.EventOccurrence.SpecialEventType"/> values allowed on student pages.
/// Gated in code (omit here): VotingDelegateMeeting, MeetTheCandidates, ChapterOfficerMeeting.
/// </summary>
public string[] StudentSpecialEventTypes { get; set; } =
[
"GeneralSchedule",
"SocialGathering"
];
/// <summary>
/// Occurrence <see cref="Core.Entities.EventOccurrence.Name"/> substrings that exclude a row from student pages (case-insensitive).
/// Master schedule still lists all occurrences.
/// </summary>
public string[] StudentExcludeOccurrenceNameSubstrings { get; set; } =
[
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
];
}
+17 -8
View File
@@ -11,6 +11,7 @@ using WebApp;
using WebApp.Authentication;
using WebApp.Components;
using WebApp.Logging;
using WebApp.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -101,17 +102,16 @@ builder.Host.UseSerilog((context, configuration) =>
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
});
// Configure authentication secrets for production (Docker, etc.)
if (builder.Environment.IsProduction())
// Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(authSecretsPath))
{
// Option 1: Load from volume-mounted secrets file in Data directory
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(secretsPath))
{
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
}
// Option 2: Environment variables with prefix
if (builder.Environment.IsProduction())
{
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
}
@@ -194,7 +194,16 @@ builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.ITeamMeetingHistoryService, WebApp.Services.TeamMeetingHistoryService>();
builder.Services.AddScoped<WebApp.Services.ICalendarService, WebApp.Services.CalendarService>();
builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
// State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>();
+144
View File
@@ -0,0 +1,144 @@
using Core.Entities;
using Core.Utility;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Service for calendar-related operations.
/// </summary>
public class CalendarService : ICalendarService
{
private readonly IEventOccurrenceService _eventOccurrenceService;
private readonly ITeamMeetingHistoryService _teamMeetingHistoryService;
public CalendarService(
IEventOccurrenceService eventOccurrenceService,
ITeamMeetingHistoryService teamMeetingHistoryService)
{
_eventOccurrenceService = eventOccurrenceService;
_teamMeetingHistoryService = teamMeetingHistoryService;
}
public async Task<List<CalendarItemWrapper>> GetAllCalendarItemsAsync()
{
var items = new List<CalendarItemWrapper>();
await AddEventItemsAsync(items, includeStudentNames: true);
await AddMeetingItemsAsync(items);
return items;
}
public async Task<List<CalendarItemWrapper>> GetUpcomingCalendarItemsAsync(int count = 3)
{
var today = DateTime.Today;
var items = new List<CalendarItemWrapper>();
await AddEventItemsAsync(items, startDate: today, includeStudentNames: false);
await AddMeetingItemsAsync(items, startDate: today);
// Sort by date and take top N
return items
.OrderBy(item => item.Start)
.Take(count)
.ToList();
}
private async Task AddEventItemsAsync(
List<CalendarItemWrapper> items,
DateTime? startDate = null,
bool includeStudentNames = false)
{
try
{
var occurrences = await _eventOccurrenceService.GetEventOccurrencesAsync();
var eventOccurrences = occurrences as EventOccurrence[] ?? occurrences.ToArray();
// Filter by start date if provided
if (startDate.HasValue)
{
eventOccurrences = eventOccurrences
.Where(occ => occ.StartTime.Date >= startDate.Value)
.ToArray();
}
Dictionary<int, List<Team>>? teamsByEventId = null;
if (includeStudentNames)
{
// Get all unique event definition IDs that have occurrences
var eventDefinitionIds = eventOccurrences
.Where(occ => occ?.EventDefinition?.Id != null)
.Select(occ => occ!.EventDefinition!.Id)
.Distinct()
.ToList();
// Load teams for all event definitions
teamsByEventId = await _eventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
}
// Add event occurrences
foreach (var occ in eventOccurrences)
{
try
{
List<string>? studentFirstNames = null;
if (includeStudentNames && occ.EventDefinition != null && teamsByEventId != null)
{
studentFirstNames = teamsByEventId.TryGetValue(occ.EventDefinition.Id, out var teams)
? TeamStudentNameFormatter.FormatStudentListForEvent(
occ.EventDefinition,
teams,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Star,
Ordering = TeamStudentNameFormatter.OrderingStyle.Alphabetical
})
: [];
}
var calendarEventItem = new CalendarEventItem(occ, studentFirstNames);
items.Add(new CalendarItemWrapper(calendarEventItem));
}
catch (Exception)
{
// Continue processing other items
}
}
}
catch (Exception)
{
// Continue - don't fail the entire calendar load if events fail
}
}
private async Task AddMeetingItemsAsync(
List<CalendarItemWrapper> items,
DateTime? startDate = null)
{
try
{
var meetingHistories = await _teamMeetingHistoryService.GetMeetingHistoriesAsync();
IEnumerable<TeamMeetingHistory> meetings = meetingHistories;
if (startDate.HasValue)
{
meetings = meetings.Where(m => m.MeetingDate.Date >= startDate.Value);
}
foreach (var meetingHistory in meetings)
{
try
{
var calendarMeetingItem = new CalendarMeetingItem(meetingHistory);
items.Add(new CalendarItemWrapper(calendarMeetingItem));
}
catch (Exception)
{
// Continue processing other items
}
}
}
catch (Exception)
{
// Continue - don't fail the entire calendar load if meetings fail
}
}
}
+4 -2
View File
@@ -38,9 +38,11 @@ public class EventDefinitionService
return;
}
// Get all existing careers from database (case-insensitive lookup)
// Get existing careers with tracking so we use the same instances the context
// may already be tracking (e.g. from the event's RelatedCareers). Using
// AsNoTracking() would create duplicate instances and cause "another instance
// with the same key value is already being tracked".
var existingCareers = await _context.Careers
.AsNoTracking()
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.ToListAsync();
+3 -2
View File
@@ -39,13 +39,14 @@ public class EventOccurrenceService : IEventOccurrenceService
}
var teams = await _context.Teams
.Include(t => t.Event)
.Include(t => t.Students)
.Include(t => t.Captain)
.Where(t => ids.Contains(t.Event.Id))
.Where(t => t.Event != null && ids.Contains(t.Event.Id))
.ToListAsync();
return teams
.GroupBy(t => t.Event.Id)
.GroupBy(t => t.Event!.Id)
.ToDictionary(g => g.Key, g => g.ToList());
}
}
+25
View File
@@ -0,0 +1,25 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Service for calendar-related operations.
/// </summary>
public interface ICalendarService
{
/// <summary>
/// Gets all calendar items (events and meetings) for display in the calendar.
/// Includes student names for events.
/// </summary>
/// <returns>A list of CalendarItemWrapper objects ready for display.</returns>
Task<List<CalendarItemWrapper>> GetAllCalendarItemsAsync();
/// <summary>
/// Gets the next N upcoming calendar items (events and meetings) starting from today.
/// Returns CalendarItemWrapper objects ready for display.
/// </summary>
/// <param name="count">The maximum number of items to return. Default is 3.</param>
/// <returns>A list of CalendarItemWrapper objects ready for display.</returns>
Task<List<CalendarItemWrapper>> GetUpcomingCalendarItemsAsync(int count = 3);
}
@@ -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);
}
+1 -1
View File
@@ -19,7 +19,7 @@ public interface INotesService
/// Gets a page-specific note by page identifier.
/// </summary>
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
/// <returns>The note with title "@{pageIdentifier}" or null if not found</returns>
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
@@ -0,0 +1,65 @@
using Core.Entities;
namespace WebApp.Services;
public interface ITeamMeetingHistoryService
{
/// <summary>
/// Gets all meeting histories within an optional date range.
/// </summary>
/// <param name="startDate">Optional start date filter</param>
/// <param name="endDate">Optional end date filter</param>
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null);
/// <summary>
/// Gets a specific meeting history by ID with teams and students included.
/// </summary>
Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id);
/// <summary>
/// Creates a new meeting history record.
/// </summary>
Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Updates an existing meeting history record.
/// </summary>
Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Deletes a meeting history record.
/// </summary>
Task DeleteMeetingHistoryAsync(int id);
/// <summary>
/// Gets which teams met on a specific date.
/// </summary>
Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date);
/// <summary>
/// Gets which students were present on a specific date.
/// </summary>
Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date);
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
Task<Note?> GetMeetingNoteAsync(DateTime meetingDate);
/// <summary>
/// Gets all meeting histories for a specific team, ordered by date descending.
/// </summary>
/// <param name="teamId">The ID of the team</param>
/// <returns>Meeting histories for the team, ordered by date descending</returns>
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesForTeamAsync(int teamId);
/// <summary>
/// Gets the count of meeting histories for a specific team.
/// This is more efficient than loading all histories just to count them.
/// </summary>
/// <param name="teamId">The ID of the team</param>
/// <returns>The number of meeting histories for the team</returns>
Task<int> GetMeetingHistoryCountForTeamAsync(int teamId);
}
@@ -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);
}
}
+12 -8
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
@@ -10,15 +11,18 @@ public class NotesService : INotesService
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<NotesService> _logger;
private readonly INoteNamingService _noteNamingService;
public NotesService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<NotesService> logger)
ILogger<NotesService> logger,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_noteNamingService = noteNamingService;
}
private string? GetCurrentUserEmail()
@@ -42,7 +46,7 @@ public class NotesService : INotesService
}
return await query
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.ToListAsync();
}
@@ -56,7 +60,7 @@ public class NotesService : INotesService
public async Task<Note?> GetPageNoteAsync(string pageIdentifier)
{
var pageNoteTitle = $"@{pageIdentifier}";
var pageNoteTitle = _noteNamingService.GetPageNoteTitle(pageIdentifier);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
@@ -184,7 +188,7 @@ public class NotesService : INotesService
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted)
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderByDescending(n => n.UpdatedAt)
.Take(3)
.ToListAsync();
@@ -201,7 +205,7 @@ public class NotesService : INotesService
}
// Prevent pinning page notes
if (note.Title.StartsWith("@"))
if (_noteNamingService.IsPageNote(note.Title))
{
throw new InvalidOperationException("Page notes cannot be pinned.");
}
@@ -213,13 +217,13 @@ public class NotesService : INotesService
if (!note.IsPinned)
{
var pinnedCount = await _context.Notes
.CountAsync(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted);
.CountAsync(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted);
if (pinnedCount >= 3)
{
// Unpin the oldest pinned note
var oldestPinned = await _context.Notes
.Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted)
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderBy(n => n.UpdatedAt)
.FirstOrDefaultAsync();
@@ -250,7 +254,7 @@ public class NotesService : INotesService
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsDeleted)
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.ToListAsync();
}
@@ -0,0 +1,228 @@
using Core.Entities;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace WebApp.Services;
public class TeamMeetingHistoryService : ITeamMeetingHistoryService
{
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<TeamMeetingHistoryService> _logger;
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
public TeamMeetingHistoryService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<TeamMeetingHistoryService> logger,
INotesService notesService,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_notesService = notesService;
_noteNamingService = noteNamingService;
}
private string? GetCurrentUserEmail()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user == null)
return null;
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
}
public async Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null)
{
var query = _context.TeamMeetingHistories
.AsNoTracking()
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Students)
.AsQueryable();
if (startDate.HasValue)
{
var start = startDate.Value.Date;
query = query.Where(tmh => tmh.MeetingDate >= start);
}
if (endDate.HasValue)
{
var end = endDate.Value.Date.AddDays(1); // Include the entire end date
query = query.Where(tmh => tmh.MeetingDate < end);
}
return await query
.OrderByDescending(tmh => tmh.MeetingDate)
.ToListAsync();
}
public async Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id)
{
return await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Students)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == id);
}
public async Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
// Create a new meeting history entity to avoid tracking conflicts
var newMeetingHistory = new TeamMeetingHistory
{
MeetingDate = meetingHistory.MeetingDate.Date // Normalize to date only
};
// Attach teams by loading them from the database to avoid tracking conflicts
var teamIds = meetingHistory.Teams.Select(t => t.Id).ToList();
var teams = await _context.Teams
.Where(t => teamIds.Contains(t.Id))
.ToListAsync();
// Attach students by loading them from the database to avoid tracking conflicts
var studentIds = meetingHistory.Students.Select(s => s.Id).ToList();
var students = await _context.Students
.Where(s => studentIds.Contains(s.Id))
.ToListAsync();
// Attach teams and students to the new meeting history
newMeetingHistory.Teams = teams;
newMeetingHistory.Students = students;
_context.TeamMeetingHistories.Add(newMeetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history created: {MeetingHistoryId} for date {MeetingDate}",
newMeetingHistory.Id, newMeetingHistory.MeetingDate);
return newMeetingHistory;
}
public async Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
var existingHistory = await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == meetingHistory.Id);
if (existingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {meetingHistory.Id} not found.");
}
// Update properties
existingHistory.MeetingDate = meetingHistory.MeetingDate.Date; // Normalize to date only
// Update teams
existingHistory.Teams.Clear();
foreach (var team in meetingHistory.Teams)
{
var existingTeam = await _context.Teams.FindAsync(team.Id);
if (existingTeam != null)
{
existingHistory.Teams.Add(existingTeam);
}
}
// Update students
existingHistory.Students.Clear();
foreach (var student in meetingHistory.Students)
{
var existingStudent = await _context.Students.FindAsync(student.Id);
if (existingStudent != null)
{
existingHistory.Students.Add(existingStudent);
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history updated: {MeetingHistoryId} by {User}",
meetingHistory.Id, GetCurrentUserEmail());
return existingHistory;
}
public async Task DeleteMeetingHistoryAsync(int id)
{
var meetingHistory = await _context.TeamMeetingHistories
.FirstOrDefaultAsync(tmh => tmh.Id == id);
if (meetingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {id} not found.");
}
_context.TeamMeetingHistories.Remove(meetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history deleted: {MeetingHistoryId}", id);
}
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
public async Task<Note?> GetMeetingNoteAsync(DateTime meetingDate)
{
var noteTitle = _noteNamingService.GetMeetingNoteTitle(meetingDate);
var notes = await _notesService.GetNotesAsync(includeDeleted: false);
return notes.FirstOrDefault(n => n.Title == noteTitle);
}
public async Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Teams)
.Include(t => t.Event)
.Distinct()
.ToListAsync();
}
public async Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Students)
.Distinct()
.ToListAsync();
}
public async Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesForTeamAsync(int teamId)
{
return await _context.TeamMeetingHistories
.AsNoTracking()
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Students)
.Include(tmh => tmh.Students)
.Where(tmh => tmh.Teams.Any(t => t.Id == teamId))
.OrderByDescending(tmh => tmh.MeetingDate)
.ThenByDescending(tmh => tmh.Id)
.ToListAsync();
}
public async Task<int> GetMeetingHistoryCountForTeamAsync(int teamId)
{
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.Teams.Any(t => t.Id == teamId))
.CountAsync();
}
}
@@ -0,0 +1,79 @@
using Core.Entities;
using WebApp.Models;
namespace WebApp.Utility;
/// <summary>
/// Determines which special <see cref="EventOccurrence"/> rows belong on a per-student handout.
/// </summary>
public static class StateScheduleOccurrenceFilter
{
public static bool NameMatchesStudentExclude(string? name, StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(name)) return false;
foreach (var sub in options.StudentExcludeOccurrenceNameSubstrings ?? [])
{
if (string.IsNullOrWhiteSpace(sub)) continue;
if (name.Contains(sub.Trim(), StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// True when the occurrence name refers to Meet the Candidates (covers General Schedule lines duplicated under another type).
/// </summary>
public static bool NameLooksLikeMeetTheCandidates(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Meet the Candidate", StringComparison.OrdinalIgnoreCase);
public static bool NameLooksLikeChapterOfficerMeeting(string? name) =>
!string.IsNullOrEmpty(name) &&
name.Contains("Chapter Officer Meeting", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Special rows have <see cref="EventOccurrence.EventDefinitionId"/> null and <see cref="EventOccurrence.SpecialEventType"/> set.
/// </summary>
public static bool IncludeSpecialOccurrenceForStudent(
EventOccurrence occurrence,
Student student,
StateScheduleHandoutOptions options)
{
if (string.IsNullOrEmpty(occurrence.SpecialEventType))
return false;
if (NameMatchesStudentExclude(occurrence.Name, options))
return false;
if (occurrence.SpecialEventType == "VotingDelegateMeeting")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "MeetTheCandidates")
return student.VotingDelegate;
if (occurrence.SpecialEventType == "ChapterOfficerMeeting")
return student.OfficerRole.HasValue;
// Same event often appears once as MeetTheCandidates and again under GeneralSchedule; non-delegates should see neither.
if (!student.VotingDelegate && NameLooksLikeMeetTheCandidates(occurrence.Name))
return false;
// Chapter Officer Meeting lines under GeneralSchedule for non-officers
if (!student.OfficerRole.HasValue && NameLooksLikeChapterOfficerMeeting(occurrence.Name))
return false;
var allowed = options.StudentSpecialEventTypes ?? [];
return allowed.Contains(occurrence.SpecialEventType);
}
/// <summary>
/// Competition rows: optional name-based exclusion on student pages.
/// </summary>
public static bool IncludeCompetitionOccurrenceForStudent(EventOccurrence occurrence, StateScheduleHandoutOptions options)
{
if (occurrence.EventDefinitionId == null)
return false;
return !NameMatchesStudentExclude(occurrence.Name, options);
}
}
+16 -1
View File
@@ -16,7 +16,22 @@
"NationalId": "0000",
"StateId": "00000",
"RegionalId": "00000",
"CompetitionYear": "2026"
"CompetitionYear": "2026",
"StateAbbrev": "ST",
"StateScheduleHandout": {
"StudentSpecialEventTypes": [
"GeneralSchedule",
"SocialGathering"
],
"StudentExcludeOccurrenceNameSubstrings": [
"Store",
"TECHSPO",
"Tech Expo",
"Senior Social",
"Help Desk",
"Mandatory Advisor Meeting"
]
}
},
"ValidationSettings": {
"MinRecommendedEvents": 2,
+109
View File
@@ -48,6 +48,76 @@
white-space: pre-wrap;
}
.state-schedule-table {
width: 100%;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top !important;
}
.combined-sub-event {
padding-left: 1.5rem !important;
}
@media print {
.state-schedule-handout {
margin-left: -30pt !important;
margin-right: -12pt !important;
padding-left: 0 !important;
padding-right: 0 !important;
width: calc(100% + 42pt) !important;
max-width: none !important;
}
.state-schedule-handout .mud-paper,
.state-schedule-handout .mud-table-container,
.state-schedule-handout .mud-table {
box-shadow: none !important;
}
.state-schedule-table,
.state-schedule-table table {
width: 100%;
border-collapse: collapse;
}
.state-schedule-table th,
.state-schedule-table td,
.state-schedule-table table th,
.state-schedule-table table td {
vertical-align: top;
padding: 4px 8px;
border-top: none !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
.state-schedule-table thead th,
.state-schedule-table table thead th {
border-bottom: 2px solid #000 !important;
}
.state-schedule-table tbody td,
.state-schedule-table table tbody td {
border-bottom: 1px solid #000 !important;
}
.state-schedule-table tbody tr:last-child td,
.state-schedule-table table tbody tr:last-child td {
border-bottom: none !important;
}
.state-schedule-table .combined-sub-event,
.state-schedule-table table .combined-sub-event {
padding-left: 1.5rem !important;
}
}
.page-header {
margin-bottom: 1.5rem;
}
@@ -266,3 +336,42 @@
.note-color-2 {
background-color: #f3e5f5;
}
/* Calendar event item styling */
.calendar-event-item {
background-color: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
width: 100% !important;
height: 100% !important;
display: block !important;
box-sizing: border-box;
text-align: center;
}
/* Ensure tooltip wrapper doesn't constrain width or height and adds spacing */
.event-calendar .mud-tooltip-root {
width: 100%;
height: 100%;
display: block;
padding: 2px;
box-sizing: border-box;
}
.calendar-event-item:hover {
background-color: var(--mud-palette-primary-darken);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
}
.calendar-event-item:active {
background-color: var(--mud-palette-primary-darken);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}