Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f400813667 | ||
|
|
4dcd9e5aab | ||
|
|
336bbb1dec | ||
|
|
675f04afec | ||
|
|
84eaf338a9 | ||
|
|
15d7edec8f | ||
|
|
46836fde2e | ||
|
|
d0ce71397b | ||
|
|
840a8edbf1 | ||
|
|
680f61241a | ||
|
|
083e81aa25 | ||
|
|
bba0f5f618 | ||
|
|
9ab241ed77 | ||
|
|
804e12ca22 | ||
|
|
cc6e0d71a7 | ||
|
|
a503655f97 | ||
|
|
6e2834f2be | ||
|
|
48861eb6a6 | ||
|
|
455be30821 | ||
|
|
ddb743847d | ||
|
|
649a0061cf | ||
|
|
6bc4c2e7f2 | ||
|
|
9ed9c93540 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})" : "")}";
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Core.Entities;
|
||||
|
||||
namespace Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Groups parsed occurrences by event definition and optional section school level from headers
|
||||
/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same <see cref="EventDefinition"/>
|
||||
/// can appear in multiple groups.
|
||||
/// </summary>
|
||||
public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel);
|
||||
@@ -9,11 +9,11 @@ namespace Core.Models;
|
||||
public class EventOccurrenceParseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Dictionary of parsed event occurrences, keyed by EventDefinition.
|
||||
/// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering),
|
||||
/// the EventDefinition key will be the static instance.
|
||||
/// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
|
||||
/// Special events use <see cref="EventOccurrenceParseGroup.EventDefinition"/> static instances with
|
||||
/// <see cref="EventOccurrenceParseGroup.SectionSchoolLevel"/> typically null.
|
||||
/// </summary>
|
||||
public IDictionary<EventDefinition, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<EventOccurrence>>();
|
||||
public IDictionary<EventOccurrenceParseGroup, List<EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<EventOccurrence>>();
|
||||
|
||||
/// <summary>
|
||||
/// List of parsing errors (critical issues that prevented parsing).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
|
||||
@@ -12,7 +12,7 @@ namespace Core.Parsers;
|
||||
/// </summary>
|
||||
public class EventOccurrenceParserResult
|
||||
{
|
||||
public IDictionary<EventDefinition, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventDefinition, List<Entities.EventOccurrence>>();
|
||||
public IDictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>> Occurrences { get; set; } = new Dictionary<EventOccurrenceParseGroup, List<Entities.EventOccurrence>>();
|
||||
public List<ParsingIssue> Issues { get; set; } = new();
|
||||
public List<string> SkippedSectionHeaders { get; set; } = new();
|
||||
public int SkippedEventCount { get; set; }
|
||||
@@ -296,12 +296,14 @@ public class EventOccurrenceParser
|
||||
Location = location
|
||||
};
|
||||
|
||||
if (!occurrences.ContainsKey(eventDefinition))
|
||||
occurrences.Add(eventDefinition, []);
|
||||
occurrences[eventDefinition].Add(eventOccurrence);
|
||||
|
||||
// Reset section level when we successfully parse an occurrence (means we're in a valid section)
|
||||
currentSectionLevel = null;
|
||||
var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
|
||||
if (!occurrences.TryGetValue(groupKey, out var groupList))
|
||||
{
|
||||
groupList = [];
|
||||
occurrences[groupKey] = groupList;
|
||||
}
|
||||
|
||||
groupList.Add(eventOccurrence);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -65,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
||||
// Convert parsed occurrences to result format, handling special event types
|
||||
foreach (var kvp in parsedOccurrences)
|
||||
{
|
||||
var eventDefinition = kvp.Key;
|
||||
var group = kvp.Key;
|
||||
var eventDefinition = group.EventDefinition;
|
||||
var occurrences = kvp.Value;
|
||||
|
||||
// Check if this is a special event type (not stored in database)
|
||||
@@ -90,8 +91,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
||||
};
|
||||
}
|
||||
|
||||
// Add to result with the special EventDefinition as key
|
||||
result.Occurrences[eventDefinition] = occurrences;
|
||||
result.Occurrences[group] = occurrences;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
|
||||
occurrence.SpecialEventType = null;
|
||||
}
|
||||
|
||||
result.Occurrences[eventDefinition] = occurrences;
|
||||
result.Occurrences[group] = occurrences;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoogleSheetsScheduleImport", "tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj", "{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
|
||||
@@ -35,6 +37,10 @@ Global
|
||||
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class GlobalEventDeduplicatorTests
|
||||
{
|
||||
[Test]
|
||||
public void Curfew_same_time_multiple_locations_becomes_one_line_without_location()
|
||||
{
|
||||
var lines = new List<ParsedOccurrenceLine>
|
||||
{
|
||||
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1),
|
||||
new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2),
|
||||
new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1)
|
||||
};
|
||||
|
||||
var result = GlobalEventDeduplicator.Deduplicate(lines);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Count, Is.EqualTo(2));
|
||||
var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.That(curfew.Location, Is.Empty);
|
||||
Assert.That(result.Any(l => l.Name == "Meeting"), Is.True);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Core.Entities;
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportTextEmitterRoundTripTests
|
||||
{
|
||||
[Test]
|
||||
public void EmittedText_Parses_UnderGeneralSchedule()
|
||||
{
|
||||
var sheets = new List<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)>
|
||||
{
|
||||
("Thursday", "General Schedule", new List<ParsedOccurrenceLine>
|
||||
{
|
||||
new(
|
||||
Name: "Opening Ceremony",
|
||||
Month: "April",
|
||||
Day: 3,
|
||||
TimeRange: "9 a.m. - 10 a.m.",
|
||||
Location: "Main Hall",
|
||||
SourceRowStart: 1,
|
||||
SourceRowEnd: 1,
|
||||
SourceCol: 1)
|
||||
})
|
||||
};
|
||||
|
||||
var text = ImportTextEmitter.Build(sheets);
|
||||
var result = ParserRoundTripValidator.Validate(text, new List<EventDefinition>
|
||||
{
|
||||
EventDefinition.GeneralSchedule
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
|
||||
Assert.That(result.TotalParsed, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class OccurrenceDisplayNameReducerTests
|
||||
{
|
||||
private static EventDefinition Cyber() =>
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Cybersecurity",
|
||||
ShortName = "Cyber",
|
||||
Eligibility = "",
|
||||
EventFormat = EventFormat.Team
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Strips_ms_prefix_and_event_name()
|
||||
{
|
||||
var n = OccurrenceDisplayNameReducer.ReduceForSection(
|
||||
"MS Cybersecurity Semifinals Presentations",
|
||||
Cyber(),
|
||||
SchoolLevel.MiddleSchool);
|
||||
Assert.That(n, Is.EqualTo("Semifinals Presentations"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Core.Entities;
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class OccurrenceEventMatcherTests
|
||||
{
|
||||
private static EventDefinition E(string name, int id) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
ShortName = name,
|
||||
Eligibility = "",
|
||||
EventFormat = EventFormat.Team
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Ms_prefix_matches_Biotechnology()
|
||||
{
|
||||
var events = new List<EventDefinition> { E("Biotechnology", 1), E("Biotechnology Design", 2) };
|
||||
var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ok, Is.True);
|
||||
Assert.That(evt!.Name, Is.EqualTo("Biotechnology"));
|
||||
Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Clear_prefix_goes_unmatched_or_general_bucket()
|
||||
{
|
||||
var events = new List<EventDefinition> { E("Opening Session", 1) };
|
||||
var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl);
|
||||
Assert.That(ok, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class ScheduleGridExtractorTests
|
||||
{
|
||||
[Test]
|
||||
public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot()
|
||||
{
|
||||
string?[][] values =
|
||||
[
|
||||
[null, "Main Hall"],
|
||||
["9:00 a.m.", "Opening Ceremony"],
|
||||
["10:00 a.m.", null]
|
||||
];
|
||||
string?[][] bg =
|
||||
[
|
||||
[null, null],
|
||||
[null, null],
|
||||
[null, null]
|
||||
];
|
||||
var grid = new GridSheetModel
|
||||
{
|
||||
SheetTitle = "Day1",
|
||||
Values = values,
|
||||
BackgroundKeys = bg
|
||||
};
|
||||
var warnings = new List<string>();
|
||||
var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(lines, Has.Count.EqualTo(1));
|
||||
Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony"));
|
||||
Assert.That(lines[0].Month, Is.EqualTo("April"));
|
||||
Assert.That(lines[0].Day, Is.EqualTo(2));
|
||||
Assert.That(lines[0].Location, Is.EqualTo("Main Hall"));
|
||||
Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m."));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
namespace Tests.GoogleSheets;
|
||||
|
||||
[TestFixture]
|
||||
public class TextNormalizationTests
|
||||
{
|
||||
[Test]
|
||||
public void Collapses_line_separator_and_newlines()
|
||||
{
|
||||
var s = "Banquet Room\u2028E";
|
||||
Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E"));
|
||||
}
|
||||
}
|
||||
@@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// Verify successful occurrence is still parsed (if any valid lines exist)
|
||||
// The "Valid Event" line should parse successfully despite other issues
|
||||
var validEvent = events.First(e => e.Name == "Valid Event");
|
||||
if (result.Occurrences.ContainsKey(validEvent))
|
||||
var validGroup = new EventOccurrenceParseGroup(validEvent, null);
|
||||
if (result.Occurrences.ContainsKey(validGroup))
|
||||
{
|
||||
Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
|
||||
Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
|
||||
}
|
||||
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
|
||||
// but typically it should still parse since it's a valid line
|
||||
@@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests
|
||||
|
||||
// Verify occurrences were parsed correctly (if they were parsed)
|
||||
var testEvent = events.First(e => e.Name == "Test Event");
|
||||
if (result.Occurrences.ContainsKey(testEvent))
|
||||
var testGroup = new EventOccurrenceParseGroup(testEvent, null);
|
||||
if (result.Occurrences.ContainsKey(testGroup))
|
||||
{
|
||||
Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
|
||||
Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
|
||||
|
||||
var occurrence = result.Occurrences[testEvent].First();
|
||||
var occurrence = result.Occurrences[testGroup].First();
|
||||
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
|
||||
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
|
||||
}
|
||||
@@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// The important thing is that the parser doesn't crash and processes the input
|
||||
|
||||
// Verify locations are extracted correctly (pattern matching is no longer used)
|
||||
var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
|
||||
? result.Occurrences[testEvent].FirstOrDefault()
|
||||
var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
|
||||
? result.Occurrences[testGroup].FirstOrDefault()
|
||||
: null;
|
||||
if (testEventOccurrence != null)
|
||||
{
|
||||
@@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests
|
||||
|
||||
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
|
||||
// General Schedule section uses EventDefinition.GeneralSchedule
|
||||
Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
|
||||
$"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
|
||||
var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
|
||||
Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
|
||||
$"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
|
||||
|
||||
var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
|
||||
var occurrences = result.Occurrences[gsGroup];
|
||||
Assert.That(occurrences, Has.Count.GreaterThan(0),
|
||||
"Should have at least one occurrence in General Schedule");
|
||||
|
||||
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// Assert
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// Assert
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// Assert
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
|
||||
// Assert
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
|
||||
Assert.That(result.Occurrences.ContainsKey(events[0]));
|
||||
Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
|
||||
/// <summary>
|
||||
/// Writes special events summary to console.
|
||||
/// </summary>
|
||||
private static void WriteSpecialEventsSummary(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
||||
private static void WriteSpecialEventsSummary(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
|
||||
{
|
||||
Console.WriteLine($"\n--- Special Events Found ---");
|
||||
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
|
||||
Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
|
||||
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
|
||||
Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
|
||||
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
|
||||
Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
|
||||
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
|
||||
Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
|
||||
static int CountFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
|
||||
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
|
||||
|
||||
var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
|
||||
if (gs > 0)
|
||||
Console.WriteLine($" GeneralSchedule: {gs} occurrences");
|
||||
var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
|
||||
if (mtc > 0)
|
||||
Console.WriteLine($" MeetTheCandidates: {mtc} occurrences");
|
||||
var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting);
|
||||
if (com > 0)
|
||||
Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences");
|
||||
var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting);
|
||||
if (vdm > 0)
|
||||
Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests
|
||||
/// <summary>
|
||||
/// Writes special events to console output.
|
||||
/// </summary>
|
||||
private static void WriteSpecialEvents(IDictionary<EventDefinition, List<Core.Entities.EventOccurrence>> occurrences)
|
||||
private static void WriteSpecialEvents(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occurrences)
|
||||
{
|
||||
static List<Core.Entities.EventOccurrence> ListFor(IDictionary<EventOccurrenceParseGroup, List<Core.Entities.EventOccurrence>> occ, EventDefinition def) =>
|
||||
occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
|
||||
|
||||
Console.WriteLine("General Schedule");
|
||||
if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
|
||||
{
|
||||
foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
|
||||
{
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
}
|
||||
}
|
||||
foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
|
||||
Console.WriteLine("Meet the Candidates");
|
||||
if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
|
||||
{
|
||||
foreach (var eo in meetTheCandidates)
|
||||
{
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
}
|
||||
}
|
||||
foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
|
||||
Console.WriteLine("Chapter Officer Meeting");
|
||||
if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
|
||||
{
|
||||
foreach (var eo in chapterOfficerMeeting)
|
||||
{
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
}
|
||||
}
|
||||
foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
|
||||
Console.WriteLine("Voting Delegate Meeting");
|
||||
if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
|
||||
{
|
||||
foreach (var eo in votingDelegateMeeting)
|
||||
{
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
}
|
||||
}
|
||||
foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
|
||||
Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
|
||||
{
|
||||
Console.WriteLine($"{@event.Name}");
|
||||
|
||||
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
||||
var eventOccurrences = dictionary
|
||||
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
|
||||
.SelectMany(kvp => kvp.Value)
|
||||
.ToList();
|
||||
if (eventOccurrences.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||
continue;
|
||||
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
|
||||
{
|
||||
Console.WriteLine($"{@event.Name}");
|
||||
|
||||
if (!dictionary.TryGetValue(@event, out var eventOccurrences))
|
||||
var eventOccurrences = dictionary
|
||||
.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
|
||||
.SelectMany(kvp => kvp.Value)
|
||||
.ToList();
|
||||
if (eventOccurrences.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
|
||||
continue;
|
||||
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
|
||||
// Total expected MS occurrences: 16
|
||||
|
||||
var msEventCount = 0;
|
||||
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
|
||||
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
|
||||
msEventCount += csOccurrences.Count;
|
||||
if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
|
||||
if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
|
||||
msEventCount += codingOccurrences.Count;
|
||||
if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
|
||||
if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
|
||||
msEventCount += csvOccurrences.Count;
|
||||
if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
|
||||
if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
|
||||
msEventCount += ccOccurrences.Count;
|
||||
|
||||
// When no school level is set, HS events should be processed (not skipped)
|
||||
@@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests
|
||||
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
|
||||
|
||||
// Verify specific locations are parsed
|
||||
if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
|
||||
if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
|
||||
{
|
||||
var locations = childrensStoriesOccurrences
|
||||
.Select(eo => eo.Location)
|
||||
@@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests
|
||||
"HS section header should NOT be in SkippedSectionHeaders when no school level is set");
|
||||
|
||||
// With no school level filtering, both MS and HS events are processed
|
||||
if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
|
||||
{
|
||||
// With no school level set, we process all occurrences (both MS and HS)
|
||||
// Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
|
||||
Assert.That(allOccurrences, Has.Count.EqualTo(5),
|
||||
"Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
|
||||
$"Found {allOccurrences.Count} occurrences total.");
|
||||
|
||||
// Verify all expected occurrence names are present
|
||||
var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
|
||||
Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
|
||||
Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
|
||||
Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
|
||||
}
|
||||
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
|
||||
result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
|
||||
msOccurrences ??= [];
|
||||
hsOccurrences ??= [];
|
||||
Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
|
||||
Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
|
||||
|
||||
var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
|
||||
Assert.That(allNames, Does.Contain("Submit Entry"));
|
||||
Assert.That(allNames, Does.Contain("Judging"));
|
||||
Assert.That(allNames, Does.Contain("Pick-up"));
|
||||
|
||||
Assert.Pass("All events processed when no school level is set");
|
||||
}
|
||||
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
|
||||
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups()
|
||||
{
|
||||
var testContent = "Prepared Speech - HS\n" +
|
||||
"Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" +
|
||||
"Prepared Speech - MS\n" +
|
||||
"Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10";
|
||||
var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
|
||||
var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") };
|
||||
var parser = new EventOccurrenceParser(tempFile, events);
|
||||
|
||||
try
|
||||
{
|
||||
var result = parser.Parse();
|
||||
Assert.That(result.Issues, Has.Count.EqualTo(0));
|
||||
|
||||
var def = events[0];
|
||||
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True);
|
||||
Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True);
|
||||
Assert.That(hsList, Has.Count.EqualTo(1));
|
||||
Assert.That(msList, Has.Count.EqualTo(1));
|
||||
Assert.That(hsList![0].Name, Does.Contain("Extemporaneous"));
|
||||
Assert.That(msList![0].Name, Does.Contain("Prelims"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -18,6 +18,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
<ProjectReference Include="..\tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Parsers\TestInput\2025 Assumptions.csv">
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Success" Dense="true">
|
||||
Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s)
|
||||
Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s)
|
||||
@if (_parseResult.SkippedEventCount > 0)
|
||||
{
|
||||
<text> (Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)</text>
|
||||
@@ -201,9 +201,11 @@
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mt-4 mb-2">Occurrences by Event:</MudText>
|
||||
<MudExpansionPanels Elevation="0">
|
||||
@foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key)))
|
||||
@foreach (var kvp in _parseResult.Occurrences
|
||||
.OrderBy(x => GetEventName(x.Key.EventDefinition))
|
||||
.ThenBy(x => x.Key.SectionSchoolLevel switch { SchoolLevel.MiddleSchool => 0, SchoolLevel.HighSchool => 1, _ => 2 }))
|
||||
{
|
||||
<MudExpansionPanel Text="@GetEventName(kvp.Key)">
|
||||
<MudExpansionPanel Text="@GetOccurrenceGroupTitle(kvp.Key)">
|
||||
<MudTable Items="@kvp.Value" Dense="true" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
@@ -395,6 +397,17 @@
|
||||
return eventDefinition.Name;
|
||||
}
|
||||
|
||||
private string GetOccurrenceGroupTitle(EventOccurrenceParseGroup group)
|
||||
{
|
||||
var title = GetEventName(group.EventDefinition);
|
||||
return group.SectionSchoolLevel switch
|
||||
{
|
||||
SchoolLevel.MiddleSchool => $"{title} (MS)",
|
||||
SchoolLevel.HighSchool => $"{title} (HS)",
|
||||
_ => title
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetIssueTypeColor(ParsingIssueType issueType)
|
||||
{
|
||||
return issueType switch
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,42 +32,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 +66,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 +89,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 +104,18 @@
|
||||
}
|
||||
|
||||
|
||||
private DateTime GetNextDateWithEvents()
|
||||
|
||||
private string GetEventTooltip(CalendarItemWrapper wrapper)
|
||||
{
|
||||
try
|
||||
if (wrapper.ItemType == CalendarItemType.Event && wrapper.EventItem != null)
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return item.Start.Date >= today;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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;
|
||||
return GetEventTooltip(wrapper.EventItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else if (wrapper.ItemType == CalendarItemType.Meeting && wrapper.MeetingItem != null)
|
||||
{
|
||||
Logger.LogError(ex, "Error in GetNextDateWithEvents");
|
||||
return DateTime.Today;
|
||||
return GetMeetingTooltip(wrapper.MeetingItem);
|
||||
}
|
||||
return wrapper.Text;
|
||||
}
|
||||
|
||||
private string GetEventTooltip(CalendarEventItem item)
|
||||
@@ -235,12 +150,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 +194,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : " ";
|
||||
|
||||
@@ -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">
|
||||
<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 Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="Copy to Clipboard">
|
||||
<MudIconButton OnClick="CopyToClipboard" Icon="@Icons.Material.Filled.ContentCopy"></MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudButton Variant="@(IsDirty() ? Variant.Outlined : Variant.Filled)"
|
||||
OnClick="Solve"
|
||||
Color="Color.Primary"
|
||||
Disabled="@_isSolving">
|
||||
Solve
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
@@ -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
|
||||
@@ -567,6 +560,13 @@
|
||||
_excludedStudents);
|
||||
await currentState.SaveToLocalStorage(LocalStorage);
|
||||
_lastSavedState = currentState;
|
||||
|
||||
// Also save via state service for consistency
|
||||
await StateService.SaveScheduledTeamsAsync(_scheduledTeams);
|
||||
await StateService.SaveAbsentStudentsAsync(_absentStudents);
|
||||
await StateService.SaveTimeSlotCountAsync(_parameters.TimeSlots);
|
||||
await StateService.SaveExtendedTeamsAsync(_extendedTeams);
|
||||
await StateService.SaveExcludedStudentsAsync(_excludedStudents);
|
||||
|
||||
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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))
|
||||
{
|
||||
sb.Append(teamName);
|
||||
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,14 +29,17 @@
|
||||
@if (IsSelected(team))
|
||||
{
|
||||
var isExtended = IsExtended(team);
|
||||
<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"
|
||||
Color="@(isExtended ? Color.Primary : Color.Default)"
|
||||
Variant="@(isExtended ? Variant.Filled : Variant.Text)"
|
||||
OnClick="@(() => ToggleExtended(team))"
|
||||
Style="margin-left: 4px; padding: 2px;" />
|
||||
</MudTooltip>
|
||||
<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"
|
||||
Color="@(isExtended ? Color.Primary : Color.Default)"
|
||||
Variant="@(isExtended ? Variant.Filled : Variant.Text)"
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +47,12 @@
|
||||
<CellTemplate>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
Underline="Underline.Hover"
|
||||
Color="Color.Primary">
|
||||
@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
|
||||
@@ -110,7 +110,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 +169,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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
private void HandleTouchStart(TouchEventArgs e)
|
||||
{
|
||||
// On touch devices, toggle controls on tap (for devices with both touch and mouse)
|
||||
if (_isTouched)
|
||||
{
|
||||
_isTouched = !_isTouched;
|
||||
}
|
||||
_isTouched = !_isTouched;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,45 @@
|
||||
@inject IDialogService DialogService
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudButton StartIcon="@IconValue"
|
||||
OnClick="OpenDialog"
|
||||
Variant="@Variant"
|
||||
Size="@Size"
|
||||
Color="@ButtonColor"
|
||||
Tooltip="@TooltipText"
|
||||
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
|
||||
@if (!string.IsNullOrEmpty(ButtonText))
|
||||
{
|
||||
@ButtonText
|
||||
}
|
||||
</MudButton>
|
||||
<div @ref="_anchorElement"
|
||||
@onmouseenter="@(() => { if (_hasContent) _popoverOpen = true; })"
|
||||
@onmouseleave="@(() => _popoverOpen = false)"
|
||||
style="display: inline-block;">
|
||||
<MudButton StartIcon="@IconValue"
|
||||
OnClick="OpenDialog"
|
||||
Variant="@Variant"
|
||||
Size="@Size"
|
||||
Color="@ButtonColor"
|
||||
Tooltip="@TooltipText"
|
||||
Class="@MarkdownHelper.GetNoteColorClass(_noteId)">
|
||||
@if (!string.IsNullOrEmpty(ButtonText))
|
||||
{
|
||||
@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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -194,7 +194,13 @@ 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>();
|
||||
|
||||
// State container for maintaining state per user connection (Blazor Server)
|
||||
builder.Services.AddScoped<StateContainer>();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -265,4 +265,43 @@
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
created: 2026-03-30
|
||||
description: Extract a multi-day Google Sheets schedule grid into event-occurrence import text for the web app.
|
||||
---
|
||||
|
||||
# Import event schedule from Google Sheets
|
||||
|
||||
This repo includes a console tool that reads a **public** Google Sheet (with grid data and cell formatting), interprets each tab as one calendar day, and writes **plain text** in the same format as the legacy PDF-derived files consumed by **Calendar → Import Event Occurrences** (`/calendar/event-occurrences/import`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Google Cloud API key** with the **Google Sheets API** enabled.
|
||||
2. The spreadsheet must be readable with that key (typically **File → Share → Anyone with the link** *Viewer*, or share explicitly as needed for your key type).
|
||||
3. A **mapping JSON** file that lists each tab title and the calendar **month/day** for that tab (see sample under `docs/notes/`).
|
||||
|
||||
## Expected grid layout
|
||||
|
||||
- **Row 1:** Column `B` onward = **location** names (column `A` may be blank or a label).
|
||||
- **Column A (from row 2 down):** **Start time** for each row (e.g. `9:00 AM`, `9:00 a.m.`). The tool infers **slot length** from consecutive times and uses the next row’s time as the **end** of a block when cells span multiple rows.
|
||||
- **Data cells:** Event title (required for a block) and/or **non-white background** (treated as part of the same block as adjacent cells with the same text + color). **Merged cells** are expanded so the anchor value applies to the whole merge.
|
||||
|
||||
## Default section header
|
||||
|
||||
Output uses **`General Schedule`** as the section header unless you override it in mapping. That matches grids where each cell is its own schedule item (conference-style). If every line should belong to a specific competition event, set `sectionHeader` for that tab to something like `Biotechnology - MS` (must fuzzy-match an event in your database when importing).
|
||||
|
||||
Under **General Schedule**, the parser assigns occurrences to the **General Schedule** event type in the app (see `EventDefinitionResolver` / `EventDefinition.GeneralSchedule`).
|
||||
|
||||
## Grouping into competition events (PDF-style sections)
|
||||
|
||||
If you set **`eventDefinitionsCsv`** in the mapping JSON (or pass **`--events-csv`**), the tool loads event names from the CSV **`Event`** column and, when possible, rewrites output like the state schedule text:
|
||||
|
||||
- Section header: **`{Event Name} - MS`** or **`{Event Name} - HS`**
|
||||
- Occurrence lines under that section use **activity text only** (the tool drops the leading `MS`/`HS` and the repeated event name so lines read like the PDF, e.g. `On-Site Preliminary Exam …` not `MS Cybersecurity On-Site …`).
|
||||
- Only rows whose cell text has a **leading** `MS `, `HS `, `MS/`, or `HS/` prefix (after normalization) are grouped; the remainder is fuzzy-matched to an event (see `OccurrenceEventMatcher`).
|
||||
- **`MS/HS …`** combined rows → **General Schedule** (no single section).
|
||||
- Unmatched lines (opening session, meet-the-candidates, help desk, etc.) stay under a final **`General Schedule`** block.
|
||||
|
||||
Use **`--no-group-by-event`** to force the old “one General Schedule per sheet” layout.
|
||||
|
||||
## Site-wide rows (e.g. CURFEW)
|
||||
|
||||
If the same label appears across **every location column** for the same time (typical for **CURFEW**), the tool emits **one line per date and time** with **no location**. Built-in: `CURFEW` (case-insensitive). Optional mapping field **`siteWideEventNames`** adds more titles (e.g. `["Fire drill"]`).
|
||||
|
||||
## Run the tool
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
$env:GOOGLE_SHEETS_API_KEY = "<your-api-key>"
|
||||
|
||||
dotnet run --project tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj -- `
|
||||
--sheet-url "https://docs.google.com/spreadsheets/d/<spreadsheetId>/edit" `
|
||||
--mapping path/to/mapping.json `
|
||||
--output path/to/event-times.txt
|
||||
```
|
||||
|
||||
Optional flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|--------|
|
||||
| `--year 2026` | Year for date validation (also in mapping file). |
|
||||
| `--tabs "Day 1,Day 2"` | Only these tab titles (exact match). |
|
||||
| `--events-csv path\to\Event Definitions.csv` | Load event names for stricter parser validation (`Event` column). |
|
||||
| `--strict` | Exit code `1` if the built-in parser reports errors or parses zero occurrences. |
|
||||
|
||||
The tool always runs a **parser round-trip** on the generated text and prints errors/issues to the console.
|
||||
|
||||
## Import into the app
|
||||
|
||||
1. Open **Import Event Occurrences** in the web app.
|
||||
2. Paste the contents of the generated `.txt` file.
|
||||
3. **Parse**, review results, then **Save to Database** as usual.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **403 / access denied:** Confirm the sheet is visible to the API key and Sheets API is enabled.
|
||||
- **Wrong durations:** Ensure time labels in column A are consistent; the tool infers slot length from the most common delta between consecutive rows.
|
||||
- **Parser issues on import:** Use `--events-csv` pointing at your chapter’s event definitions export; fix `sectionHeader` in mapping if items should sit under a specific `Event Name - MS/HS` section.
|
||||
@@ -0,0 +1,163 @@
|
||||
# THURSDAY 4/9
|
||||
General Schedule
|
||||
Registration April 9 3 p.m. - 7 p.m. CCC Lobby (outside of Banquet Rooms)
|
||||
FCCLA/TSA Store April 9 3 p.m. - 7 p.m. Meeting Room 1
|
||||
MS/HS Prompt Releases (Virtual via App) April 9 6 p.m. - 9 p.m. Banquet Room E
|
||||
MS/HS Event Turn-In April 9 6 p.m. - 9 p.m. Banquet Room G
|
||||
HS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room H
|
||||
MS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room I
|
||||
MEMCO Meeting April 9 6 p.m. - 7 p.m. Banquet Room J
|
||||
MS/HS Time Sign-Ups (Virtual via App) April 9 6:30 p.m. - 7:30 p.m. Banquet Room F
|
||||
Coordinators Meeting April 9 7 p.m. - 8 p.m. Banquet Room J
|
||||
SOT Candidates Meeting April 9 8 p.m. - 9:30 p.m. Banquet Room J
|
||||
CURFEW April 9 11 p.m. - 12:30 a.m.
|
||||
|
||||
# FRIDAY 4/10
|
||||
CAD foundations - MS
|
||||
HS 2D CAD Architecture/ HS 3D CAD Engineering On-Site Challenge April 10 10:30 a.m. - 4:30 p.m. Banquet Room G
|
||||
Career Prep - MS
|
||||
Semifinals Interviews April 10 1 p.m. - 3 p.m. Meeting Room 10
|
||||
Challenging Technology Issues - HS
|
||||
Debating Technological Issues Prelims Pre-Debate Meeting April 10 10 a.m. - 10:30 a.m. Meeting Room 6
|
||||
Debating Technological Issues Prelims Presentation Room (Heat 1) April 10 1 p.m. - 5 p.m. Meeting Room 4
|
||||
Debating Technological Issues Prelims Presentation Room (Heat 2) April 10 1 p.m. - 5 p.m. Meeting Room 5
|
||||
Challenging Technology Issues - MS
|
||||
Leadership Strategies Holding Room April 10 10:30 a.m. - 4 p.m. Meeting Room 7
|
||||
Prelims Presentation Room April 10 10:30 a.m. - 1 p.m. Meeting Room 8
|
||||
Coding - HS
|
||||
Extemporaneous Speech/Debating Technological Issues Holding Room Room April 10 10 a.m. - 5 p.m. Meeting Room 3
|
||||
Data Science & Analytics - HS
|
||||
"Quarterfinals" Presentation April 10 12:30 p.m. - 5 p.m. Meeting Room 9
|
||||
Digital Photography - MS
|
||||
Semifinals Challenge April 10 10 a.m. - 1 p.m. Meeting Room 19
|
||||
Semifinals Interviews April 10 3 p.m. - 4 p.m. Meeting Room 16
|
||||
Electrical Applications - MS
|
||||
Semifinals Challenge April 10 2 p.m. - 3:30 p.m. Meeting Room 19
|
||||
Forensic Technology - HS
|
||||
Future Technology and Engineering Teacher Semifinals Presentations April 10 10 a.m. - 1 p.m. Meeting Room 18
|
||||
Leadership Strategies - MS
|
||||
Prelims Presentation Room April 10 1:30 p.m. - 4 p.m. Meeting Room 8
|
||||
Mass Production - HS
|
||||
Digital Video Production Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 17
|
||||
Music Production Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 17
|
||||
Prepared Speech - HS
|
||||
Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4
|
||||
Extemporaneous Speech Presentation Room (Heat 2) April 10 10 a.m. - 12:30 p.m. Meeting Room 5
|
||||
Prepared Presentation Prelims Presentation April 10 10 a.m. - 3:30 p.m. Meeting Room 21
|
||||
Prepared Speech - MS
|
||||
Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
|
||||
System Control Technology - MS
|
||||
HS System Control Technology On-Site Challenge April 10 10 a.m. - 2 p.m. Banquet Room F
|
||||
Tech Bowl - HS
|
||||
Photographic Tech Semifinals Prompt Release April 10 11 a.m. - 12 p.m. Meeting Room 6
|
||||
Video Game Design - MS
|
||||
Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
|
||||
Website Design - MS
|
||||
Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 16
|
||||
General Schedule
|
||||
FCCLA/TSA Store April 10 8 a.m. - 4 p.m. Meeting Room 1
|
||||
MS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
|
||||
HS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
|
||||
Opening Session April 10 9 a.m. - 10 a.m. Exhibit Hall A
|
||||
TECHSPO April 10 10 a.m. - 4 p.m. Main Hallway
|
||||
Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
|
||||
Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
|
||||
Advisor Meeting April 10 10 a.m. - 11 a.m. Banquet Room E
|
||||
HS Software Development Semifinals Presentations April 10 10 a.m. - 12 p.m. Meeting Room 9
|
||||
MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
|
||||
MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
|
||||
MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
|
||||
MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
|
||||
Workshop April 10 11:30 a.m. - 1:30 p.m. Banquet Room E
|
||||
HS STEM Mass Media Semifinals Press Conference April 10 12:30 p.m. - 3:30 p.m. Meeting Room 6
|
||||
TSA Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
|
||||
HS Animatronics Presentations/ Interviews April 10 1:30 p.m. - 4:30 p.m. Meeting Room 18
|
||||
Workshop April 10 2:30 p.m. - 4:30 p.m. Banquet Room E
|
||||
HS VR Semifinals Interviews April 10 2:30 p.m. - 4:30 p.m. Banquet Room F
|
||||
Community Service Video Seminfinal Interviews April 10 3 p.m. - 4 p.m. Meeting Room 17
|
||||
TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
|
||||
TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
|
||||
(no title) April 10 4:30 p.m. - 5:30 p.m. Banquet Room E
|
||||
Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
|
||||
Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
|
||||
(no title) April 10 10 p.m. - 11 p.m. Banquet Room E
|
||||
CURFEW April 10 11 p.m. - 12:30 a.m.
|
||||
|
||||
# SATURDAY 4/11
|
||||
Challenging Technology Issues - HS
|
||||
Debating Technological Issues Semifinals Pre-Debate Meeting April 11 9:30 a.m. - 10 a.m. Meeting Room 7
|
||||
Debating Technological Issues Semifinals Presentation Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 8
|
||||
Challenging Technology Issues - MS
|
||||
Semifinals Holding Room April 11 9:30 a.m. - 11 a.m. Meeting Room 9
|
||||
Semifinals Presentation Room April 11 9:30 a.m. - 11 a.m. Meeting Room 10
|
||||
Chapter Team - HS
|
||||
Semifinals Presentation April 11 1:30 p.m. - 3:30 p.m. Meeting Room 16
|
||||
Chapter Team - MS
|
||||
Semifinals Presentation April 11 11:30 a.m. - 1 p.m. Meeting Room 16
|
||||
Children's Stories - HS
|
||||
Semifinals Interviews April 11 9:30 a.m. - 12:30 p.m. Meeting Room 18
|
||||
Children's Stories - MS
|
||||
Semifinals Interviews April 11 1 p.m. - 4 p.m. Meeting Room 18
|
||||
Coding - HS
|
||||
Semifinals Challenge April 11 9:30 a.m. - 12 p.m. Meeting Room 19
|
||||
Debating Technological Issues Semifinals Holding Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 7
|
||||
Extemporaneous Speech Semifinals Holding Room April 11 1 p.m. - 2:30 p.m. Meeting Room 7
|
||||
Coding - MS
|
||||
Semifinals Challenge April 11 12:30 p.m. - 3 p.m. Meeting Room 19
|
||||
Cybersecurity - MS
|
||||
Seminals Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
|
||||
Data Science & Analytics - HS
|
||||
Forensic Science Written Analysis Room April 11 9:30 a.m. - 2:30 p.m. Meeting Room 5
|
||||
Semifinals Challenge April 11 3 p.m. - 5 p.m. Meeting Room 6
|
||||
Data Science & Analytics - MS
|
||||
Data Science and Analytics Presentations April 11 3 p.m. - 4:30 p.m. Meeting Room 4
|
||||
Data Science and Analytics Preparation Room April 11 3 p.m. - 4:30 p.m. Meeting Room 5
|
||||
Forensic Technology - HS
|
||||
Photographic Technology Semifinals Interviews April 11 3 p.m. - 5 p.m. Meeting Room 7
|
||||
Forensic Technology - MS
|
||||
Semifinals Presentation Room April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
|
||||
Leadership Strategies - MS
|
||||
Semifinals Holding Room April 11 11:30 a.m. - 1 p.m. Meeting Room 9
|
||||
Semifinals Presentation Room April 11 11:30 a.m. - 1 p.m. Meeting Room 10
|
||||
Medical Technology - HS
|
||||
Fashion Design and Technology Semifinals Presentation/ Interviews April 11 2:30 p.m. - 4:30 p.m. Meeting Room 17
|
||||
Prepared Speech - HS
|
||||
Extemporaneous Speech Semifinals Presentation Room April 11 1 p.m. - 2:30 p.m. Meeting Room 8
|
||||
Prepared Presentation Semifinals Presentation Room April 11 3 p.m. - 5 p.m. Meeting Room 8
|
||||
Prepared Speech - MS
|
||||
Semifinals Presentation Room April 11 1:30 p.m. - 3 p.m. Meeting Room 10
|
||||
Promotional Marketing - HS
|
||||
Promotional Design Semifinals Challenge April 11 11:30 a.m. - 2:30 p.m. Meeting Room 6
|
||||
Promotional Marketing - MS
|
||||
Semifinals Challenge April 11 9:30 a.m. - 11 a.m. Meeting Room 6
|
||||
STEM Animation - MS
|
||||
Semifinals Interviews April 11 9:30 a.m. - 11 a.m. Meeting Room 16
|
||||
Tech Bowl - MS
|
||||
HS Technology Bowl Semifinals Bracket Play April 11 9 a.m. - 5 p.m. Banquet Rooms G
|
||||
HS Technology Bowl Semifinals Holding Room April 11 9 a.m. - 5 p.m. Banquet Rooms H
|
||||
Video Game Design - HS
|
||||
Semifinals Interviews April 11 12 p.m. - 2 p.m. Meeting Room 17
|
||||
Website Design - HS
|
||||
Webmaster Semifinals Interviews April 11 9:30 a.m. - 11:30 a.m. Meeting Room 17
|
||||
General Schedule
|
||||
Voting Delegate Meeting April 11 8 a.m. - 9 a.m. Banquet Room F
|
||||
Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
|
||||
Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
|
||||
Tennessee TSA Store April 11 9 a.m. - 4 p.m. Meeting Room 1
|
||||
HS Forensic Science CSI April 11 9:30 a.m. - 2:30 p.m. Meeting Room 4
|
||||
MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
|
||||
MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
|
||||
MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
|
||||
MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
|
||||
TECHSPO April 11 10 a.m. - 4 p.m. Main Hallway
|
||||
MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
|
||||
General Session 2: Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
|
||||
Senior Social April 11 8:30 p.m. - 9:30 p.m. Banquet Room F
|
||||
Chapter Officer Meeting April 11 8:30 p.m. - 9 p.m. Banquet Rooms G
|
||||
CURFEW April 11 11 p.m. - 12:30 a.m.
|
||||
|
||||
# SUNDAY 4/12
|
||||
General Schedule
|
||||
General Session 3: Awards Ceremony April 12 8:30 a.m. - 12 p.m. Exhibit Hall A
|
||||
New SOT Pictures April 12 12 p.m. - 12:30 p.m. Exhibit Hall A
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"year": 2026,
|
||||
"defaultSectionHeader": "General Schedule",
|
||||
"sheets": [
|
||||
{
|
||||
"title": "Wednesday",
|
||||
"month": "April",
|
||||
"day": 2,
|
||||
"sectionHeader": null
|
||||
},
|
||||
{
|
||||
"title": "Thursday",
|
||||
"month": "April",
|
||||
"day": 3
|
||||
},
|
||||
{
|
||||
"title": "Friday",
|
||||
"month": "April",
|
||||
"day": 4,
|
||||
"sectionHeader": "General Schedule"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"year": 2026,
|
||||
"eventDefinitionsCsv": "../../Tests/Parsers/TestInput/2024 Event Definitions.csv",
|
||||
"defaultSectionHeader": "General Schedule",
|
||||
"sheets": [
|
||||
{
|
||||
"title": "THURSDAY 4/9",
|
||||
"month": "April",
|
||||
"day": 9
|
||||
},
|
||||
{
|
||||
"title": "FRIDAY 4/10",
|
||||
"month": "April",
|
||||
"day": 10
|
||||
},
|
||||
{
|
||||
"title": "SATURDAY 4/11",
|
||||
"month": "April",
|
||||
"day": 11
|
||||
},
|
||||
{
|
||||
"title": "SUNDAY 4/12",
|
||||
"month": "April",
|
||||
"day": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text;
|
||||
using Core.Entities;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Loads minimal <see cref="EventDefinition"/> rows from Tests-style CSV (column "Event").
|
||||
/// </summary>
|
||||
public static class EventDefinitionsCsvLoader
|
||||
{
|
||||
public static List<EventDefinition> Load(string path)
|
||||
{
|
||||
var list = new List<EventDefinition>();
|
||||
var lines = File.ReadAllLines(path);
|
||||
if (lines.Length < 2)
|
||||
return list;
|
||||
|
||||
var header = ParseCsvLine(lines[0]);
|
||||
var eventIdx = header.FindIndex(h => h.Equals("Event", StringComparison.OrdinalIgnoreCase));
|
||||
if (eventIdx < 0)
|
||||
throw new InvalidOperationException($"CSV '{path}' must contain an 'Event' column header.");
|
||||
|
||||
for (var i = 1; i < lines.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lines[i]))
|
||||
continue;
|
||||
var cols = ParseCsvLine(lines[i]);
|
||||
if (eventIdx >= cols.Count)
|
||||
continue;
|
||||
var name = cols[eventIdx].Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
list.Add(new EventDefinition
|
||||
{
|
||||
Id = i,
|
||||
Name = name,
|
||||
ShortName = name,
|
||||
Eligibility = "",
|
||||
EventFormat = EventFormat.Team
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var cur = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var ch = line[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
cur.Append('"');
|
||||
i++;
|
||||
}
|
||||
else inQuotes = false;
|
||||
}
|
||||
else cur.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ch == '"') inQuotes = true;
|
||||
else if (ch == ',')
|
||||
{
|
||||
result.Add(cur.ToString());
|
||||
cur.Clear();
|
||||
}
|
||||
else cur.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(cur.ToString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Collapses duplicate site-wide rows (same event name, date, time) that appear in every location column
|
||||
/// (e.g. CURFEW shaded across all rooms) into a single line with no location — still under
|
||||
/// <c>General Schedule</c>, which the parser maps to <see cref="Core.Entities.EventDefinition.GeneralSchedule"/>.
|
||||
/// </summary>
|
||||
public static class GlobalEventDeduplicator
|
||||
{
|
||||
private static readonly HashSet<string> BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"CURFEW"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific.
|
||||
/// </summary>
|
||||
public static List<ParsedOccurrenceLine> Deduplicate(
|
||||
IReadOnlyList<ParsedOccurrenceLine> lines,
|
||||
IReadOnlyCollection<string>? extraSiteWideNames = null)
|
||||
{
|
||||
var siteWide = new HashSet<string>(BuiltinSiteWideNames, StringComparer.OrdinalIgnoreCase);
|
||||
if (extraSiteWideNames != null)
|
||||
{
|
||||
foreach (var n in extraSiteWideNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(n))
|
||||
siteWide.Add(n.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var seen = new HashSet<(string Name, string Month, int Day, string Time)>();
|
||||
var ordered = lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol).ToList();
|
||||
var result = new List<ParsedOccurrenceLine>(ordered.Count);
|
||||
|
||||
foreach (var line in ordered)
|
||||
{
|
||||
var name = TextNormalization.ForEmitLine(line.Name);
|
||||
if (string.IsNullOrEmpty(name) || !siteWide.Contains(name))
|
||||
{
|
||||
result.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (name, line.Month, line.Day, line.TimeRange);
|
||||
if (!seen.Add(key))
|
||||
continue;
|
||||
|
||||
result.Add(line with { Location = string.Empty, Name = name });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Google.Apis.Sheets.v4;
|
||||
using Google.Apis.Sheets.v4.Data;
|
||||
using Color = Google.Apis.Sheets.v4.Data.Color;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches raw grid data via Sheets API (public API key).
|
||||
/// </summary>
|
||||
public sealed class GoogleSheetGridReader
|
||||
{
|
||||
private readonly SheetsService _service;
|
||||
|
||||
public GoogleSheetGridReader(string apiKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
throw new ArgumentException("API key is required for Google Sheets access.", nameof(apiKey));
|
||||
_service = new SheetsService(new Google.Apis.Services.BaseClientService.Initializer
|
||||
{
|
||||
ApiKey = apiKey,
|
||||
ApplicationName = "TSA GoogleSheetsScheduleImport"
|
||||
});
|
||||
}
|
||||
|
||||
public Spreadsheet FetchSpreadsheet(string spreadsheetId)
|
||||
{
|
||||
var req = _service.Spreadsheets.Get(spreadsheetId);
|
||||
req.IncludeGridData = true;
|
||||
return req.Execute();
|
||||
}
|
||||
|
||||
public static GridSheetModel BuildModel(Sheet sheet)
|
||||
{
|
||||
var title = sheet.Properties?.Title ?? "(untitled)";
|
||||
var grid = sheet.Data?.FirstOrDefault();
|
||||
if (grid?.RowData == null || grid.RowData.Count == 0)
|
||||
{
|
||||
return new GridSheetModel
|
||||
{
|
||||
SheetTitle = title,
|
||||
Values = Array.Empty<string?[]>(),
|
||||
BackgroundKeys = Array.Empty<string?[]>()
|
||||
};
|
||||
}
|
||||
|
||||
var rowCount = grid.RowData.Count;
|
||||
var colCount = grid.RowData.Max(r => r.Values?.Count ?? 0);
|
||||
|
||||
var values = new string?[rowCount][];
|
||||
var bg = new string?[rowCount][];
|
||||
|
||||
for (var r = 0; r < rowCount; r++)
|
||||
{
|
||||
values[r] = new string?[colCount];
|
||||
bg[r] = new string?[colCount];
|
||||
var row = grid.RowData[r];
|
||||
for (var c = 0; c < colCount; c++)
|
||||
{
|
||||
string? text = null;
|
||||
string? hex = null;
|
||||
if (row.Values != null && c < row.Values.Count)
|
||||
{
|
||||
var cell = row.Values[c];
|
||||
text = string.IsNullOrWhiteSpace(cell.FormattedValue)
|
||||
? cell.EffectiveValue?.StringValue
|
||||
: cell.FormattedValue;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
text = TextNormalization.ForSheetCell(text);
|
||||
var color = cell.UserEnteredFormat?.BackgroundColor
|
||||
?? cell.EffectiveFormat?.BackgroundColor;
|
||||
hex = ColorToHexKey(color);
|
||||
}
|
||||
|
||||
values[r][c] = string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
bg[r][c] = hex;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyMerges(sheet.Merges, values, bg);
|
||||
NormalizeAllValueCells(values);
|
||||
|
||||
return new GridSheetModel
|
||||
{
|
||||
SheetTitle = title,
|
||||
Values = values,
|
||||
BackgroundKeys = bg
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyMerges(IList<GridRange>? merges, string?[][] values, string?[][] bg)
|
||||
{
|
||||
if (merges == null || merges.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var range in merges)
|
||||
{
|
||||
var r0 = range.StartRowIndex ?? 0;
|
||||
var r1 = range.EndRowIndex ?? r0;
|
||||
var c0 = range.StartColumnIndex ?? 0;
|
||||
var c1 = range.EndColumnIndex ?? c0;
|
||||
if (r1 <= r0 || c1 <= c0)
|
||||
continue;
|
||||
|
||||
var anchorText = values[r0][c0];
|
||||
var anchorBg = bg[r0][c0];
|
||||
for (var r = r0; r < r1; r++)
|
||||
{
|
||||
for (var c = c0; c < c1; c++)
|
||||
{
|
||||
if (values[r][c] == null && anchorText != null)
|
||||
values[r][c] = anchorText;
|
||||
if (bg[r][c] == null && anchorBg != null)
|
||||
bg[r][c] = anchorBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-run after merges so copied anchor text is also single-line.</summary>
|
||||
private static void NormalizeAllValueCells(string?[][] values)
|
||||
{
|
||||
for (var r = 0; r < values.Length; r++)
|
||||
for (var c = 0; c < values[r].Length; c++)
|
||||
{
|
||||
if (values[r][c] is { } v && !string.IsNullOrWhiteSpace(v))
|
||||
values[r][c] = TextNormalization.ForEmitLine(v);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ColorToHexKey(Color? color)
|
||||
{
|
||||
if (color == null)
|
||||
return null;
|
||||
var r = color.Red ?? 1f;
|
||||
var g = color.Green ?? 1f;
|
||||
var b = color.Blue ?? 1f;
|
||||
// Treat near-white as no color key for grouping
|
||||
if (r >= 0.99f && g >= 0.99f && b >= 0.99f)
|
||||
return null;
|
||||
static byte F(float x) => (byte)(Math.Clamp(x, 0f, 1f) * 255f);
|
||||
return $"#{F(r):X2}{F(g):X2}{F(b):X2}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>GoogleSheetsScheduleImport</AssemblyName>
|
||||
<RootNamespace>GoogleSheetsScheduleImport</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.70.0.3806" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored).
|
||||
/// </summary>
|
||||
public sealed class GridSheetModel
|
||||
{
|
||||
public required string SheetTitle { get; init; }
|
||||
|
||||
/// <summary>display values, sanitized hyphens; [row][col]</summary>
|
||||
public required string?[][] Values { get; init; }
|
||||
|
||||
/// <summary>Optional RGB hex backgrounds for block grouping (#RRGGBB or null)</summary>
|
||||
public required string?[][]? BackgroundKeys { get; init; }
|
||||
|
||||
public int RowCount => Values.Length;
|
||||
public int ColCount => Values.Length == 0 ? 0 : Values[0].Length;
|
||||
}
|
||||
|
||||
public readonly record struct ParsedOccurrenceLine(
|
||||
string Name,
|
||||
string Month,
|
||||
int Day,
|
||||
string TimeRange,
|
||||
string Location,
|
||||
int SourceRowStart,
|
||||
int SourceRowEnd,
|
||||
int SourceCol);
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Emits PDF-style section headers <c>Event Name - MS</c> / <c>Event Name - HS</c> before occurrence lines,
|
||||
/// matching competition schedule imports.
|
||||
/// </summary>
|
||||
public static class GroupedImportTextEmitter
|
||||
{
|
||||
private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable<SectionKey>
|
||||
{
|
||||
public int EventDefinitionId { get; } = eventDefinitionId;
|
||||
public SchoolLevel Level { get; } = level;
|
||||
|
||||
public bool Equals(SectionKey other) =>
|
||||
EventDefinitionId == other.EventDefinitionId && Level == other.Level;
|
||||
|
||||
public override bool Equals(object? obj) => obj is SectionKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(EventDefinitionId, Level);
|
||||
}
|
||||
|
||||
public static string Build(
|
||||
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets,
|
||||
IReadOnlyList<EventDefinition> matchableEvents,
|
||||
int year)
|
||||
{
|
||||
var idToDef = matchableEvents.Where(e => e.Id != 0).ToDictionary(e => e.Id);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (sheetTitle, _, lines) in sheets)
|
||||
{
|
||||
sb.AppendLine($"# {sheetTitle}");
|
||||
|
||||
var general = new List<ParsedOccurrenceLine>();
|
||||
var bySection = new Dictionary<SectionKey, List<ParsedOccurrenceLine>>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!OccurrenceEventMatcher.TryMatch(line.Name, matchableEvents, out var evt, out var lvl)
|
||||
|| evt == null
|
||||
|| !lvl.HasValue)
|
||||
{
|
||||
general.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = new SectionKey(evt.Id, lvl.Value);
|
||||
if (!bySection.TryGetValue(key, out var list))
|
||||
{
|
||||
list = [];
|
||||
bySection[key] = list;
|
||||
}
|
||||
|
||||
list.Add(line);
|
||||
}
|
||||
|
||||
foreach (var key in bySection.Keys.OrderBy(k => HeaderSortKey(idToDef, k), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
|
||||
continue;
|
||||
sb.AppendLine($"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}");
|
||||
foreach (var line in OccurrenceChronologicalSort.Sort(bySection[key], year))
|
||||
{
|
||||
var displayName = OccurrenceDisplayNameReducer.ReduceForSection(line.Name, def, key.Level);
|
||||
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line with { Name = displayName }));
|
||||
}
|
||||
}
|
||||
|
||||
if (general.Count > 0)
|
||||
{
|
||||
sb.AppendLine("General Schedule");
|
||||
foreach (var line in OccurrenceChronologicalSort.Sort(general, year))
|
||||
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string HeaderSortKey(Dictionary<int, EventDefinition> idToDef, SectionKey key)
|
||||
{
|
||||
if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
|
||||
return $"{key.EventDefinitionId} - {key.Level}";
|
||||
return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
public static class ImportLineFormatter
|
||||
{
|
||||
public static string FormatOccurrenceLine(ParsedOccurrenceLine line)
|
||||
{
|
||||
var name = TextNormalization.ForEmitLine(line.Name);
|
||||
var time = TextNormalization.ForEmitLine(line.TimeRange);
|
||||
var loc = TextNormalization.ForEmitLine(line.Location);
|
||||
var tail = string.IsNullOrEmpty(loc) ? string.Empty : $" {loc}";
|
||||
return $"{name} {line.Month} {line.Day} {time}{tail}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
public static class ImportTextEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds text compatible with <see cref="Core.Parsers.EventOccurrenceParser"/> / Import.razor paste target.
|
||||
/// </summary>
|
||||
public static string Build(
|
||||
IReadOnlyList<(string SheetTitle, string SectionHeader, List<ParsedOccurrenceLine> Lines)> sheets)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (sheetTitle, sectionHeader, lines) in sheets)
|
||||
{
|
||||
sb.AppendLine($"# {sheetTitle}");
|
||||
sb.AppendLine(sectionHeader);
|
||||
foreach (var line in lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol))
|
||||
sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// JSON config: sheet tab titles mapped to calendar days plus optional defaults.
|
||||
/// </summary>
|
||||
public sealed class MappingConfig
|
||||
{
|
||||
/// <summary>Calendar year for occurrence dates (overridden by CLI --year).</summary>
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Emitted before occurrence lines for each sheet (unless overridden per sheet).
|
||||
/// Use "General Schedule" when grid cells are generic schedule items.
|
||||
/// </summary>
|
||||
public string? DefaultSectionHeader { get; set; }
|
||||
|
||||
/// <summary>Per-sheet overrides and day mapping.</summary>
|
||||
public List<SheetDayMapping>? Sheets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event titles (cell text) to treat as site-wide: one output line per date+time, no location.
|
||||
/// If omitted, built-in rules still include CURFEW.
|
||||
/// </summary>
|
||||
public List<string>? SiteWideEventNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to event definitions CSV (column <c>Event</c>), relative to this mapping file or absolute.
|
||||
/// When set (or when <c>--events-csv</c> is passed), output is grouped into <c>Event - MS/HS</c> sections when possible.
|
||||
/// </summary>
|
||||
public string? EventDefinitionsCsv { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SheetDayMapping
|
||||
{
|
||||
/// <summary>Exact tab title as it appears in Google Sheets.</summary>
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
/// <summary>Month name matching EventOccurrenceGrammar (e.g. "April").</summary>
|
||||
public string Month { get; set; } = "";
|
||||
|
||||
/// <summary>Day of month (1-31).</summary>
|
||||
public int Day { get; set; }
|
||||
|
||||
/// <summary>Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS").</summary>
|
||||
public string? SectionHeader { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string? NormalizedMonth => string.IsNullOrWhiteSpace(Month) ? null : Month.Trim();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Title))
|
||||
throw new InvalidOperationException("Mapping entry must include a non-empty Title (sheet tab name).");
|
||||
if (string.IsNullOrWhiteSpace(Month))
|
||||
throw new InvalidOperationException($"Sheet '{Title}': Month is required.");
|
||||
if (Day is < 1 or > 31)
|
||||
throw new InvalidOperationException($"Sheet '{Title}': Day must be between 1 and 31.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Core.Parsers.EventOccurrence;
|
||||
using Core.Utility;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
public static class OccurrenceChronologicalSort
|
||||
{
|
||||
public static List<ParsedOccurrenceLine> Sort(IEnumerable<ParsedOccurrenceLine> lines, int year)
|
||||
{
|
||||
return lines
|
||||
.OrderBy(l => Key(l, year))
|
||||
.ThenBy(l => l.SourceRowStart)
|
||||
.ThenBy(l => l.SourceCol)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static DateTime Key(ParsedOccurrenceLine line, int year)
|
||||
{
|
||||
try
|
||||
{
|
||||
var d = TextUtil.ParseDate(line.Month, line.Day.ToString(), year);
|
||||
var timePart = TimeParser.ExtractStartTime(line.TimeRange);
|
||||
var t = TimeParser.Parse(timePart);
|
||||
return new DateTime(d, t);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not
|
||||
/// <c>MS EventName Activity</c> when the section is already <c>EventName - MS</c>.
|
||||
/// </summary>
|
||||
public static class OccurrenceDisplayNameReducer
|
||||
{
|
||||
public static string ReduceForSection(string occurrenceName, EventDefinition matched, SchoolLevel level)
|
||||
{
|
||||
var (remainder, lvl) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(occurrenceName);
|
||||
if (!lvl.HasValue || lvl.Value != level)
|
||||
return TextNormalization.ForEmitLine(occurrenceName);
|
||||
|
||||
var r = remainder.Trim();
|
||||
var eventName = matched.Name.Trim();
|
||||
if (r.StartsWith(eventName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
r = r[eventName.Length..].TrimStart();
|
||||
r = TrimLeadingJoiners(r);
|
||||
}
|
||||
|
||||
r = TextNormalization.ForEmitLine(r);
|
||||
if (string.IsNullOrWhiteSpace(r))
|
||||
return TextNormalization.ForEmitLine(remainder.Trim());
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
private static string TrimLeadingJoiners(string s)
|
||||
{
|
||||
var r = s;
|
||||
while (r.Length > 0)
|
||||
{
|
||||
var c = r[0];
|
||||
if (c is '/' or '-' or ':' or '&' or '–' or '—' or ',' or '.')
|
||||
{
|
||||
r = r[1..].TrimStart();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using FuzzySharp;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers.
|
||||
/// </summary>
|
||||
public static class OccurrenceEventMatcher
|
||||
{
|
||||
private const int MinTokenScore = 62;
|
||||
|
||||
public static bool TryMatch(
|
||||
string occurrenceName,
|
||||
IReadOnlyList<EventDefinition> events,
|
||||
out EventDefinition? matched,
|
||||
out SchoolLevel? level)
|
||||
{
|
||||
matched = null;
|
||||
level = null;
|
||||
if (string.IsNullOrWhiteSpace(occurrenceName) || events.Count == 0)
|
||||
return false;
|
||||
|
||||
var normalized = TextNormalization.ForEmitLine(occurrenceName);
|
||||
var (remainder, prefixLevel) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(normalized);
|
||||
|
||||
// Section headers require "Event Name - MS|HS". Titles without a clear school prefix stay in General Schedule.
|
||||
if (!prefixLevel.HasValue)
|
||||
return false;
|
||||
|
||||
var best = FindBest(remainder, events);
|
||||
if (best == null || string.IsNullOrWhiteSpace(remainder))
|
||||
return false;
|
||||
|
||||
matched = best;
|
||||
level = prefixLevel;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static EventDefinition? FindBest(string text, IReadOnlyList<EventDefinition> events)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
EventDefinition? best = null;
|
||||
var bestScore = 0;
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(e.Name))
|
||||
continue;
|
||||
|
||||
var score = Math.Max(
|
||||
Fuzz.TokenSetRatio(text, e.Name),
|
||||
Fuzz.PartialRatio(text, e.Name));
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = e;
|
||||
}
|
||||
else if (score == bestScore && best != null && e.Name.Length > best.Name.Length)
|
||||
best = e;
|
||||
}
|
||||
|
||||
if (best == null || bestScore < MinTokenScore)
|
||||
return null;
|
||||
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Core.Entities;
|
||||
using Core.Models;
|
||||
using Core.Services;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
public static class ParserRoundTripValidator
|
||||
{
|
||||
public static EventOccurrenceParseResult Validate(string text, ICollection<EventDefinition> events)
|
||||
{
|
||||
var parser = new EventOccurrenceParserService(null);
|
||||
return parser.ParseFromText(text, events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Core.Entities;
|
||||
using Core.Parsers;
|
||||
using Google.Apis.Sheets.v4.Data;
|
||||
using GoogleSheetsScheduleImport;
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("""
|
||||
Google Sheets -> Event occurrence import text (for /calendar/event-occurrences/import)
|
||||
|
||||
Required:
|
||||
--sheet-url <url-or-id> Google Sheet URL or raw spreadsheet id
|
||||
--mapping <path.json> Tab titles -> month/day (+ optional section headers)
|
||||
--output <path.txt> Output file path
|
||||
|
||||
Optional:
|
||||
--year <yyyy> Calendar year (overrides mapping file)
|
||||
--api-key <key> Google API key (else env GOOGLE_SHEETS_API_KEY)
|
||||
--tabs <a,b> Only process these tab titles (exact match)
|
||||
--events-csv <path> Event definitions CSV with 'Event' column (grouping + validation)
|
||||
--no-group-by-event Keep a single General Schedule block per sheet (no MS/HS sections)
|
||||
--strict Exit code 1 if parser reports errors or zero occurrences
|
||||
|
||||
Environment:
|
||||
GOOGLE_SHEETS_API_KEY Default API key for Sheets API (spreadsheet must be accessible to the key)
|
||||
""");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var argsDict = ParseArgs(args);
|
||||
if (argsDict.ContainsKey("help") || argsDict.ContainsKey("h"))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
var sheetUrl = GetRequired(argsDict, "sheet-url");
|
||||
var mappingPath = GetRequired(argsDict, "mapping");
|
||||
var outputPath = GetRequired(argsDict, "output");
|
||||
|
||||
var mappingJson = await File.ReadAllTextAsync(mappingPath);
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip };
|
||||
var mapping = JsonSerializer.Deserialize<MappingConfig>(mappingJson, options)
|
||||
?? throw new InvalidOperationException("Mapping file is empty or invalid JSON.");
|
||||
|
||||
var year = int.TryParse(argsDict.GetValueOrDefault("year"), out var y) ? y
|
||||
: mapping.Year ?? DateTime.Now.Year;
|
||||
|
||||
var apiKey = argsDict.GetValueOrDefault("api-key")
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_SHEETS_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
Console.Error.WriteLine("Missing API key: pass --api-key or set GOOGLE_SHEETS_API_KEY.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
HashSet<string>? tabFilter = null;
|
||||
if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg))
|
||||
{
|
||||
tabFilter = new HashSet<string>(
|
||||
tabsArg.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var spreadsheetId = SpreadsheetId.FromUrlOrId(sheetUrl);
|
||||
var reader = new GoogleSheetGridReader(apiKey);
|
||||
var spreadsheet = reader.FetchSpreadsheet(spreadsheetId);
|
||||
|
||||
var sheetMappings = mapping.Sheets ?? [];
|
||||
if (sheetMappings.Count == 0)
|
||||
throw new InvalidOperationException("Mapping file must include a non-empty \"sheets\" array with tab titles and dates.");
|
||||
|
||||
foreach (var sm in sheetMappings)
|
||||
sm.Validate();
|
||||
|
||||
var defaultSection = mapping.DefaultSectionHeader?.Trim();
|
||||
if (string.IsNullOrEmpty(defaultSection))
|
||||
defaultSection = "General Schedule";
|
||||
|
||||
var mappingDir = Path.GetDirectoryName(Path.GetFullPath(mappingPath)) ?? Directory.GetCurrentDirectory();
|
||||
var eventsCsvArg = argsDict.GetValueOrDefault("events-csv");
|
||||
var eventsCsvConfigured = mapping.EventDefinitionsCsv;
|
||||
var eventsCsvPath = !string.IsNullOrWhiteSpace(eventsCsvArg)
|
||||
? (Path.IsPathRooted(eventsCsvArg) ? eventsCsvArg : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), eventsCsvArg)))
|
||||
: (!string.IsNullOrWhiteSpace(eventsCsvConfigured)
|
||||
? Path.GetFullPath(Path.Combine(mappingDir, eventsCsvConfigured!))
|
||||
: null);
|
||||
|
||||
List<EventDefinition> csvEvents = new();
|
||||
if (!string.IsNullOrWhiteSpace(eventsCsvPath) && File.Exists(eventsCsvPath))
|
||||
csvEvents = EventDefinitionsCsvLoader.Load(eventsCsvPath);
|
||||
else if (!string.IsNullOrWhiteSpace(eventsCsvPath))
|
||||
throw new InvalidOperationException($"Event definitions CSV not found: {eventsCsvPath}");
|
||||
|
||||
var groupByEvent = csvEvents.Count > 0 && !argsDict.ContainsKey("no-group-by-event");
|
||||
|
||||
List<EventDefinition> validationEvents = MergeValidationEvents(csvEvents);
|
||||
|
||||
var warnings = new List<string>();
|
||||
var sheetOutputs = new List<(string Title, string SectionHeader, List<ParsedOccurrenceLine> Lines)>();
|
||||
|
||||
foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty<Sheet>())
|
||||
{
|
||||
var title = sheet.Properties?.Title ?? "";
|
||||
if (tabFilter != null && !tabFilter.Contains(title))
|
||||
continue;
|
||||
|
||||
var dayMap = sheetMappings.FirstOrDefault(m =>
|
||||
m.Title.Equals(title, StringComparison.Ordinal));
|
||||
if (dayMap == null)
|
||||
{
|
||||
warnings.Add($"Skipping sheet '{title}': no entry in mapping JSON.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var month = dayMap.NormalizedMonth!;
|
||||
if (!EventOccurrenceGrammar.MonthNames.Any(m => m.Equals(month, StringComparison.OrdinalIgnoreCase)))
|
||||
warnings.Add($"Sheet '{title}': month '{month}' is not a standard grammar month name.");
|
||||
try
|
||||
{
|
||||
Core.Utility.TextUtil.ParseDate(month, dayMap.Day.ToString(), year);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Sheet '{title}': invalid date {month} {dayMap.Day}, {year}: {ex.Message}");
|
||||
}
|
||||
|
||||
var grid = GoogleSheetGridReader.BuildModel(sheet);
|
||||
var lines = ScheduleGridExtractor.Extract(grid, month, dayMap.Day, warnings);
|
||||
lines = GlobalEventDeduplicator.Deduplicate(lines, mapping.SiteWideEventNames);
|
||||
var section = string.IsNullOrWhiteSpace(dayMap.SectionHeader) ? defaultSection : dayMap.SectionHeader!.Trim();
|
||||
sheetOutputs.Add((title, section, lines));
|
||||
}
|
||||
|
||||
foreach (var sm in sheetMappings)
|
||||
{
|
||||
var exists = spreadsheet.Sheets?.Any(s => string.Equals(s.Properties?.Title, sm.Title, StringComparison.Ordinal)) ?? false;
|
||||
if (!exists)
|
||||
warnings.Add($"Mapping references sheet '{sm.Title}' but it was not found in the spreadsheet.");
|
||||
}
|
||||
|
||||
var text = groupByEvent
|
||||
? GroupedImportTextEmitter.Build(sheetOutputs, csvEvents, year)
|
||||
: ImportTextEmitter.Build(sheetOutputs);
|
||||
var outDir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
|
||||
if (!string.IsNullOrEmpty(outDir))
|
||||
Directory.CreateDirectory(outDir);
|
||||
await File.WriteAllTextAsync(outputPath, text, System.Text.Encoding.UTF8);
|
||||
|
||||
Console.WriteLine($"Wrote {outputPath} ({text.Length} characters).");
|
||||
|
||||
foreach (var w in warnings)
|
||||
Console.WriteLine($"WARNING: {w}");
|
||||
|
||||
var strict = argsDict.ContainsKey("strict");
|
||||
|
||||
var parseResult = ParserRoundTripValidator.Validate(text, validationEvents);
|
||||
Console.WriteLine($"Parser round-trip: success={parseResult.IsSuccess}, occurrences={parseResult.TotalParsed}, issues={parseResult.Issues.Count}, errors={parseResult.Errors.Count}");
|
||||
foreach (var err in parseResult.Errors)
|
||||
Console.WriteLine($" ERROR: {err}");
|
||||
foreach (var issue in parseResult.Issues.Take(20))
|
||||
Console.WriteLine($" Issue L{issue.LineNumber}: {issue.Message}");
|
||||
if (parseResult.Issues.Count > 20)
|
||||
Console.WriteLine($" ... and {parseResult.Issues.Count - 20} more issues.");
|
||||
|
||||
if (strict && (!parseResult.IsSuccess || parseResult.TotalParsed == 0))
|
||||
{
|
||||
Console.Error.WriteLine("Strict mode: failing due to parser errors or zero occurrences parsed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.Error.WriteLine(ex.ToString());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var a = args[i];
|
||||
if (!a.StartsWith("--", StringComparison.Ordinal))
|
||||
continue;
|
||||
var key = a[2..];
|
||||
if (string.IsNullOrEmpty(key))
|
||||
continue;
|
||||
if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
|
||||
{
|
||||
d[key] = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
else
|
||||
d[key] = "true";
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
static string GetRequired(Dictionary<string, string> d, string key)
|
||||
{
|
||||
if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v))
|
||||
throw new InvalidOperationException($"Missing required --{key}");
|
||||
return v;
|
||||
}
|
||||
|
||||
static List<EventDefinition> MergeValidationEvents(List<EventDefinition> fromCsv)
|
||||
{
|
||||
if (fromCsv.Count == 0)
|
||||
{
|
||||
return
|
||||
[
|
||||
EventDefinition.GeneralSchedule,
|
||||
EventDefinition.MeetTheCandidates,
|
||||
EventDefinition.ChapterOfficerMeeting,
|
||||
EventDefinition.VotingDelegateMeeting,
|
||||
EventDefinition.SocialGathering
|
||||
];
|
||||
}
|
||||
|
||||
var list = new List<EventDefinition>(fromCsv);
|
||||
foreach (var extra in new EventDefinition[]
|
||||
{
|
||||
EventDefinition.MeetTheCandidates,
|
||||
EventDefinition.ChapterOfficerMeeting,
|
||||
EventDefinition.VotingDelegateMeeting,
|
||||
EventDefinition.SocialGathering
|
||||
})
|
||||
{
|
||||
if (list.All(e => !string.Equals(e.Name, extra.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
list.Add(extra);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Interprets location headers + time rows + colored/value blocks as occurrence lines.
|
||||
/// </summary>
|
||||
public static class ScheduleGridExtractor
|
||||
{
|
||||
public static List<ParsedOccurrenceLine> Extract(
|
||||
GridSheetModel grid,
|
||||
string month,
|
||||
int day,
|
||||
List<string> warnings)
|
||||
{
|
||||
var result = new List<ParsedOccurrenceLine>();
|
||||
if (grid.RowCount < 2 || grid.ColCount < 2)
|
||||
{
|
||||
warnings.Add($"Sheet '{grid.SheetTitle}': grid too small; need at least a header row and time column.");
|
||||
return result;
|
||||
}
|
||||
|
||||
var locations = new string[grid.ColCount];
|
||||
for (var c = 1; c < grid.ColCount; c++)
|
||||
{
|
||||
var header = grid.Values[0][c];
|
||||
locations[c] = string.IsNullOrWhiteSpace(header) ? $"Column {c + 1}" : header.Trim();
|
||||
}
|
||||
|
||||
var rowTimes = new TimeOnly?[grid.RowCount];
|
||||
rowTimes[0] = null;
|
||||
for (var r = 1; r < grid.RowCount; r++)
|
||||
{
|
||||
var cell = grid.Values[r][0];
|
||||
if (!TimeCellParser.TryParse(cell, out var t))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cell))
|
||||
warnings.Add($"Sheet '{grid.SheetTitle}' row {r + 1}: could not parse time label '{cell}'.");
|
||||
rowTimes[r] = null;
|
||||
}
|
||||
else
|
||||
rowTimes[r] = t;
|
||||
}
|
||||
|
||||
var slot = InferSlotDuration(rowTimes, warnings, grid.SheetTitle);
|
||||
|
||||
for (var c = 1; c < grid.ColCount; c++)
|
||||
{
|
||||
var location = locations[c];
|
||||
var r = 1;
|
||||
while (r < grid.RowCount)
|
||||
{
|
||||
if (!IsOccupied(grid, r, c))
|
||||
{
|
||||
r++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var startRow = r;
|
||||
var name = grid.Values[r][c] ?? "";
|
||||
var key = BlockKey(grid, r, c);
|
||||
|
||||
var endRow = r;
|
||||
while (endRow + 1 < grid.RowCount &&
|
||||
IsOccupied(grid, endRow + 1, c) &&
|
||||
BlockKey(grid, endRow + 1, c) == key)
|
||||
{
|
||||
endRow++;
|
||||
}
|
||||
|
||||
var startTime = rowTimes[startRow];
|
||||
if (startTime == null)
|
||||
{
|
||||
warnings.Add(
|
||||
$"Sheet '{grid.SheetTitle}' ({location}): block at row {startRow + 1} has no parseable start time in column A.");
|
||||
r = endRow + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var endInstant = ComputeEndTime(rowTimes, endRow, grid.RowCount, slot);
|
||||
|
||||
var timeRange = TimeFormatter.ToParserTimeRange(startTime.Value, endInstant);
|
||||
var title = name.Trim();
|
||||
if (string.IsNullOrEmpty(title))
|
||||
title = "(no title)";
|
||||
|
||||
result.Add(new ParsedOccurrenceLine(
|
||||
Name: title,
|
||||
Month: month,
|
||||
Day: day,
|
||||
TimeRange: timeRange,
|
||||
Location: location,
|
||||
SourceRowStart: startRow,
|
||||
SourceRowEnd: endRow,
|
||||
SourceCol: c));
|
||||
|
||||
r = endRow + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsOccupied(GridSheetModel grid, int r, int c)
|
||||
{
|
||||
var v = grid.Values[r][c];
|
||||
var bg = grid.BackgroundKeys?[r][c];
|
||||
if (!string.IsNullOrWhiteSpace(v))
|
||||
return true;
|
||||
return bg != null; // colored empty cell
|
||||
}
|
||||
|
||||
private static (string NameKey, string? Bg) BlockKey(GridSheetModel grid, int r, int c)
|
||||
{
|
||||
var raw = grid.Values[r][c] ?? "";
|
||||
var v = raw.Trim();
|
||||
var bg = grid.BackgroundKeys?[r][c];
|
||||
return (v, bg);
|
||||
}
|
||||
|
||||
private static TimeOnly ComputeEndTime(TimeOnly?[] rowTimes, int endRow, int rowCount, TimeSpan slot)
|
||||
{
|
||||
if (endRow + 1 < rowCount && rowTimes[endRow + 1] != null)
|
||||
return rowTimes[endRow + 1]!.Value;
|
||||
|
||||
var lastStart = rowTimes[endRow] ?? throw new InvalidOperationException();
|
||||
return lastStart.Add(slot);
|
||||
}
|
||||
|
||||
private static TimeSpan InferSlotDuration(TimeOnly?[] rowTimes, List<string> warnings, string sheetTitle)
|
||||
{
|
||||
var deltas = new List<int>();
|
||||
for (var i = 1; i < rowTimes.Length - 1; i++)
|
||||
{
|
||||
if (rowTimes[i] == null || rowTimes[i + 1] == null)
|
||||
continue;
|
||||
var minutes = (int)(rowTimes[i + 1]!.Value - rowTimes[i]!.Value).TotalMinutes;
|
||||
if (minutes > 0 && minutes <= 24 * 60)
|
||||
deltas.Add(minutes);
|
||||
}
|
||||
|
||||
if (deltas.Count == 0)
|
||||
{
|
||||
warnings.Add($"Sheet '{sheetTitle}': could not infer time-slot length from column A; defaulting to 30 minutes.");
|
||||
return TimeSpan.FromMinutes(30);
|
||||
}
|
||||
|
||||
var g = deltas.GroupBy(d => d).OrderByDescending(g => g.Count()).First();
|
||||
return TimeSpan.FromMinutes(g.Key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Core.Models;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to <see cref="Core.Entities.EventDefinition.Name"/>.
|
||||
/// </summary>
|
||||
public static class SchoolLevelPrefixParser
|
||||
{
|
||||
/// <returns>Remainder text and school level when unambiguous; <c>null</c> level for MS/HS combined or unknown.</returns>
|
||||
public static (string Remainder, SchoolLevel? Level) StripLeadingSchoolPrefix(string raw)
|
||||
{
|
||||
var s = TextNormalization.ForEmitLine(raw);
|
||||
if (string.IsNullOrEmpty(s))
|
||||
return (s, null);
|
||||
|
||||
if (s.StartsWith("MS/HS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = s[5..].TrimStart();
|
||||
if (rest.StartsWith('/'))
|
||||
rest = rest[1..].TrimStart();
|
||||
return (rest, null);
|
||||
}
|
||||
|
||||
if (s.Length >= 3 && s.StartsWith("MS ", StringComparison.OrdinalIgnoreCase))
|
||||
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
|
||||
if (s.Length >= 3 && s.StartsWith("HS ", StringComparison.OrdinalIgnoreCase))
|
||||
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
|
||||
|
||||
if (s.Length >= 3 && s.StartsWith("MS/", StringComparison.OrdinalIgnoreCase))
|
||||
return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
|
||||
if (s.Length >= 3 && s.StartsWith("HS/", StringComparison.OrdinalIgnoreCase))
|
||||
return (s[3..].TrimStart(), SchoolLevel.HighSchool);
|
||||
|
||||
return (s, null);
|
||||
}
|
||||
|
||||
public static string ToSectionSuffix(SchoolLevel level) =>
|
||||
level == SchoolLevel.MiddleSchool ? "MS" : "HS";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
public static class SpreadsheetId
|
||||
{
|
||||
private static readonly Regex IdRegex = new(
|
||||
@"/spreadsheets/d/([a-zA-Z0-9-_]+)",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id.
|
||||
/// </summary>
|
||||
public static string FromUrlOrId(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
var m = IdRegex.Match(trimmed);
|
||||
if (m.Success)
|
||||
return m.Groups[1].Value;
|
||||
if (trimmed.Length > 20 && !trimmed.Contains('/') && !trimmed.Contains(':'))
|
||||
return trimmed;
|
||||
throw new ArgumentException(
|
||||
"Expected a Google Sheets URL (…/spreadsheets/d/{id}/…) or a raw spreadsheet id.", nameof(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators.
|
||||
/// Import text must be one logical line per occurrence.
|
||||
/// </summary>
|
||||
public static class TextNormalization
|
||||
{
|
||||
public static string ForSheetCell(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return string.Empty;
|
||||
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
|
||||
}
|
||||
|
||||
public static string ForEmitLine(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return string.Empty;
|
||||
return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
|
||||
}
|
||||
|
||||
private static string CollapseWhitespace(string s)
|
||||
{
|
||||
var sb = new StringBuilder(s.Length);
|
||||
var pendingSpace = false;
|
||||
foreach (var ch in s)
|
||||
{
|
||||
if (char.IsWhiteSpace(ch))
|
||||
pendingSpace = true;
|
||||
else
|
||||
{
|
||||
if (pendingSpace && sb.Length > 0)
|
||||
sb.Append(' ');
|
||||
pendingSpace = false;
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Core.Parsers.EventOccurrence;
|
||||
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Parses time labels from the first column of schedule grids.
|
||||
/// </summary>
|
||||
public static class TimeCellParser
|
||||
{
|
||||
private static readonly Regex ClockRegex = new(
|
||||
@"^(?<h>\d{1,2})(?::(?<m>\d{2}))?\s*(?<ap>a\.?m\.?|p\.?m\.?|AM|PM|am|pm)\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public static bool TryParse(string? cellText, out TimeOnly time)
|
||||
{
|
||||
time = default;
|
||||
if (string.IsNullOrWhiteSpace(cellText))
|
||||
return false;
|
||||
var t = cellText.Trim();
|
||||
if (t.Equals("NOON", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
time = new TimeOnly(12, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
var m = ClockRegex.Match(t);
|
||||
if (m.Success)
|
||||
{
|
||||
var h = int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture);
|
||||
var minute = m.Groups["m"].Success ? int.Parse(m.Groups["m"].Value, CultureInfo.InvariantCulture) : 0;
|
||||
var apStr = m.Groups["ap"].Value;
|
||||
var isPm = apStr.Contains('P', StringComparison.OrdinalIgnoreCase);
|
||||
var isAm = apStr.Contains('A', StringComparison.OrdinalIgnoreCase);
|
||||
if (isPm && h < 12) h += 12;
|
||||
if (isAm && h == 12) h = 0;
|
||||
time = new TimeOnly(h, minute);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: use core TimeParser if string already looks like parsed format
|
||||
try
|
||||
{
|
||||
time = TimeParser.Parse(t);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace GoogleSheetsScheduleImport;
|
||||
|
||||
/// <summary>
|
||||
/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns).
|
||||
/// </summary>
|
||||
public static class TimeFormatter
|
||||
{
|
||||
public static string ToParserTimeString(TimeOnly time)
|
||||
{
|
||||
var h12 = time.Hour % 12;
|
||||
if (h12 == 0) h12 = 12;
|
||||
var minute = time.Minute;
|
||||
var isPm = time.Hour >= 12;
|
||||
var ap = isPm ? "p.m." : "a.m.";
|
||||
if (minute == 0)
|
||||
return $"{h12} {ap}";
|
||||
return $"{h12}:{minute:D2} {ap}";
|
||||
}
|
||||
|
||||
public static string ToParserTimeRange(TimeOnly start, TimeOnly end)
|
||||
{
|
||||
if (start == end)
|
||||
return ToParserTimeString(start);
|
||||
return $"{ToParserTimeString(start)} - {ToParserTimeString(end)}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user