Compare commits

...
18 Commits
Author SHA1 Message Date
poprhythm 5c4aaf91df Add Note and NoteHistory entities with configurations and service implementation
This commit introduces the Note and NoteHistory entities, along with their respective configurations for Entity Framework Core. The AppDbContext has been updated to include DbSet properties for both entities. A new INotesService interface and its implementation, NotesService, have been created to handle CRUD operations for notes, including history tracking. Additionally, several Blazor components have been added for note management, including dialogs for editing notes and viewing note history. The UI has been enhanced to support markdown content rendering and improved user interaction for note creation and editing. These changes contribute to a comprehensive note-taking feature within the application.
2026-01-15 21:47:01 -05:00
poprhythm 5fdda08627 Refactor chip variants in event and team components for consistency
This commit updates various components within the Events and MeetingSchedule features to utilize standardized chip variants defined in the AppIcons class. The changes include replacing hardcoded chip variants with calls to the new methods for event, team, and student chips, enhancing consistency across the UI. Additionally, the UnscheduledStudentsList component has been updated to include necessary using directives, improving code clarity. These modifications contribute to a more uniform and maintainable user interface.
2026-01-14 10:22:44 -05:00
poprhythm 68311f4012 Refactor ScheduledTeamsList component to enhance student exclusion UI
This commit updates the ScheduledTeamsList.razor component by replacing the existing MudChip implementation with InteractiveChip for better user interaction when managing student exclusions. The logic for displaying excluded students has been simplified, improving the overall readability and maintainability of the code. Additionally, the hover functionality has been removed to streamline the user experience. These changes contribute to a more intuitive and responsive scheduling interface.
2026-01-14 09:50:16 -05:00
poprhythm 27f08b3718 Refactor MeetingSchedule component to improve event handling and state management
This commit updates the Index.razor component of the MeetingSchedule feature by refactoring several methods to remove unnecessary asynchronous operations. The OnScheduledTeamsChanged, OnAbsentStudentsChanged, OnExtendedTeamsChanged, ToggleRequiredTeam, and OnToggleStudentExclusion methods have been modified to use synchronous execution, enhancing performance and simplifying the code. Additionally, the logic for toggling required teams has been improved to ensure reference equality with MudToggleGroup. These changes contribute to a more efficient and maintainable scheduling experience.
2026-01-14 09:04:55 -05:00
poprhythm 34658e9697 Implement state management for MeetingSchedule component with local storage support
This commit enhances the Index.razor component of the MeetingSchedule feature by introducing a new MeetingScheduleState class to manage the scheduling state. The component now tracks the last saved state for dirty/clean comparisons and saves the current state to local storage after solving the schedule. Additionally, the UI has been updated to reflect changes in the button's appearance based on the dirty state, improving user feedback and interaction. These changes contribute to a more robust and user-friendly scheduling experience.
2026-01-13 12:50:34 -05:00
poprhythm 8c4c21f204 Refactor overlap and unscheduled student calculations in MeetingSchedule component
This commit updates the Index.razor component of the MeetingSchedule feature to improve the calculation of student overlaps and unscheduled students. The logic now filters out excluded students when determining overlaps and ensures that students excluded from all teams are correctly identified as unscheduled. These changes enhance the accuracy of the scheduling process and improve the overall user experience.
2026-01-13 12:42:33 -05:00
poprhythm 52bf303537 Enhance MeetingSchedule component to support student exclusion management
This commit introduces functionality in the MeetingSchedule feature to manage student exclusions during scheduling. The Index.razor component has been updated to include methods for saving and loading excluded students, as well as UI elements for toggling exclusions. Additionally, the TeamScheduler logic has been modified to account for excluded students when calculating overlaps and scheduling teams. These changes improve the flexibility and accuracy of the scheduling process, enhancing the overall user experience.
2026-01-13 11:54:06 -05:00
poprhythm c505bf42dd Enhance Reset method in MeetingSchedule component to include extended teams
This commit updates the Reset method in the Index.razor component of the MeetingSchedule feature to initialize and save both scheduled and extended teams. By ensuring that both team types are reset and saved, the functionality of the scheduling system is improved, contributing to a more reliable and comprehensive team management experience.
2026-01-13 10:19:05 -05:00
poprhythm aba8ea3ae3 Refactor event, student, and team deletion logic to improve tracking and error handling
This commit updates the deletion logic in the Index.razor components for Events, Students, and Teams to load the entities fresh from the database with tracking. This change prevents tracking conflicts and ensures that the correct entity is deleted. Additionally, it adds error handling to notify users if the entity is not found or has already been deleted, enhancing the user experience and reliability of the deletion process.
2026-01-13 10:01:37 -05:00
poprhythm e2a5767b04 Refactor team extension logic in MeetingSchedule component for improved scheduling
This commit introduces a refactor of the ExtendTeamsInSolution method in the Index.razor component to enhance the handling of extended teams within the scheduling solution. The logic now includes extending teams both forward and backward in the time slots, improving the overall scheduling accuracy. Additionally, a TODO comment has been added to indicate the need to move this logic into the Core.Calculation.TeamScheduler for better integration with the constraint programming model. These changes contribute to a more robust and maintainable scheduling system.
2026-01-13 09:36:46 -05:00
poprhythm 1601610226 Implement TeamMeetingToggleSelector component and extend team management functionality
This commit introduces the TeamMeetingToggleSelector component, which allows for the selection and management of teams within the meeting schedule. The Index.razor component has been updated to utilize this new selector, enhancing the user interface for managing scheduled and extended teams. Additionally, new methods for saving and loading extended teams have been added, improving the overall functionality and user experience in team scheduling. These changes contribute to better organization and management of team events in the application.
2026-01-13 09:32:45 -05:00
poprhythm f8c22690d4 Add student name formatting utilities and refactor team student name handling
This commit introduces two new utility classes: StudentNameFormatter and TeamStudentNameFormatter, which provide methods for formatting student names with options for indicating absence and overlaps. The Team class has been updated to remove the StudentsFirstNames property, and various components across the WebApp have been refactored to utilize the new formatting utilities. This enhances the maintainability and readability of the code while improving the presentation of student names in the UI.
2026-01-11 21:43:00 -05:00
poprhythm 6cd4418142 Update package references for Core, Tests, and WebApp projects
This commit updates the version of Google.OrTools in the Core project to 9.14.6206, and upgrades various testing packages in the Tests project to their latest versions, including Microsoft.NET.Test.Sdk (18.0.1), NUnit (4.4.0), NUnit3TestAdapter (6.1.0), NUnit.Analyzers (4.11.2), and coverlet.collector (6.0.4). Additionally, the MudBlazor package in the WebApp project is updated to version 8.15.0. These updates ensure compatibility with the latest features and improvements in the respective libraries.
2026-01-11 20:23:23 -05:00
poprhythm 6acbc4e852 Enhance authentication flow by adding return URL support
This commit updates the authentication process to include a return URL parameter, allowing users to be redirected back to their original page after logging in. Changes were made to the AuthController, Login component, and Routes component to handle the return URL appropriately. Additionally, improvements were made to the TeamScheduler and TeamSchedulerSolution classes for better team and student management. These enhancements improve user experience and navigation within the application.
2026-01-11 13:13:24 -05:00
poprhythm 5a1b3fad2e Update event and team edit links to include return URL for better navigation
This commit modifies the edit links in the Events and Teams components to append a return URL parameter. This change allows users to be redirected back to the appropriate page after editing an event or team, enhancing the user experience and navigation flow. The affected files include Details.razor, Index.razor, and Registration.razor.
2026-01-11 10:47:44 -05:00
poprhythm 8af86e22d9 Refactor collection initializers to use C# 12 collection expressions
This commit updates various files across the Core and WebApp projects to replace traditional collection initializers with C# 12 collection expressions. Changes include modifications to EventAssignment.cs, TeamScheduler_DecisionTree.cs, CareerField.cs, EventDefinition.cs, and several components in the WebApp. These updates enhance code readability and maintainability by adhering to modern C# syntax standards.
2026-01-11 10:35:58 -05:00
poprhythm e53403c934 Refactor UI components to utilize MudBlazor's layout system
This commit updates several components to replace traditional HTML layout elements with MudBlazor's MudStack component for improved styling and responsiveness. Changes include the CareerMapping.razor, Index.razor, Registration.razor, TeamStudents.razor, and TeamToggleSelector.razor files. These modifications enhance the visual consistency and maintainability of the UI by adhering to the project's design standards.
2026-01-11 09:54:32 -05:00
poprhythm 5e6d61d400 Update .gitignore to include additional rules and hidden files
This commit modifies the .gitignore file to add rules for ignoring hidden files and directories, specifically those starting with a dot (e.g., .claude/* and .rules). This change helps maintain a cleaner repository by preventing unnecessary files from being tracked. Additionally, the existing rule for ignoring the DataBackup directory remains intact, ensuring that backup files are excluded from version control.
2026-01-11 09:54:11 -05:00
67 changed files with 3566 additions and 525 deletions
+3 -1
View File
@@ -23,7 +23,8 @@ _ReSharper*/
DataBackup/
*.db
/.claude/*
/.*/*
.*rules
# Production secrets and configuration
auth-secrets.json
@@ -34,3 +35,4 @@ docker-compose.override.yml
# Runtime data directory
/WebApp/Data/*
+5 -5
View File
@@ -254,7 +254,7 @@ namespace Core.Calculation
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
{
var buffer = new List<ILiteral>();
List<ILiteral> buffer = [];
foreach (var s in _allStudents)
{
buffer.Clear();
@@ -273,7 +273,7 @@ namespace Core.Calculation
/// </summary>
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
{
var prohibitVar = new List<IntVar>(1);
List<IntVar> prohibitVar = [];
foreach (var s in _allStudents)
{
@@ -328,7 +328,7 @@ namespace Core.Calculation
/// </summary>
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
{
var studentCapacity = new List<IntVar>();
List<IntVar> studentCapacity = [];
foreach (var s in _allStudents)
{
@@ -418,7 +418,7 @@ namespace Core.Calculation
/// </summary>
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
{
var eventCapacity = new List<IntVar>();
List<IntVar> eventCapacity = [];
foreach (var s in _allStudents)
{
eventCapacity.Add(x[eventIndex, s]);
@@ -455,7 +455,7 @@ namespace Core.Calculation
model.AddAssumption(x[e, s]);
}
var prohibitVar = new List<IntVar>(1);
List<IntVar> prohibitVar = [];
foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude))
{
var e = _events.IndexOf(excludedAssignment.EventDefinition);
+2 -1
View File
@@ -82,10 +82,11 @@ public class TeamScheduler
var model = new CpModel();
// Build membership matrix: m[i,t] = 1 if student i is on team t, else 0
// Use team.Students to support PartialTeam objects with omitted students
var m = new int[_students.Length,_teams.Length];
foreach (var i in _students)
foreach (var t in _teams)
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
m[i, t] = _teamObjects[t].Students.Any(s => s.Id == _studentObjects[i].Id) ? 1 : 0;
// Decision variables:
// x[t,s] = 1 if meeting of team t takes place at time slot s, else 0
+2 -1
View File
@@ -84,6 +84,7 @@ public class TeamSchedulerSolution(
public Team[] StudentUnassignedTeams(Student student)
{
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
var meetingTeamIds = meetingTeams.Select(t => t.Id).ToHashSet();
return student.Teams.Where(e => !meetingTeamIds.Contains(e.Id)).ToArray();
}
}
@@ -18,7 +18,7 @@ public class TeamScheduler_DecisionTree
{
var timeSlots = new IList<Team>[_timeSlotCount];
for (var i = 0; i < _timeSlotCount; i++)
timeSlots[i] = new List<Team>();
timeSlots[i] = [];
foreach (var team in _teams.OrderByDescending(t => t.Students.Count))
{
+1 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Google.OrTools" Version="9.7.2996" />
<PackageReference Include="Google.OrTools" Version="9.14.6206" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
+3 -6
View File
@@ -43,13 +43,10 @@ public class CareerField
Id = id;
Name = name;
Description = description;
DirectCareerMatches = directCareerMatches ?? Array.Empty<string>();
PatternKeywords = patternKeywords ?? Array.Empty<string>();
DirectCareerMatches = directCareerMatches ?? [];
PatternKeywords = patternKeywords ?? [];
}
public override string ToString()
{
return Name;
}
public override string ToString() => Name;
}
+2 -20
View File
@@ -37,21 +37,6 @@ public class EventDefinition
=> SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"));
public bool OnSiteActivity { get; set; }
//=> SemifinalistActivity != null
// && (SemifinalistActivity.Contains("Challenge")
// || SemifinalistActivity.Contains("Race")
// || SemifinalistActivity.Contains("Speech")
// || SemifinalistActivity.Contains("Test")
// || SemifinalistActivity.Contains("Flight")
// || Name.Contains("Leadership")
// || Name.Contains("Forensic")
// || Name.Contains("Flight")
// || Name.Contains("Coding")
// || SemifinalistActivity.Contains("Debate")
// || SemifinalistActivity.Contains("Photography")
// || SemifinalistActivity.Contains("Build")
// || Name.Contains("Chapter")
// || Name.Contains("Podcast"));
[StringLength(1024)]
public string? Notes { get; set; }
@@ -79,15 +64,12 @@ public class EventDefinition
public string? Description { get; set; }
public int? LevelOfEffort { get; set; }
public ICollection<Career> RelatedCareers { get; set; } = new List<Career>();
public ICollection<Career> RelatedCareers { get; set; } = [];
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public string? RelatedCareersText { get; set; }
public override string ToString()
{
return Name;
}
public override string ToString() => Name;
public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"};
public static readonly EventDefinition MeetTheCandidates = new(){Name = "Meet the Candidates"};
+30
View File
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Core.Entities;
public class Note
{
public int Id { get; set; }
[Required]
[StringLength(200)]
[Display(Name = "Title")]
public string Title { get; set; } = null!;
[Display(Name = "Content")]
public string? Content { get; set; }
[Display(Name = "Created At")]
public DateTime CreatedAt { get; set; }
[Display(Name = "Updated At")]
public DateTime UpdatedAt { get; set; }
[Display(Name = "Created By")]
public string? CreatedBy { get; set; }
[Display(Name = "Last Modified By")]
public string? LastModifiedBy { get; set; }
public List<NoteHistory> NoteHistories { get; } = [];
}
+32
View File
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Core.Entities;
public class NoteHistory
{
public int Id { get; set; }
[Required]
[Display(Name = "Note Id")]
public int NoteId { get; set; }
[StringLength(200)]
[Display(Name = "Title")]
public string Title { get; set; } = null!;
[Display(Name = "Content")]
public string? Content { get; set; }
[Display(Name = "Modified By")]
public string? ModifiedBy { get; set; }
[Display(Name = "Modified At")]
public DateTime ModifiedAt { get; set; }
[Required]
[StringLength(50)]
[Display(Name = "Change Type")]
public string ChangeType { get; set; } = null!;
public Note Note { get; set; } = null!;
}
-13
View File
@@ -63,17 +63,4 @@ public class Team
{
return $"{Event.Name} {(Identifier != null ? $"({Identifier})" : "")}";
}
public string StudentsFirstNames
{
get
{
return
string.Join(", ",
Students.Select(e =>
e.FirstName
+ (Captain != null && (Captain.Equals(e)) ? "(Cpt)" : ""))
);
}
}
}
+3 -3
View File
@@ -14,11 +14,11 @@ public static class EventOccurrenceGrammar
/// Array of all month names in order (January through December).
/// This is the single source of truth for month names used throughout the parser.
/// </summary>
public static readonly string[] MonthNames = new[]
{
public static readonly string[] MonthNames =
[
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
};
];
// Build month parsers dynamically from MonthNames array
private static readonly Parser<string>[] MonthParsers = MonthNames
+1 -1
View File
@@ -27,7 +27,7 @@ public class StudentEventRankingParser : CsvParserBase
continue;
var competitiveEvents = new List<EventDefinition>(6);
var competitiveEvents = new List<EventDefinition>();
for (var i = 1; i <= 6; i++)
{
+3 -3
View File
@@ -13,7 +13,7 @@ public class StudentParser : CsvParserBase
public Student[] Parse()
{
var s = new List<Student>();
var students = new List<Student>();
CsvReader.Read();
CsvReader.ReadHeader();
@@ -44,9 +44,9 @@ public class StudentParser : CsvParserBase
RegionalId = regionalId,
NationalId = nationalId
};
s.Add(student);
students.Add(student);
}
return s.ToArray();
return students.ToArray();
}
}
+55 -55
View File
@@ -23,7 +23,7 @@ public static class CareerFieldDefinitions
public static IReadOnlyList<CareerField> GetRelatedCareerFields(IEnumerable<Career> careers)
{
if (careers == null)
return Array.Empty<CareerField>();
return [];
var careerNames = careers
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
@@ -31,7 +31,7 @@ public static class CareerFieldDefinitions
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!careerNames.Any())
return Array.Empty<CareerField>();
return [];
var matchingFields = new HashSet<CareerField>();
var allFields = GetAllCareerFields();
@@ -74,15 +74,15 @@ public static class CareerFieldDefinitions
private static IReadOnlyList<CareerField> CreateAllCareerFields()
{
return new List<CareerField>
{
return
[
// 1. Aerospace & Automotive Engineering
new CareerField(
1,
"Aerospace & Automotive Engineering",
"Careers focused on designing and engineering aircraft, spacecraft, and vehicles for transportation.",
new[] { "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" },
new[] { "aeronautical", "aircraft", "automobile", "automotive", "race car" }
[ "Aeronautical engineer", "Aircraft systems engineer", "Automobile designer", "Automotive designer", "Automotive modeler", "Race car engineer" ],
[ "aeronautical", "aircraft", "automobile", "automotive", "race car" ]
),
// 2. Mechanical & Robotics Engineering
@@ -90,8 +90,8 @@ public static class CareerFieldDefinitions
2,
"Mechanical & Robotics Engineering",
"Engineering disciplines involving mechanical systems, machinery design, and automated robotic systems.",
new[] { "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" },
new[] { "mechanical", "robotics", "machine" }
[ "Machine designer", "Mechanical drafter", "Mechanical engineer", "Robotics engineer" ],
[ "mechanical", "robotics", "machine" ]
),
// 3. Electrical & Electronics Engineering
@@ -99,8 +99,8 @@ public static class CareerFieldDefinitions
3,
"Electrical & Electronics Engineering",
"Careers involving electrical systems, circuits, and electronic device design and maintenance.",
new[] { "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" },
new[] { "electrical", "electronic", "electrician" }
[ "Electrical engineer", "Electrical technician", "Electrician", "Electromechanical engineer", "Electronic analyst", "Electronic designer" ],
[ "electrical", "electronic", "electrician" ]
),
// 4. Civil & Structural Engineering
@@ -108,8 +108,8 @@ public static class CareerFieldDefinitions
4,
"Civil & Structural Engineering",
"Engineering fields focused on infrastructure, buildings, bridges, and construction project management.",
new[] { "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" },
new[] { "civil", "construction", "structural", "contractor" }
[ "Civil engineer", "Construction analyst", "Construction manager", "General contractor", "Structural engineer", "Structural iron and steel work technician" ],
[ "civil", "construction", "structural", "contractor" ]
),
// 5. Environmental & Energy Engineering
@@ -117,8 +117,8 @@ public static class CareerFieldDefinitions
5,
"Environmental & Energy Engineering",
"Engineering careers focused on sustainable energy solutions, environmental protection, and chemical processes.",
new[] { "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" },
new[] { "chemical", "energy", "environmental", "solar" }
[ "Chemical engineer", "Energy efficiency technician", "Environmental engineer", "Solar engineer", "Solar panel installer", "Solar sales consultant" ],
[ "chemical", "energy", "environmental", "solar" ]
),
// 6. General Engineering & Quality
@@ -126,8 +126,8 @@ public static class CareerFieldDefinitions
6,
"General Engineering & Quality",
"Broad engineering roles including management, quality assurance, and standards compliance across various industries.",
new[] { "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" },
new[] { "engineer", "quality", "standards" }
[ "Engineer", "Engineering manager", "Engineering technician", "Quality assurance engineer", "Quality engineer", "Standards engineer" ],
[ "engineer", "quality", "standards" ]
),
// 7. Architecture & Urban Planning
@@ -135,8 +135,8 @@ public static class CareerFieldDefinitions
7,
"Architecture & Urban Planning",
"Design and planning careers focused on buildings, spaces, and community development.",
new[] { "Architect", "Community planner", "Interior designer", "Urban and regional planner" },
new[] { "architect", "planner", "interior design", "urban" }
[ "Architect", "Community planner", "Interior designer", "Urban and regional planner" ],
[ "architect", "planner", "interior design", "urban" ]
),
// 8. Software Development
@@ -144,8 +144,8 @@ public static class CareerFieldDefinitions
8,
"Software Development",
"Careers in creating, designing, and developing computer software applications and systems.",
new[] { "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" },
new[] { "programming", "programmer", "software", "developer" }
[ "Computer programmer", "Computer software engineer", "Programming & software development", "Software designer", "Software engineer" ],
[ "programming", "programmer", "software", "developer" ]
),
// 9. IT & Networking
@@ -153,8 +153,8 @@ public static class CareerFieldDefinitions
9,
"IT & Networking",
"Information technology careers involving computer systems, networks, technical support, and telecommunications.",
new[] { "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" },
new[] { "network", "computer", "technical support", "telecommunications", "IT" }
[ "Computer engineer", "Computer network specialist", "Computer technician", "Information support & services", "Network systems", "Technical support specialist", "Telecommunications manager" ],
[ "network", "computer", "technical support", "telecommunications", "IT" ]
),
// 10. Cybersecurity & Digital Forensics
@@ -162,8 +162,8 @@ public static class CareerFieldDefinitions
10,
"Cybersecurity & Digital Forensics",
"Security-focused careers protecting digital systems, investigating cybercrimes, and ensuring information security.",
new[] { "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" },
new[] { "cyber", "security", "forensics", "cryptography", "vulnerability" }
[ "Cryptographer", "Cyber Crime Investigator", "Cyber defense incident responder", "Cyber forensics expert", "Cyber legal advisor", "Cyber operator", "Cybersecurity engineer", "Vulnerability assessor" ],
[ "cyber", "security", "forensics", "cryptography", "vulnerability" ]
),
// 11. Data Science & Analytics
@@ -171,8 +171,8 @@ public static class CareerFieldDefinitions
11,
"Data Science & Analytics",
"Careers analyzing data, applying mathematical and statistical methods to solve problems and make decisions.",
new[] { "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" },
new[] { "data", "analyst", "actuary", "economist", "mathematician", "research" }
[ "Actuary", "Data analyst", "Data scientist", "Economist", "Mathematician", "Operations research analyst" ],
[ "data", "analyst", "actuary", "economist", "mathematician", "research" ]
),
// 12. CAD, CNC & Manufacturing
@@ -180,8 +180,8 @@ public static class CareerFieldDefinitions
12,
"CAD, CNC & Manufacturing",
"Careers in computer-aided design, manufacturing processes, and production planning.",
new[] { "CAD professional", "CNC programmer", "Manufacturing", "Production planner" },
new[] { "CAD", "CNC", "manufacturing", "production" }
[ "CAD professional", "CNC programmer", "Manufacturing", "Production planner" ],
[ "CAD", "CNC", "manufacturing", "production" ]
),
// 13. Industrial & Product Design
@@ -189,8 +189,8 @@ public static class CareerFieldDefinitions
13,
"Industrial & Product Design",
"Design careers creating products, commercial goods, and industrial solutions with focus on form and function.",
new[] { "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" },
new[] { "designer", "design", "industrial", "product", "appraiser" }
[ "Appraiser", "Commercial and industrial design", "Designer", "Industrial designer", "Product designer" ],
[ "designer", "design", "industrial", "product", "appraiser" ]
),
// 14. Visual Arts & Animation
@@ -198,8 +198,8 @@ public static class CareerFieldDefinitions
14,
"Visual Arts & Animation",
"Creative careers in visual design, illustration, animation, and digital art creation.",
new[] { "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" },
new[] { "animator", "artist", "graphic", "illustrator", "multimedia" }
[ "Animator", "Artist", "Computer animator", "Graphic artist", "Illustrator", "Multimedia designer" ],
[ "animator", "artist", "graphic", "illustrator", "multimedia" ]
),
// 15. Game Design & Interactive Media
@@ -207,8 +207,8 @@ public static class CareerFieldDefinitions
15,
"Game Design & Interactive Media",
"Careers in video game design, development, testing, and professional gaming.",
new[] { "Game designer", "Game Play Tester", "Professional Gamer" },
new[] { "game", "gamer", "gaming" }
[ "Game designer", "Game Play Tester", "Professional Gamer" ],
[ "game", "gamer", "gaming" ]
),
// 16. Audio & Music Production
@@ -216,8 +216,8 @@ public static class CareerFieldDefinitions
16,
"Audio & Music Production",
"Careers in audio engineering, music composition, sound design, and broadcast technology.",
new[] { "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" },
new[] { "audio", "music", "broadcast", "sound" }
[ "Audio designer or engineer", "Audio Engineer", "Audio operator or technician", "Broadcast technician", "Music composer" ],
[ "audio", "music", "broadcast", "sound" ]
),
// 17. Video & Film Production
@@ -225,8 +225,8 @@ public static class CareerFieldDefinitions
17,
"Video & Film Production",
"Careers in video production, filmmaking, directing, and television broadcasting.",
new[] { "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" },
new[] { "video", "film", "director", "television", "broadcast", "videographer" }
[ "Audiovisual technician", "Director", "Entertainment/television broadcaster", "Videographer" ],
[ "video", "film", "director", "television", "broadcast", "videographer" ]
),
// 18. Web & Digital Communications
@@ -234,8 +234,8 @@ public static class CareerFieldDefinitions
18,
"Web & Digital Communications",
"Careers in web design, digital communication, and instructional technology.",
new[] { "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" },
new[] { "web", "website", "digital", "communications", "webmaster" }
[ "Instructional technologist", "Web & digital communications", "Webmaster", "Website designer" ],
[ "web", "website", "digital", "communications", "webmaster" ]
),
// 19. Writing & Publishing
@@ -243,8 +243,8 @@ public static class CareerFieldDefinitions
19,
"Writing & Publishing",
"Careers in writing, editing, publishing, and content creation across various media formats.",
new[] { "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" },
new[] { "writing", "writer", "editor", "publisher", "copy" }
[ "Ad copy writer", "Editor", "Publisher", "Screenplay writer", "Speech writer", "Technical writer", "Writer" ],
[ "writing", "writer", "editor", "publisher", "copy" ]
),
// 20. Journalism & Public Relations
@@ -252,8 +252,8 @@ public static class CareerFieldDefinitions
20,
"Journalism & Public Relations",
"Careers in news reporting, photojournalism, public relations, and communications management.",
new[] { "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" },
new[] { "journalism", "reporter", "photojournalist", "communications", "speaker" }
[ "Internal communications manager", "Motivational speaker", "Photojournalist", "Reporter" ],
[ "journalism", "reporter", "photojournalist", "communications", "speaker" ]
),
// 21. Forensics & Criminal Investigation
@@ -261,8 +261,8 @@ public static class CareerFieldDefinitions
21,
"Forensics & Criminal Investigation",
"Careers in criminal investigation, forensic science, and analyzing evidence for legal proceedings.",
new[] { "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" },
new[] { "forensic", "detective", "investigator", "crime" }
[ "Crime scene investigator", "Detective", "Forensic accountant", "Forensic anthropologist", "Forensic engineering scientist", "Forensic pathologist" ],
[ "forensic", "detective", "investigator", "crime" ]
),
// 22. Healthcare & Medical Technology
@@ -270,8 +270,8 @@ public static class CareerFieldDefinitions
22,
"Healthcare & Medical Technology",
"Medical and healthcare careers providing patient care, medical technology, and health services.",
new[] { "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" },
new[] { "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" }
[ "Dietitian", "Doctor", "Epidemiologist", "Medical technologist", "Nurse", "Pharmacist", "Prosthetics practitioner" ],
[ "medical", "health", "doctor", "nurse", "pharmacist", "dietitian", "epidemiology" ]
),
// 23. Science & Research
@@ -279,8 +279,8 @@ public static class CareerFieldDefinitions
23,
"Science & Research",
"Scientific research careers across biology, physics, meteorology, and other scientific disciplines.",
new[] { "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" },
new[] { "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" }
[ "Botanist", "Food scientist", "Meteorologist", "Molecular biologist", "Physics instructor", "Plant geneticist", "Research and development scientist", "Research assistant", "Researcher" ],
[ "scientist", "research", "biology", "physics", "botanist", "meteorologist", "geneticist" ]
),
// 24. Education & Training
@@ -288,8 +288,8 @@ public static class CareerFieldDefinitions
24,
"Education & Training",
"Careers in teaching, training, and educational instruction across various subjects and technologies.",
new[] { "Educator", "Teacher/trainer", "Technology education instructor" },
new[] { "educator", "teacher", "trainer", "education", "instructor" }
[ "Educator", "Teacher/trainer", "Technology education instructor" ],
[ "educator", "teacher", "trainer", "education", "instructor" ]
),
// 25. Business, Legal & Government
@@ -297,10 +297,10 @@ public static class CareerFieldDefinitions
25,
"Business, Legal & Government",
"Careers in business management, legal services, government, politics, and public policy.",
new[] { "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" },
new[] { "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" }
[ "Creative consultant", "Entrepreneur", "Government Official", "Lawyer", "Legal Aide", "Lobbyist", "Management executive", "Market researcher", "Marketing strategist", "Parliamentarian", "Politician", "Project manager", "Public affairs specialist", "Public policy specialist", "Recording Clerk", "Small business owner", "Volunteer manager" ],
[ "business", "legal", "lawyer", "government", "politician", "manager", "marketing", "consultant", "entrepreneur" ]
)
};
];
}
}
+53
View File
@@ -0,0 +1,53 @@
using Core.Entities;
namespace Core.Utility;
/// <summary>
/// Utility class for formatting individual student names with overlap and absent markers.
/// </summary>
public static class StudentNameFormatter
{
/// <summary>
/// Options for formatting student names.
/// </summary>
public record FormatOptions
{
/// <summary>
/// Whether the student is absent. If true, adds "(absent)" suffix. Default is false.
/// </summary>
public bool IsAbsent { get; init; } = false;
/// <summary>
/// Whether the student has schedule overlaps. If true, adds "*" suffix. Default is false.
/// </summary>
public bool HasOverlap { get; init; } = false;
}
/// <summary>
/// Formats a single student name with overlap and absent markers.
/// </summary>
/// <param name="student">The student to format.</param>
/// <param name="options">Formatting options.</param>
/// <returns>Formatted student name.</returns>
public static string FormatStudentName(Student student, FormatOptions options)
{
if (student == null)
return string.Empty;
var name = student.FirstName;
// Add overlap marker
if (options.HasOverlap)
{
name += "*";
}
// Add absent marker
if (options.IsAbsent)
{
name += " (absent)";
}
return name;
}
}
+261
View File
@@ -0,0 +1,261 @@
using Core.Entities;
namespace Core.Utility;
/// <summary>
/// Utility class for formatting student names for teams with various formatting options.
/// </summary>
public static class TeamStudentNameFormatter
{
/// <summary>
/// Options for formatting student names.
/// </summary>
public record FormatOptions
{
/// <summary>
/// Style for indicating the team captain. Default is None.
/// </summary>
public CaptainIndicatorStyle CaptainIndicator { get; init; } = CaptainIndicatorStyle.None;
/// <summary>
/// How to order the students. Default is None (preserve original order).
/// </summary>
public OrderingStyle Ordering { get; init; } = OrderingStyle.None;
/// <summary>
/// Whether to add "*" suffix for students with schedule overlaps. Default is false.
/// </summary>
public bool MarkOverlaps { get; init; } = false;
/// <summary>
/// Whether to add "(absent)" suffix for absent students. Default is false.
/// </summary>
public bool MarkAbsent { get; init; } = false;
/// <summary>
/// Function to determine if a student has overlaps. Required if MarkOverlaps is true.
/// </summary>
public Func<Student, bool>? HasOverlaps { get; init; }
/// <summary>
/// Collection of absent students. Required if MarkAbsent is true.
/// </summary>
public ICollection<Student>? AbsentStudents { get; init; }
/// <summary>
/// Whether to only show captain indicator for team events (EventFormat.Team). Default is true.
/// </summary>
public bool OnlyTeamEventsForCaptain { get; init; } = true;
}
/// <summary>
/// Style for indicating the team captain.
/// </summary>
public enum CaptainIndicatorStyle
{
/// <summary>
/// No captain indicator.
/// </summary>
None,
/// <summary>
/// Use asterisk (*) for captain.
/// </summary>
Star,
/// <summary>
/// Use "(Cpt)" for captain.
/// </summary>
Captain
}
/// <summary>
/// Style for ordering students.
/// </summary>
public enum OrderingStyle
{
/// <summary>
/// Preserve original order.
/// </summary>
None,
/// <summary>
/// Captain first, then alphabetical by first name.
/// </summary>
CaptainFirst,
/// <summary>
/// Alphabetical by first name.
/// </summary>
Alphabetical,
/// <summary>
/// By grade + TSA year descending (highest first).
/// </summary>
GradeDescending
}
/// <summary>
/// Formats a single student name with the specified options.
/// </summary>
/// <param name="student">The student to format.</param>
/// <param name="team">The team the student belongs to.</param>
/// <param name="options">Formatting options.</param>
/// <returns>Formatted student name.</returns>
public static string FormatStudentName(Student student, Team team, FormatOptions options)
{
if (student == null)
return string.Empty;
var name = student.FirstName;
// Add captain indicator (before overlap/absent markers)
if (options.CaptainIndicator != CaptainIndicatorStyle.None && team != null && team.Captain != null && team.Captain.Equals(student))
{
var shouldShow = !options.OnlyTeamEventsForCaptain || (team.Event != null && team.Event.EventFormat == EventFormat.Team);
if (shouldShow)
{
name += options.CaptainIndicator switch
{
CaptainIndicatorStyle.Star => "*",
CaptainIndicatorStyle.Captain => "(Cpt)",
_ => string.Empty
};
}
}
// Convert collections/functions to booleans for StudentNameFormatter
var hasOverlap = options.MarkOverlaps && options.HasOverlaps != null && options.HasOverlaps(student);
var isAbsent = options.MarkAbsent && options.AbsentStudents != null && options.AbsentStudents.Contains(student);
// Use StudentNameFormatter for overlap/absent markers (appended after captain indicator)
var studentNameOptions = new StudentNameFormatter.FormatOptions
{
HasOverlap = hasOverlap,
IsAbsent = isAbsent
};
// Get the suffix from StudentNameFormatter (overlap/absent markers)
var baseFormatted = StudentNameFormatter.FormatStudentName(student, studentNameOptions);
var suffix = baseFormatted.Substring(student.FirstName.Length);
return name + suffix;
}
/// <summary>
/// Formats all students for a team as a comma-separated string.
/// </summary>
/// <param name="team">The team to format students for.</param>
/// <param name="options">Formatting options.</param>
/// <returns>Comma-separated string of formatted student names.</returns>
public static string FormatStudentList(Team team, FormatOptions options)
{
if (team?.Students == null || !team.Students.Any())
return string.Empty;
var students = ApplyOrdering(team.Students, team, options.Ordering);
return string.Join(", ", students.Select(s => FormatStudentName(s, team, options)));
}
/// <summary>
/// Formats all students for a team as a list of strings.
/// </summary>
/// <param name="team">The team to format students for.</param>
/// <param name="options">Formatting options.</param>
/// <returns>List of formatted student names.</returns>
public static List<string> FormatStudentListAsList(Team team, FormatOptions options)
{
if (team?.Students == null || !team.Students.Any())
return [];
var students = ApplyOrdering(team.Students, team, options.Ordering);
return students.Select(s => FormatStudentName(s, team, options)).ToList();
}
/// <summary>
/// Formats all unique students across multiple teams for an event definition.
/// Returns a list of unique student names (by student ID).
/// </summary>
/// <param name="eventDefinition">The event definition.</param>
/// <param name="teams">The teams for this event.</param>
/// <param name="options">Formatting options.</param>
/// <returns>List of unique formatted student names.</returns>
public static List<string> FormatStudentListForEvent(EventDefinition eventDefinition, IEnumerable<Team> teams, FormatOptions options)
{
if (eventDefinition == null || teams == null)
return [];
var teamsList = teams.ToList();
if (!teamsList.Any())
return [];
// Get all unique students from all teams for this event
var allStudents = teamsList
.SelectMany(t => t.Students)
.DistinctBy(s => s.Id)
.ToList();
if (!allStudents.Any())
return [];
// Determine if each student is a captain in any team
var studentsWithCaptainInfo = allStudents.Select(s =>
{
var isCaptain = teamsList.Any(t => t.Captain != null && t.Captain.Equals(s));
// Find a team this student belongs to for formatting context
var studentTeam = teamsList.FirstOrDefault(t => t.Students.Contains(s));
return new { Student = s, IsCaptain = isCaptain, Team = studentTeam };
}).ToList();
// Apply ordering
var orderedStudents = options.Ordering switch
{
OrderingStyle.CaptainFirst => studentsWithCaptainInfo
.OrderBy(x => !x.IsCaptain)
.ThenBy(x => x.Student.FirstName)
.Select(x => x.Student),
OrderingStyle.Alphabetical => studentsWithCaptainInfo
.OrderBy(x => x.Student.FirstName)
.Select(x => x.Student),
OrderingStyle.GradeDescending => studentsWithCaptainInfo
.OrderByDescending(x => x.Student.Grade + x.Student.TsaYear)
.Select(x => x.Student),
_ => studentsWithCaptainInfo.Select(x => x.Student)
};
// Format names - for event-level formatting, we need to handle captain indicator specially
// since a student might be captain in one team but not another
return orderedStudents.Select(s =>
{
var studentInfo = studentsWithCaptainInfo.First(x => x.Student.Equals(s));
// Use the student's team if available, otherwise create a minimal team for formatting context
var team = studentInfo.Team ?? new Team { Students = [s], Event = eventDefinition };
// Create options that handle captain indicator for event-level
var eventOptions = options with
{
CaptainIndicator = studentInfo.IsCaptain &&
(!options.OnlyTeamEventsForCaptain || eventDefinition.EventFormat == EventFormat.Team)
? options.CaptainIndicator
: CaptainIndicatorStyle.None
};
return FormatStudentName(s, team, eventOptions);
}).ToList();
}
private static IEnumerable<Student> ApplyOrdering(IEnumerable<Student> students, Team team, OrderingStyle ordering)
{
return ordering switch
{
OrderingStyle.CaptainFirst => students
.OrderBy(s => team.Captain == null || !team.Captain.Equals(s))
.ThenBy(s => s.FirstName),
OrderingStyle.Alphabetical => students.OrderBy(s => s.FirstName),
OrderingStyle.GradeDescending => students.OrderByDescending(s => s.Grade + s.TsaYear),
_ => students
};
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using Core.Entities;
using Microsoft.EntityFrameworkCore;
@@ -12,6 +12,8 @@ namespace Data
public DbSet<StudentEventRanking> StudentEventRanking { get; set; }
public DbSet<EventOccurrence> EventOccurrences { get; set; }
public DbSet<Career> Careers { get; set; }
public DbSet<Note> Notes { get; set; }
public DbSet<NoteHistory> NoteHistories { get; set; }
public AppDbContext()
{
+39
View File
@@ -0,0 +1,39 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class NoteConfiguration : IEntityTypeConfiguration<Note>
{
public void Configure(EntityTypeBuilder<Note> builder)
{
builder.HasKey(n => n.Id);
// Indexes
builder.HasIndex(n => n.Title);
builder.HasIndex(n => n.CreatedAt);
// Constraints
builder.Property(n => n.Title)
.IsRequired()
.HasMaxLength(200);
builder.Property(n => n.Content)
.HasColumnType("TEXT");
builder.Property(n => n.CreatedBy)
.HasMaxLength(255);
builder.Property(n => n.LastModifiedBy)
.HasMaxLength(255);
// Relationships
builder.HasMany(n => n.NoteHistories)
.WithOne(h => h.Note)
.HasForeignKey(h => h.NoteId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Data.Configurations
{
public class NoteHistoryConfiguration : IEntityTypeConfiguration<NoteHistory>
{
public void Configure(EntityTypeBuilder<NoteHistory> builder)
{
builder.HasKey(h => h.Id);
// Indexes
builder.HasIndex(h => h.NoteId);
builder.HasIndex(h => h.ModifiedAt);
builder.HasIndex(h => new { h.NoteId, h.ModifiedAt });
// Constraints
builder.Property(h => h.Title)
.IsRequired()
.HasMaxLength(200);
builder.Property(h => h.Content)
.HasColumnType("TEXT");
builder.Property(h => h.ModifiedBy)
.HasMaxLength(255);
builder.Property(h => h.ChangeType)
.IsRequired()
.HasMaxLength(50);
// Relationship is configured in NoteConfiguration
}
}
}
@@ -0,0 +1,481 @@
// <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("20260115185640_AddNotesAndNoteHistory")]
partial class AddNotesAndNoteHistory
{
/// <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<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("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("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("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("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Navigation("EventRankings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.Migrations
{
/// <inheritdoc />
public partial class AddNotesAndNoteHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
LastModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Notes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NoteHistories",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
NoteId = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "TEXT", nullable: true),
ModifiedBy = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
ModifiedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
ChangeType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NoteHistories", x => x.Id);
table.ForeignKey(
name: "FK_NoteHistories_Notes_NoteId",
column: x => x.NoteId,
principalTable: "Notes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_NoteHistories_ModifiedAt",
table: "NoteHistories",
column: "ModifiedAt");
migrationBuilder.CreateIndex(
name: "IX_NoteHistories_NoteId",
table: "NoteHistories",
column: "NoteId");
migrationBuilder.CreateIndex(
name: "IX_NoteHistories_NoteId_ModifiedAt",
table: "NoteHistories",
columns: new[] { "NoteId", "ModifiedAt" });
migrationBuilder.CreateIndex(
name: "IX_Notes_CreatedAt",
table: "Notes",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Notes_Title",
table: "Notes",
column: "Title");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NoteHistories");
migrationBuilder.DropTable(
name: "Notes");
}
}
}
@@ -177,6 +177,83 @@ namespace Data.Migrations
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<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("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")
@@ -323,6 +400,17 @@ namespace Data.Migrations
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")
@@ -375,6 +463,11 @@ namespace Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Core.Entities.Note", b =>
{
b.Navigation("NoteHistories");
});
modelBuilder.Entity("Core.Entities.Student", b =>
{
b.Navigation("EventRankings");
+5 -5
View File
@@ -10,11 +10,11 @@
<None Remove="Parsers\TestInput\2024 TN TSA State Competition Event Times.txt" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
+20 -4
View File
@@ -26,7 +26,8 @@ namespace WebApp.Authentication
public async Task<IActionResult> CookieLogin(
[FromForm] string email,
[FromForm] string password,
[FromForm] bool rememberMe = false)
[FromForm] bool rememberMe = false,
[FromForm] string? returnUrl = null)
{
try
{
@@ -42,7 +43,10 @@ namespace WebApp.Authentication
ipAddress, remaining);
var errorMsg = Uri.EscapeDataString($"Too many failed attempts. Try again in {remaining?.Minutes ?? 15} minutes.");
return Redirect($"/login?error={errorMsg}");
var redirectUrl = string.IsNullOrEmpty(returnUrl)
? $"/login?error={errorMsg}"
: $"/login?error={errorMsg}&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
}
// Validate credentials
@@ -57,7 +61,10 @@ namespace WebApp.Authentication
"Failed login attempt for {Email} from {IpAddress}",
email, ipAddress);
return Redirect("/login?error=Invalid%20email%20or%20password.");
var redirectUrl = string.IsNullOrEmpty(returnUrl)
? "/login?error=Invalid%20email%20or%20password."
: $"/login?error=Invalid%20email%20or%20password.&returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
}
// Success - clear rate limit tracking
@@ -89,13 +96,22 @@ namespace WebApp.Authentication
"Successful login for {Email} ({Role}) from {IpAddress}",
result.Email, result.Role, ipAddress);
// Validate return URL is local to prevent open redirect attacks
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return Redirect("/");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during login process");
TempData["LoginError"] = "An error occurred. Please try again.";
return Redirect("/login");
var redirectUrl = string.IsNullOrEmpty(returnUrl)
? "/login"
: $"/login?returnUrl={Uri.EscapeDataString(returnUrl)}";
return Redirect(redirectUrl);
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -7,6 +7,8 @@
<base href="/" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<link href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" rel="stylesheet" />
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/easymde.min.css" rel="stylesheet" />
<link href="_content/PSC.Blazor.Components.MarkdownEditor/css/markdowneditor.css" rel="stylesheet" />
<link rel="stylesheet" href="app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
@@ -17,9 +19,12 @@
<Routes @rendermode="InteractiveServer" />
</AppErrorBoundary>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="_framework/blazor.web.js"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/easymde.min.js"></script>
<script src="_content/PSC.Blazor.Components.MarkdownEditor/js/markdownEditor.js"></script>
</body>
</html>
@@ -32,6 +32,7 @@
<input type="hidden" id="emailInput" name="email" value="" />
<input type="hidden" id="passwordInput" name="password" value="" />
<input type="hidden" id="rememberMeInput" name="rememberMe" value="" />
<input type="hidden" id="returnUrlInput" name="returnUrl" value="@_returnUrl" />
<MudTextField @bind-Value="_loginModel.Email"
Label="Email"
@@ -75,6 +76,7 @@
private MudInputType _passwordInput = MudInputType.Password;
private string _passwordInputIcon = Icons.Material.Filled.VisibilityOff;
private string _antiforgeryToken = string.Empty;
private string? _returnUrl;
protected override void OnInitialized()
{
@@ -86,13 +88,17 @@
_antiforgeryToken = tokenSet.RequestToken ?? string.Empty;
}
// Check for error message from query parameter (set by controller on failed login)
// Check for error message and returnUrl from query parameters
var uri = new Uri(Navigation.Uri);
var queryParams = QueryHelpers.ParseQuery(uri.Query);
if (queryParams.TryGetValue("error", out var errorValue))
{
_errorMessage = errorValue.ToString();
}
if (queryParams.TryGetValue("returnUrl", out var returnUrlValue))
{
_returnUrl = returnUrlValue.ToString();
}
}
private class LoginModel
@@ -141,10 +147,12 @@
private async Task HandleFormSubmit()
{
// Update hidden inputs with current model values, then submit the form
var returnUrlValue = string.IsNullOrEmpty(_returnUrl) ? "" : System.Text.Json.JsonSerializer.Serialize(_returnUrl);
await JS.InvokeVoidAsync("eval", $@"
document.getElementById('emailInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Email)};
document.getElementById('passwordInput').value = {System.Text.Json.JsonSerializer.Serialize(_loginModel.Password)};
document.getElementById('rememberMeInput').value = '{_loginModel.RememberMe.ToString().ToLower()}';
document.getElementById('returnUrlInput').value = {returnUrlValue};
document.getElementById('loginForm').submit();
");
}
@@ -1,79 +1 @@
@using Core.Entities
@using MudBlazor
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">Event Details</MudText>
</TitleContent>
<DialogContent>
@if (EventOccurrence == null)
{
<MudText>No event data available.</MudText>
}
else
{
<MudStack Spacing="3">
@if (EventDefinition != null)
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Event</MudText>
<MudText Typo="Typo.h6">@EventDefinition.Name</MudText>
</div>
}
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Occurrence</MudText>
<MudText Typo="Typo.body1">@EventOccurrence.Name</MudText>
</div>
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Date & Time</MudText>
<MudText Typo="Typo.body1">
@EventOccurrence.StartTime.ToString("f")
@if (EventOccurrence.EndTime.HasValue)
{
<text> - @EventOccurrence.EndTime.Value.ToString("t")</text>
}
</MudText>
</div>
@if (!string.IsNullOrEmpty(EventOccurrence.Location))
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Location</MudText>
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Size="Size.Small" Class="mr-1" />
@EventOccurrence.Location
</MudText>
</div>
}
@if (!string.IsNullOrEmpty(EventOccurrence.Time))
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Time</MudText>
<MudText Typo="Typo.body1">@EventOccurrence.Time</MudText>
</div>
}
@if (StudentFirstNames != null && StudentFirstNames.Any())
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Students</MudText>
<MudText Typo="Typo.body1">
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Small" Class="mr-1" />
@string.Join(", ", StudentFirstNames)
</MudText>
</div>
}
</MudStack>
}
</DialogContent>
</MudDialog>
@code {
[Parameter] public EventOccurrence? EventOccurrence { get; set; }
[Parameter] public EventDefinition? EventDefinition { get; set; }
[Parameter] public List<string>? StudentFirstNames { get; set; }
}
+14 -34
View File
@@ -5,6 +5,7 @@
@using Heron.MudCalendar
@using Microsoft.Extensions.Logging
@using WebApp.Authentication
@using Core.Utility
@inject IEventOccurrenceService EventOccurrenceService
@inject ILogger<Index> Logger
@inject IDialogService DialogService
@@ -99,7 +100,7 @@
// Load teams for all event definitions
var teamsByEventId = await EventOccurrenceService.GetTeamsByEventDefinitionIdsAsync(eventDefinitionIds);
var items = new List<CalendarEventItem>();
List<CalendarEventItem> items = [];
foreach (var occ in eventOccurrences)
{
try
@@ -110,9 +111,16 @@
}
// Get student first names for this event definition
var studentFirstNames = occ.EventDefinition != null
? StudentFirstNames(occ.EventDefinition, teamsByEventId)
: new List<string>();
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);
@@ -135,7 +143,7 @@
catch (Exception ex)
{
Logger.LogError(ex, "Error loading calendar events");
_calendarItems = new List<CalendarEventItem>();
_calendarItems = [];
}
finally
{
@@ -143,34 +151,6 @@
}
}
private static List<string> StudentFirstNames(EventDefinition ed, Dictionary<int, List<Team>> teamsByEventId)
{
var studentFirstNames = new List<string>();
if (ed?.Id == null || !teamsByEventId.TryGetValue(ed.Id, out var teams)) return studentFirstNames;
// Get all unique student first names from all teams for this event
// Include captain indicator (*) for team events
var allStudents = teams
.SelectMany(t => t.Students)
.DistinctBy(s => s.Id) // Ensure uniqueness by student ID
.Select(s =>
{
var isCaptain = teams.Any(t => t.Captain?.Id == s.Id);
var name = s.FirstName;
// Add star for captain in team events (EventFormat == Team)
if (isCaptain && ed.EventFormat == Core.Entities.EventFormat.Team)
{
name += "*";
}
return name;
})
.OrderBy(name => name)
.ToList();
studentFirstNames = allStudents;
return studentFirstNames;
}
private DateTime GetNextDateWithEvents()
{
@@ -220,7 +200,7 @@
private string GetEventTooltip(CalendarEventItem item)
{
var parts = new List<string>();
List<string> parts = [];
if (!string.IsNullOrEmpty(item.EventDefinition?.Name))
{
@@ -37,7 +37,11 @@ else
Events are shown in blue, career fields in green. Career fields are clusters of related careers.
Click on a node to see details. Use mouse to zoom and pan the graph.
</MudText>
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick" />
</div>
@if (_selectedNodeInfo != null)
{
<MudPaper Elevation="1" Class="pa-4 mb-4" Style="background-color: #f5f5f5;">
@@ -61,9 +65,6 @@ else
</MudPaper>
}
<div style="width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 4px;">
<Network Id="careerMappingNetwork" Data="@_networkData" Options="@GetNetworkOptions" OnClick="HandleNetworkClick"/>
</div>
</MudPaper>
}
@@ -117,7 +118,7 @@ else
{
if (!_fieldIdToCareers.ContainsKey(field.Id))
{
_fieldIdToCareers[field.Id] = new List<string>();
_fieldIdToCareers[field.Id] = [];
}
// Add unique career names for this field
foreach (var career in evt.RelatedCareers)
@@ -152,8 +153,8 @@ else
private NetworkData GenerateNetworkData(List<EventDefinition> events)
{
var nodes = new List<Node>();
var edges = new List<Edge>();
List<Node> nodes = [];
List<Edge> edges = [];
// Dictionary to track node IDs (to avoid duplicates)
var eventNodeIds = new Dictionary<int, string>();
@@ -283,7 +284,7 @@ else
Title = field.Name,
Description = field.Description,
IsCareerField = true,
Careers = careers?.ToList() ?? new List<string>()
Careers = careers?.ToList() ?? []
};
}
}
@@ -4,7 +4,7 @@
{
<span class="numberCircle">@EventDefinition.LevelOfEffort</span>
} *@
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled" Style="background-color: white; font-family: monospace; white-space: pre;">
<MudChip T="string" Color="Color.Default" Variant="@AppIcons.EventChipVariant()" Style="background-color: white; font-family: monospace; white-space: pre;">
@{
var loeIcon = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort);
var loeColor = AppIcons.IconColors.GetValueOrDefault(loeIcon, "inherit");
@@ -22,7 +22,7 @@
<MudButton StartIcon="@Icons.Material.Filled.Print"
OnClick="PrintPage"
Variant="Variant.Outlined">Print</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}")" Variant="Variant.Outlined">Edit</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit" Href="@($"/events/edit?id={eventdefinition.Id}&returnUrl={ReturnUrl ?? "/events"}")" Variant="Variant.Outlined">Edit</MudButton>
</div>
</ActionButtons>
</PageHeader>
+104 -21
View File
@@ -1,5 +1,6 @@
@page "/events"
@attribute [Authorize]
@implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@@ -30,7 +31,7 @@
<PropertyColumn Property="@(e => e.Name)" Title="Event Name" Sortable="true">
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
<MudLink Href="@($"/events/details?id={context.Item.Id}")"
<MudLink Href="@($"/events/details?id={context.Item.Id}&returnUrl=/events")"
Underline="Underline.Hover"
Color="Color.Primary">
@context.Item.Name
@@ -38,7 +39,7 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
TooltipText="Edit"
Href="@($"/events/edit?id={context.Item.Id}")" />
Href="@($"/events/edit?id={context.Item.Id}&returnUrl=/events")" />
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
TooltipText="Delete"
HoverColor="Color.Error"
@@ -74,16 +75,32 @@
@code {
MudDataGrid<EventDefinition> _dataGrid = null!;
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task<GridData<EventDefinition>> ServerReload(GridState<EventDefinition> state)
{
if (_isDisposed)
{
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
}
_isLoading = true;
try
{
var query = Context.Events.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var totalItems = await query.CountAsync();
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
var query = Context.Events
.AsNoTracking()
.OrderBy(e => e.Name).Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
return new GridData<EventDefinition>
{
@@ -91,31 +108,97 @@
Items = pagedData
};
}
catch (TaskCanceledException)
{
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
}
catch (JSDisconnectedException)
{
return new GridData<EventDefinition> { TotalItems = 0, Items = [] };
}
finally
{
_isLoading = false;
if (!_isDisposed)
{
_isLoading = false;
}
}
}
private async Task DeleteEventDefinition(EventDefinition evt)
{
//_isRowBlocked = true;
if (_isDisposed) return;
var result = await DialogService
.ShowMessageBox("Delete Event",
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
yesText:"Yes",
noText:"Cancel");
if (result == true)
try
{
Context.Events.Remove(evt!);
await Context.SaveChangesAsync();
Snackbar.Add($"Delete event: Delete of Event {evt.Name}", Severity.Info);
}
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
//_isRowBlocked = false;
StateHasChanged();
await _dataGrid.ReloadServerData();
var result = await DialogService
.ShowMessageBox("Delete Event",
(MarkupString)$"Are you sure want to delete <b>{evt.Name}</b>? This cannot be undone.",
yesText:"Yes",
noText:"Cancel");
if (_isDisposed) return;
if (result == true)
{
// Load the event fresh from database with tracking to avoid tracking conflicts
var eventToDelete = await Context.Events
.FirstOrDefaultAsync(e => e.Id == evt.Id, cancellationToken);
if (_isDisposed) return;
if (eventToDelete == null)
{
if (!_isDisposed)
{
Snackbar.Add("Event not found or already deleted", Severity.Warning);
}
return;
}
Context.Events.Remove(eventToDelete);
await Context.SaveChangesAsync(cancellationToken);
if (!_isDisposed)
{
Snackbar.Add($"Event {eventToDelete.Name} deleted", Severity.Info);
}
}
if (!_isDisposed)
{
StateHasChanged();
await _dataGrid.ReloadServerData();
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error deleting event: {ex.Message}", Severity.Error);
}
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,6 +4,7 @@
@using Core.Calculation
@using Microsoft.EntityFrameworkCore
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject IConfiguration Configuration
@inject AppDbContext Context
@inject ClipboardService ClipboardService
@@ -45,7 +46,13 @@
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="Reset" FullWidth="true">Reset</MudButton>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Class="ma-3" OnClick="Solve" Color="Color.Primary" Disabled="@_isSolving">Solve</MudButton>
<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>
@@ -62,11 +69,14 @@
<MudGrid>
<MudItem xs="12" lg="6">
<ScheduledTeamsList TimeSlotName="@context.Name"
TimeSlotIndex="@GetTimeSlotIndex(context.Name)"
Teams="@context.Teams"
ScheduledTeams="@_scheduledTeams"
AbsentStudents="@_absentStudents"
StudentHasOverlaps="@context.StudentHasOverlaps"
OnToggleTeam="@ToggleRequiredTeam" />
ExcludedStudents="@_excludedStudents"
OnToggleTeam="@ToggleRequiredTeam"
OnToggleStudentExclusion="EventCallback.Factory.Create<(int teamId, int timeSlotIndex, int studentId)>(this, OnToggleStudentExclusion)" />
</MudItem>
<MudItem xs="12" lg="6">
<UnscheduledStudentsList UnscheduledStudents="@context.UnscheduledStudents"
@@ -90,11 +100,13 @@
Label="Search for absent students"
ShowFullName="true"/>
<MudDivider Class="my-4"/>
<TeamToggleSelector Teams="@_teams"
SelectedTeams="_scheduledTeams"
SelectedTeamsChanged="OnScheduledTeamsChanged"
Title="Scheduled Teams"
ShowEventAttributes="true" />
<TeamMeetingToggleSelector Teams="@_teams"
SelectedTeams="_scheduledTeams"
SelectedTeamsChanged="OnScheduledTeamsChanged"
ExtendedTeams="_extendedTeams"
ExtendedTeamsChanged="OnExtendedTeamsChanged"
Title="Scheduled Teams"
ShowEventAttributes="false" />
</MudStack>
</MudItem>
@@ -111,76 +123,99 @@
private IEnumerable<Team> _scheduledTeams = [];
private IEnumerable<Student> _absentStudents = [];
private IEnumerable<Team> _possibleAdditions = [];
private IEnumerable<Team> _extendedTeams = [];
// Key: (teamId, timeSlotIndex, studentId) - Value: true if excluded
private Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> _excludedStudents = new();
// Track last saved state for dirty/clean comparison
private MeetingScheduleState? _lastSavedState;
private async Task OnScheduledTeamsChanged(IEnumerable<Team> teams)
private void OnScheduledTeamsChanged(IEnumerable<Team> teams)
{
_scheduledTeams = teams;
await SaveScheduledTeams();
StateHasChanged();
}
private async Task OnAbsentStudentsChanged(IEnumerable<Student> students)
private void OnAbsentStudentsChanged(IEnumerable<Student> students)
{
_absentStudents = students;
await SaveAbsentStudents();
StateHasChanged();
}
private async Task OnTimeSlotCountChanged(int timeSlots)
{
_parameters.TimeSlots = timeSlots;
await SaveTimeSlotCount();
StateHasChanged();
await Task.CompletedTask;
}
private async void AddRegionals()
private void OnExtendedTeamsChanged(IEnumerable<Team> teams)
{
_extendedTeams = teams;
StateHasChanged();
}
private void AddRegionals()
{
_scheduledTeams
= _teams.Where(e => e.Event.RegionalEvent).Concat(_scheduledTeams).Distinct();
await SaveScheduledTeams();
StateHasChanged();
}
private async void AddHighLevelOfEffort()
private void AddHighLevelOfEffort()
{
_scheduledTeams
= _teams.Where(e => e.Event.LevelOfEffort >= 3).Concat(_scheduledTeams).Distinct();
await SaveScheduledTeams();
StateHasChanged();
}
private async void RemoveIndividual()
private void RemoveIndividual()
{
_scheduledTeams
= _scheduledTeams.Where(t => t.Event.EventFormat != EventFormat.Individual);
await SaveScheduledTeams();
StateHasChanged();
}
private async void RemoveLowLevelOfEffort()
private void RemoveLowLevelOfEffort()
{
_scheduledTeams
= _scheduledTeams.Where(t => t.Event.LevelOfEffort > 1);
await SaveScheduledTeams();
StateHasChanged();
}
private async void Invert()
private void Invert()
{
var rt = _scheduledTeams.ToArray();
_scheduledTeams
= _teams.Where(t => !rt.Contains(t));
await SaveScheduledTeams();
StateHasChanged();
}
private async void Reset()
private void Reset()
{
_scheduledTeams = [];
await SaveScheduledTeams();
_extendedTeams = [];
_excludedStudents.Clear();
StateHasChanged();
}
private async void ToggleRequiredTeam(Team unassignedTeam)
private void ToggleRequiredTeam(Team unassignedTeam)
{
if (_scheduledTeams.Contains(unassignedTeam))
_scheduledTeams = _scheduledTeams.Where(t => t != unassignedTeam);
// Find the matching team from _teams to ensure reference equality with MudToggleGroup
var matchingTeam = _teams.FirstOrDefault(t => t.Id == unassignedTeam.Id);
if (matchingTeam == null) return;
var scheduledTeamIds = _scheduledTeams.Select(t => t.Id).ToHashSet();
IEnumerable<Team> newScheduledTeams;
if (scheduledTeamIds.Contains(matchingTeam.Id))
newScheduledTeams = _scheduledTeams.Where(t => t.Id != matchingTeam.Id);
else
{
_scheduledTeams = _scheduledTeams.Concat(new[] { unassignedTeam });
newScheduledTeams = _scheduledTeams.Concat([matchingTeam]);
}
await SaveScheduledTeams();
// Update state and notify component to re-render
OnScheduledTeamsChanged(newScheduledTeams);
}
protected override async Task OnInitializedAsync()
@@ -216,6 +251,7 @@
_teams
= await Context.Teams
.AsNoTracking()
.Include(e => e.Event)
.Include(e => e.Students)
.OrderBy(e => e.Event.Name)
@@ -224,16 +260,34 @@
_students =
await Context.Students
.AsNoTracking()
.Include(e => e.Teams)
.ThenInclude(e => e.Captain)
.ThenInclude(t => t.Event)
.Include(e => e.Teams)
.ThenInclude(t => t.Captain)
.Include(e => e.EventRankings)
.ThenInclude(e => e.EventDefinition)
.ThenInclude(e => e.EventDefinition)
.OrderBy(e => e.FirstName).ToArrayAsync();
// Load saved selections from localStorage
await LoadScheduledTeams();
await LoadAbsentStudents();
await LoadTimeSlotCount();
await LoadExtendedTeams();
await LoadExcludedStudents();
// Initialize last saved state from loaded values
_lastSavedState = await MeetingScheduleState.FromLocalStorage(LocalStorage, _teams, _students);
if (_lastSavedState == null)
{
// If no saved state exists, create initial state from current values
_lastSavedState = MeetingScheduleState.FromCurrent(
_scheduledTeams,
_absentStudents,
_parameters.TimeSlots,
_extendedTeams,
_excludedStudents);
}
}
private async Task SaveScheduledTeams()
@@ -280,6 +334,119 @@
}
}
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)
{
if (_solution?.TimeSlots == null)
return 0; // Default to first slot if solution not available
for (int i = 0; i < _solution.TimeSlots.Length; i++)
{
if (_solution.TimeSlots[i].Name == timeSlotName)
return i;
}
return 0; // Default to first slot if not found (shouldn't happen in normal flow)
}
private void OnToggleStudentExclusion((int teamId, int timeSlotIndex, int studentId) key)
{
if (_excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded)
{
_excludedStudents.Remove(key);
}
else
{
_excludedStudents[key] = true;
}
StateHasChanged();
}
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
{
var key = (teamId, timeSlotIndex, studentId);
return _excludedStudents.TryGetValue(key, out var isExcluded) && isExcluded;
}
/// <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();
}
private record ExcludedStudent(int TeamId, int TimeSlotIndex, int StudentId);
private bool IsDirty()
{
if (_lastSavedState == null)
return true;
var currentState = MeetingScheduleState.FromCurrent(
_scheduledTeams,
_absentStudents,
_parameters.TimeSlots,
_extendedTeams,
_excludedStudents);
return !currentState.Equals(_lastSavedState);
}
private async Task<TableData<TeamScheduleTimeSlot>> SolveSchedule(TableState arg1, CancellationToken arg2)
{
_isSolving = true;
@@ -305,24 +472,98 @@
// Update parameters with absent student names
_parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();
// Update parameters with extended team event names
_parameters.ExtendedTeams = _extendedTeams
.Where(t => t.Event != null)
.Select(t => t.Event!.Name)
.ToArray();
var teamScheduler = new TeamScheduler(_scheduledTeams, _parameters.TimeSlots, availableStudents);
// 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 teamScheduler = new TeamScheduler(teamsForScheduling, _parameters.TimeSlots, availableStudents);
_solution = teamScheduler.Solve();
// Restore full teams (with all students) in the solution so excluded students still appear (dimmed)
// Create a mapping from PartialTeam to original Team
var teamMapping = _scheduledTeams.ToDictionary(t => t.Id, t => t);
for (int slotIndex = 0; slotIndex < _solution.TimeSlots.Length; slotIndex++)
{
var slot = _solution.TimeSlots[slotIndex];
if (slot.Teams == null)
continue;
var restoredTeams = slot.Teams.Select(team =>
{
// If this is a PartialTeam or we have the original, restore it
if (teamMapping.TryGetValue(team.Id, out var originalTeam))
{
return originalTeam;
}
return team;
}).ToArray();
slot.Teams = restoredTeams;
// Recalculate overlaps and unscheduled students with full teams
// Filter out excluded students when calculating overlaps and unscheduled students
var teamsForOverlapCalculation = GetTeamsWithoutExcludedStudents(slot.Teams, slotIndex);
slot.StudentOverlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(teamsForOverlapCalculation);
// Use teams without excluded students so students excluded from all teams appear as unscheduled
slot.UnscheduledStudents = TeamSchedulerSolution.GetStudentsNotInTimSlot(teamsForOverlapCalculation, availableStudents);
}
// Post-process: extend teams to next consecutive time slot
if (_extendedTeams.Any())
{
ExtendTeamsInSolution(_solution, _extendedTeams, availableStudents);
}
// Try recommendation strategies in priority order
var scheduler = new UnassignedStudentScheduler(_teams, _solution.TimeSlots);
var strategies = new[]
{
UnassignedScheduleStrategy[] strategies =
[
UnassignedScheduleStrategy.LevelOfEffort,
UnassignedScheduleStrategy.BiggestGroup,
UnassignedScheduleStrategy.AnyNotMeetingAlready,
UnassignedScheduleStrategy.IndividualEvents
};
];
_possibleAdditions = strategies
.Select(strategy => scheduler.ScheduleStrategy(strategy))
.FirstOrDefault(result => result.Any()) ?? [];
// Save state to localStorage after solving completes successfully
var currentState = MeetingScheduleState.FromCurrent(
_scheduledTeams,
_absentStudents,
_parameters.TimeSlots,
_extendedTeams,
_excludedStudents);
await currentState.SaveToLocalStorage(LocalStorage);
_lastSavedState = currentState;
await InvokeAsync(StateHasChanged); // let the UI know that the solution has been found
_isSolving = false;
@@ -334,6 +575,78 @@
_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();
@@ -356,6 +669,7 @@
private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot)
{
var timeSlotIndex = GetTimeSlotIndex(timeslot.Name);
foreach (var scheduledTeam in timeslot.Teams.OrderBy(e => e.ToString()))
{
var teamName = scheduledTeam.ToString();
@@ -366,30 +680,73 @@
}
else
{
var studentsList = FormatStudentList(scheduledTeam, timeslot);
var studentsList = FormatStudentList(scheduledTeam, timeslot, timeSlotIndex);
sb.Append($"{teamName} - {studentsList}");
}
sb.Append(Environment.NewLine);
}
}
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot)
private string FormatStudentList(Team team, TeamScheduleTimeSlot timeslot, int timeSlotIndex)
{
return string.Join(", ",
team.Students
.OrderBy(e => e == team.Captain)
.ThenBy(e => e.FirstName)
.Select(e => FormatStudentName(e, timeslot)));
// 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)
{
var name = student.FirstName;
if (timeslot.StudentHasOverlaps(student))
name += "*";
if (_absentStudents.Contains(student))
name += " (absent)";
return name;
// 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)
@@ -402,9 +759,12 @@
foreach (var student in timeslot.UnscheduledStudents)
{
var studentName = student.FirstName;
if (_absentStudents.Contains(student))
studentName += " (absent)";
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()));
@@ -414,4 +774,113 @@
}
}
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);
}
}
}
@@ -1,37 +1,93 @@
@using Core.Calculation
@using WebApp.Models
@using Core.Utility
<MudStack>
<MudText Typo="Typo.h6">@TimeSlotName</MudText>
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString()))
{
var removed = !ScheduledTeams.Contains(team);
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
var removed = !scheduledTeamIds.Contains(team.Id);
<MudLink Typo="Typo.body1"
Class="d-flex align-center"
Color="Color.Default"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")">
</MudIcon>
@team -
@foreach (var student in team.Students)
{
var overlap = StudentHasOverlaps(student);
var isAbsent = AbsentStudents.Contains(student);
var color = overlap ? Color.Warning : Color.Default;
var suffix = GetStudentSuffix(overlap, isAbsent);
if (student != team.Students.First())
{
<MudText>, </MudText>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="d-flex align-center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Clear"
Size="Size.Small"
Class="@(removed ? "" : "d-none")"
OnClick="@(() => OnToggleTeam.InvokeAsync(team))"
Style="cursor: pointer;">
</MudIcon>
@{
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.None
});
}
<MudText Typo="Typo.body2" Color="@color">
&nbsp;@student.FirstName@suffix
</MudText>
}
</MudLink>
<MudTooltip Text="@teamMembers">
<div @onclick="@(() => OnToggleTeam.InvokeAsync(team))" style="cursor: pointer; display: inline-block;">
<MudChip T="string"
Size="Size.Small"
Color="Color.Default"
Variant="@AppIcons.TeamChipVariant()">
@team
</MudChip>
</div>
</MudTooltip>
</MudStack>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
@{
var nonExcludedStudentCount = GetNonExcludedStudentCount(team);
}
@foreach (var student in team.Students)
{
var overlap = StudentHasOverlaps(student);
var chipColor = overlap ? Color.Warning : Color.Default;
var isExcluded = IsStudentExcluded(team.Id, TimeSlotIndex, student.Id);
var formattedName = TeamStudentNameFormatter.FormatStudentName(
student,
team,
new TeamStudentNameFormatter.FormatOptions
{
MarkOverlaps = true,
HasOverlaps = StudentHasOverlaps,
MarkAbsent = true,
AbsentStudents = AbsentStudents.ToList()
});
@if (nonExcludedStudentCount > 1)
{
<InteractiveChip Size="Size.Small"
Color="@chipColor"
Variant="@AppIcons.StudentChipVariant()"
Style="@(isExcluded ? "opacity: 0.5;" : "")">
<ChildContent>
<span>@formattedName</span>
</ChildContent>
<ControlContent>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Size="Size.Small"
Color="Color.Error"
Variant="Variant.Text"
OnClick="@(() => OnToggleStudentExclusion.InvokeAsync((team.Id, TimeSlotIndex, student.Id)))"
Style="padding: 0; min-width: 16px; width: 16px; height: 16px; margin-left: 4px;" />
</ControlContent>
</InteractiveChip>
}
else
{
<MudChip T="string"
Size="Size.Small"
Color="@chipColor"
Variant="@AppIcons.StudentChipVariant()"
Style="@(isExcluded ? "opacity: 0.5;" : "")">
<span>@formattedName</span>
</MudChip>
}
}
</MudStack>
</MudStack>
}
</MudStack>
@@ -39,6 +95,9 @@
[Parameter]
public string TimeSlotName { get; set; } = string.Empty;
[Parameter]
public int TimeSlotIndex { get; set; }
[Parameter]
public IEnumerable<Team> Teams { get; set; } = [];
@@ -51,13 +110,23 @@
[Parameter]
public Func<Student, bool> StudentHasOverlaps { get; set; } = null!;
[Parameter]
public Dictionary<(int teamId, int timeSlotIndex, int studentId), bool> ExcludedStudents { get; set; } = new();
[Parameter]
public EventCallback<Team> OnToggleTeam { get; set; }
private string GetStudentSuffix(bool overlap, bool isAbsent)
[Parameter]
public EventCallback<(int teamId, int timeSlotIndex, int studentId)> OnToggleStudentExclusion { get; set; }
private bool IsStudentExcluded(int teamId, int timeSlotIndex, int studentId)
{
var suffix = overlap ? "*" : "";
suffix += isAbsent ? " (absent)" : "";
return suffix;
var key = (teamId, timeSlotIndex, studentId);
return ExcludedStudents.ContainsKey(key) && ExcludedStudents[key];
}
private int GetNonExcludedStudentCount(Team team)
{
return team.Students.Count(s => !IsStudentExcluded(team.Id, TimeSlotIndex, s.Id));
}
}
@@ -1,4 +1,6 @@
@using Core.Calculation
@using Core.Utility
@using WebApp.Models
@if (UnscheduledStudents.Any())
{
@@ -6,31 +8,52 @@
<MudStack>
@foreach (var student in UnscheduledStudents)
{
var isAbsent = AbsentStudents.Contains(student);
<MudItem>
<MudText Typo="Typo.body1" HtmlTag="i">
@student.FirstName@(isAbsent ? " (absent)" : "")&nbsp;
</MudText>
@foreach (var unassignedTeam in UnassignedTeams(student))
{
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
var isScheduled = ScheduledTeams.Contains(unassignedTeam);
var color = isPossibleAddition ? Color.Success : Color.Default;
if (unassignedTeam != UnassignedTeams(student).First())
{
<span>, </span>
}
<MudLink Typo="Typo.body2"
Color="@color"
OnClick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))">
<MudIcon Icon="@Icons.Material.Filled.Check"
Size="Size.Small"
Class="@(isScheduled ? "" : "d-none")">
</MudIcon>
@unassignedTeam
</MudLink>
@{
var formattedName = StudentNameFormatter.FormatStudentName(
student,
new StudentNameFormatter.FormatOptions
{
IsAbsent = AbsentStudents.Contains(student)
});
}
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Variant="@AppIcons.StudentChipVariant()" Class="font-style-italic">
@formattedName
</MudChip>
</MudStack>
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
@foreach (var unassignedTeam in UnassignedTeams(student))
{
var isPossibleAddition = PossibleAdditions.Contains(unassignedTeam, new TeamIdComparer());
var scheduledTeamIds = ScheduledTeams.Select(t => t.Id).ToHashSet();
var isScheduled = scheduledTeamIds.Contains(unassignedTeam.Id);
var chipColor = isPossibleAddition ? Color.Success : Color.Default;
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
unassignedTeam,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.None
});
<MudTooltip Text="@teamMembers">
<div @onclick="@(() => OnToggleTeam.InvokeAsync(unassignedTeam))" style="cursor: pointer; display: inline-block;">
<MudChip T="string"
Size="Size.Small"
Color="@chipColor"
Variant="@AppIcons.TeamChipVariant()">
@if (isScheduled)
{
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Style="margin-right: 4px;" />
}
@unassignedTeam
</MudChip>
</div>
</MudTooltip>
}
</MudStack>
</MudStack>
</MudItem>
}
</MudStack>
@@ -140,11 +140,13 @@ else
.OrderBy(e => e.Event.Name)
.ToArray();
var events = await Context.Events.ToArrayAsync();
var events = await Context.Events
.AsNoTracking()
.ToArrayAsync();
var remainingEvents =
events
.Where(e => _eventStudentRankings.All(est => est.Event.Id != e.Id))
.Select(e => new EventStudentRankings { Event = e, StudentRanking = Array.Empty<Tuple<Student, int>>() })
.Select(e => new EventStudentRankings { Event = e, StudentRanking = [] })
.OrderBy(e => e.Event.Name)
.ToArray();
+104 -21
View File
@@ -1,5 +1,6 @@
@page "/students"
@attribute [Authorize]
@implements IAsyncDisposable
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@@ -30,7 +31,7 @@
<PropertyColumn Property="@(e => e.LastName)" Title="Name" Sortable="true">
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
<div class="d-flex align-center flex-wrap" style="gap: 0.25rem;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
<MudLink Href="@($"/students/details?id={context.Item.Id}&returnUrl=/students")"
Underline="Underline.Hover"
Color="Color.Primary">
@@ -40,7 +41,7 @@
{
<MudChip T="string" Size="Size.Small" Icon="@(AppIcons.OfficerRoleIcon(context.Item.OfficerRole.Value))">@context.Item.OfficerRole</MudChip>
}
</div>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
TooltipText="Edit"
@@ -68,18 +69,34 @@
@code {
MudDataGrid<Student> _dataGrid = null!;
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task<GridData<Student>> ServerReload(GridState<Student> state)
{
if (_isDisposed)
{
return new GridData<Student> { TotalItems = 0, Items = [] };
}
_isLoading = true;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var query =
Context.Students.OrderBy(e => e.LastName)
Context.Students
.AsNoTracking()
.OrderBy(e => e.LastName)
.Where(state.FilterDefinitions).OrderBy(state.SortDefinitions);
var totalItems = await query.CountAsync();
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync();
var totalItems = await query.CountAsync(cancellationToken);
var pagedData = await query.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArrayAsync(cancellationToken);
return new GridData<Student>
{
@@ -87,31 +104,97 @@
Items = pagedData
};
}
catch (TaskCanceledException)
{
return new GridData<Student> { TotalItems = 0, Items = [] };
}
catch (JSDisconnectedException)
{
return new GridData<Student> { TotalItems = 0, Items = [] };
}
finally
{
_isLoading = false;
if (!_isDisposed)
{
_isLoading = false;
}
}
}
private async Task DeleteStudent(Student student)
{
//_isRowBlocked = true;
if (_isDisposed) return;
var result = await DialogService
.ShowMessageBox("Delete student",
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
yesText:"Yes",
noText:"Cancel");
if (result == true)
try
{
Context.Students.Remove(student!);
await Context.SaveChangesAsync();
Snackbar.Add($"Delete event: Delete of Student {student.Name}", Severity.Info);
}
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
//_isRowBlocked = false;
StateHasChanged();
await _dataGrid.ReloadServerData();
var result = await DialogService
.ShowMessageBox("Delete student",
(MarkupString)$"Are you sure want to delete <b>{student.Name}</b>? This cannot be undone.",
yesText:"Yes",
noText:"Cancel");
if (_isDisposed) return;
if (result == true)
{
// Load the student fresh from database with tracking to avoid tracking conflicts
var studentToDelete = await Context.Students
.FirstOrDefaultAsync(s => s.Id == student.Id, cancellationToken);
if (_isDisposed) return;
if (studentToDelete == null)
{
if (!_isDisposed)
{
Snackbar.Add("Student not found or already deleted", Severity.Warning);
}
return;
}
Context.Students.Remove(studentToDelete);
await Context.SaveChangesAsync(cancellationToken);
if (!_isDisposed)
{
Snackbar.Add($"Student {studentToDelete.Name} deleted", Severity.Info);
}
}
if (!_isDisposed)
{
StateHasChanged();
await _dataGrid.ReloadServerData();
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error deleting student: {ex.Message}", Severity.Error);
}
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -4,6 +4,7 @@
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Validation
@using Core.Utility
@inject AppDbContext Context
@inject WebApp.LocalStorageService LocalStorage
@inject ValidationService ValidationService
@@ -79,16 +80,22 @@
? context.Item.Teams.Where(t => t?.Event is { RegionalEvent: true }).OrderBy(t => t.Event.Name)
: context.Item.Teams.Where(t => t?.Event != null).OrderBy(t => t.Event.Name);
}
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var team in teamsToDisplay)
{
var isCaptain = team.Captain != null && team.Captain.Equals(context.Item.Student);
var teamMembers = string.Join(", ", team.Students.Select(s => s.FirstName));
var teamMembers = TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.None
});
<MudTooltip Text="@teamMembers">
<MudChip Size="Size.Small"
Color="Color.Default"
Href="@($"/teams/edit?id={team.Id}")"
Variant="@AppIcons.TeamChipVariant()"
Href="@($"/teams/edit?id={team.Id}&returnUrl=/students/teams")"
Style="cursor: pointer;">
@team
@if (isCaptain && team.Event.EventFormat != EventFormat.Individual)
@@ -98,7 +105,7 @@
</MudChip>
</MudTooltip>
}
</div>
</MudStack>
</CellTemplate>
</TemplateColumn>
</Columns>
@@ -172,14 +179,38 @@
_isLoading = true;
try
{
// Load all students with their teams
// Load teams with their students separately to avoid circular reference
var teamsWithStudents = await Context.Teams
.AsNoTracking()
.Include(t => t.Students)
.Include(t => t.Event)
.Include(t => t.Captain)
.ToListAsync();
// Create a dictionary for quick lookup of team students
var teamStudentsDict = teamsWithStudents.ToDictionary(t => t.Id, t => t.Students.ToList());
// Load all students with their teams (without loading team students to avoid circular reference)
var students = await Context.Students
.AsNoTracking()
.Include(s => s.Teams)
.ThenInclude(t => t.Event)
.Include(s => s.Teams)
.ThenInclude(t => t.Captain)
.ToListAsync();
// Populate Students for each team from the separately loaded teams
foreach (var student in students)
{
foreach (var team in student.Teams)
{
if (teamStudentsDict.TryGetValue(team.Id, out var teamStudents))
{
team.Students = teamStudents;
}
}
}
// Filter to only students with teams
var studentTeams = students
.Where(s => s.Teams.Any(t => t?.Event != null && (!_showRegionalOnly || t.Event.RegionalEvent)))
@@ -270,7 +301,7 @@
Student = student,
Events = student.Teams?.Where(t => t?.Event != null)
.Select(t => t.Event)
.ToList() ?? new List<EventDefinition>()
.ToList() ?? []
};
return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration);
@@ -153,82 +153,87 @@
.Concat(context.Events)
.Distinct();
}
@foreach (var e in
allStudentEvents
.OrderBy(e =>
context.Student.EventRankings
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
{
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
var isAssigned = context.Events.Contains(e);
var color = AppIcons.RankedEventColor(eventRank ?? 0);
var style = string.Empty;
if (isAssigned)
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
@foreach (var e in
allStudentEvents
.OrderBy(e =>
context.Student.EventRankings
.Find(ser => ser.EventDefinition == e)?.Rank ?? 10))
{
style += "border-color:black; border-width:thin;";
if (eventRank.HasValue)
{
style += $"background:{color};";
if (eventRank == 1)
style += $"color:black";
}
else
style += $"background:{Colors.Gray.Lighten3};";
}
else
{
if (eventRank.HasValue)
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
}
var eventRank = context.Student.EventRankings.Find(er => er.EventDefinition == e)?.Rank;
var isAssigned = context.Events.Contains(e);
<MudPaper Class="d-inline-flex align-center pa-2 mx-3 my-1 border-solid" Style="@(style)">
@e.ShortName&nbsp;
@AppIcons.EventAttributes(e)
@{
var isIncluded = _assignmentRequirements
.Find(ar =>
ar.EventDefinition == e
&& ar.Student == context.Student
&& ar.Requirement == Requirement.Include) == null;
var isExcluded = _assignmentRequirements
.Find(ar =>
ar.EventDefinition == e
&& ar.Student == context.Student
&& ar.Requirement == Requirement.Exclude) == null;
}
@if (isIncluded)
var color = AppIcons.RankedEventColor(eventRank ?? 0);
var style = "border-style: solid;";
if (isAssigned)
{
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
</MudTooltip>
style += "border-color:black; border-width:thin;";
if (eventRank.HasValue)
{
style += $"background:{color};";
if (eventRank == 1)
style += $"color:black";
}
else
style += $"background:{Colors.Gray.Lighten3};";
}
else
{
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
</MudTooltip>
if (eventRank.HasValue)
style += $"border-color:{color}; border-width:medium; color:{Colors.Gray.Lighten1};";
}
@if (isExcluded)
{
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
</MudTooltip>
}
</MudPaper>
}
var isIncluded = _assignmentRequirements
.Find(ar =>
ar.EventDefinition == e
&& ar.Student == context.Student
&& ar.Requirement == Requirement.Include) == null;
var isExcluded = _assignmentRequirements
.Find(ar =>
ar.EventDefinition == e
&& ar.Student == context.Student
&& ar.Requirement == Requirement.Exclude) == null;
<InteractiveChip WrapperClass="my-1" Style="@style" Variant="@AppIcons.EventChipVariant()">
<ChildContent>
@e.ShortName&nbsp;
@AppIcons.EventAttributes(e)
</ChildContent>
<ControlContent>
@if (isIncluded)
{
<MudTooltip Text="@($"Add requirement for {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Outlined.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Default"
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="@($"Remove requirement for {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Filled.ThumbUpAlt" Class="ml-3" Size="Size.Small" Color="Color.Dark"
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
</MudTooltip>
}
@if (isExcluded)
{
<MudTooltip Text="@($"Add restriction against {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Outlined.ThumbDownAlt" Size="Size.Small" Color="Color.Default"
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="@($"Remove restriction against {context.Student.FirstName} in {e.ShortName}")">
<MudIconButton Icon="@Icons.Material.Filled.ThumbDownAlt" Size="Size.Small" Color="Color.Dark"
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
</MudTooltip>
}
</ControlContent>
</InteractiveChip>
}
</MudStack>
<MudDivider Style="border-width:3px" />
</td></MudTr>
</ChildRowContent>
@@ -0,0 +1,108 @@
@using WebApp.Models
@using Core.Utility
@if (Title != null)
{
<MudText Typo="Typo.h4">@Title</MudText>
}
<MudToggleGroup T="Team"
SelectionMode="SelectionMode.MultiSelection"
Values="@SelectedTeams"
ValuesChanged="@OnSelectedTeamsChanged"
Vertical="true"
CheckMark>
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
{
<MudToggleItem Value="@team" Style="font-size: .75rem;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%;">
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
Ordering = TeamStudentNameFormatter.OrderingStyle.None
})">
<span class="ellipsis" style="flex: 1; min-width: 0;">@team.ToString()</span>
</MudTooltip>
@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>
}
@if (ShowEventAttributes)
{
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
}
</div>
</MudToggleItem>
}
</MudToggleGroup>
@code {
[Parameter]
public IEnumerable<Team> Teams { get; set; } = [];
[Parameter]
public IEnumerable<Team> SelectedTeams { get; set; } = [];
[Parameter]
public EventCallback<IEnumerable<Team>> SelectedTeamsChanged { get; set; }
[Parameter]
public IEnumerable<Team> ExtendedTeams { get; set; } = [];
[Parameter]
public EventCallback<IEnumerable<Team>> ExtendedTeamsChanged { get; set; }
[Parameter]
public string? Title { get; set; }
[Parameter]
public bool ShowEventAttributes { get; set; } = true;
private bool IsSelected(Team team)
{
var selectedTeamIds = SelectedTeams.Select(t => t.Id).ToHashSet();
return selectedTeamIds.Contains(team.Id);
}
private bool IsExtended(Team team)
{
var extendedTeamIds = ExtendedTeams.Select(t => t.Id).ToHashSet();
return extendedTeamIds.Contains(team.Id);
}
private async Task OnSelectedTeamsChanged(IEnumerable<Team> value)
{
SelectedTeams = value;
await SelectedTeamsChanged.InvokeAsync(value);
}
private async Task ToggleExtended(Team team)
{
var extendedTeamIds = ExtendedTeams.Select(t => t.Id).ToHashSet();
IEnumerable<Team> newExtendedTeams;
if (extendedTeamIds.Contains(team.Id))
{
// Remove from extended teams
newExtendedTeams = ExtendedTeams.Where(t => t.Id != team.Id);
}
else
{
// Add to extended teams
newExtendedTeams = ExtendedTeams.Concat([team]);
}
ExtendedTeams = newExtendedTeams;
await ExtendedTeamsChanged.InvokeAsync(newExtendedTeams);
}
}
@@ -1,5 +1,5 @@
@using WebApp.Models
<div class="d-flex flex-wrap" style="gap: 0.25rem;">
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var student in
Team.Students
.OrderBy(e =>
@@ -17,7 +17,7 @@
<MudTooltip Text="@rankLabel">
<MudChip T="string"
Size="Size.Medium"
Variant="Variant.Outlined"
Variant="@AppIcons.StudentChipVariant()"
Class="mx-1 my-1">
@if (eventRank.HasValue)
{
@@ -31,7 +31,7 @@
</MudChip>
</MudTooltip>
}
</div>
</MudStack>
@code {
[Parameter]
@@ -1,4 +1,5 @@
@using WebApp.Models
@using Core.Utility
@if (Title != null)
{
@@ -14,9 +15,15 @@
@foreach (var team in Teams.OrderByEventFormatFirst().ThenBy(e => e.Event.Name))
{
<MudToggleItem Value="@team" Style="font-size: .75rem;">
<MudTooltip Text="@team.StudentsFirstNames">
<div class="d-flex align-center justify-space-between flex-wrap">
<MudText Class="ellipsis">@team.ToString()</MudText>
<MudTooltip Text="@TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain,
Ordering = TeamStudentNameFormatter.OrderingStyle.None
})">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 4px; width: 100%;">
<span class="ellipsis" style="flex: 1; min-width: 0;">@team.ToString()</span>
@if (ShowEventAttributes)
{
<EventAttributes EventDefinition="@team.Event"></EventAttributes>
+3 -1
View File
@@ -71,7 +71,9 @@
.Include(e => e.Event)
.Include(e => e.Students)
.FirstOrDefaultAsync(m => m.Id == Id);
_students = await Context.Students.ToListAsync();
_students = await Context.Students
.AsNoTracking()
.ToListAsync();
_selectedStudents = Team?.Students.ToList();
if (Team is null)
+7 -1
View File
@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject IConfiguration Configuration
@inject AppDbContext Context
@@ -35,7 +36,12 @@ else
@if (team.Event.EventFormat == EventFormat.Team)
{
<span style="font-weight: normal; font-size: 0.9em;">
(Team: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName)))
(Team: @TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
}))
</span>
}
</MudText>
@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject IConfiguration Configuration
@inject AppDbContext Context
@@ -57,7 +58,12 @@ else
{
<MudItem xs="12">
<MudText Class="d-flex py-1" Typo="Typo.h6">
Team Members: @string.Join(", ", team.Students.OrderByDescending(e => e.Grade + e.TsaYear).Select(e => e.FirstName))
Team Members: @TeamStudentNameFormatter.FormatStudentList(
team,
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.GradeDescending
})
</MudText>
</MudItem>
}
+131 -29
View File
@@ -3,6 +3,7 @@
@using WebApp.Models
@page "/teams"
@attribute [Authorize]
@implements IAsyncDisposable
@inject AppDbContext Context
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@@ -36,7 +37,7 @@
<TemplateColumn Title="Event" Sortable="true" SortBy="@(t => t.Event.Name)">
<CellTemplate>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
<MudLink Href="@($"/teams/details?id={context.Item.Id}")"
<MudLink Href="@($"/teams/details?id={context.Item.Id}&returnUrl=/teams")"
Underline="Underline.Hover"
Color="Color.Primary">
@context.Item.ToString()
@@ -44,7 +45,7 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<IconButtonWithTooltip Icon="@Icons.Material.Filled.Edit"
TooltipText="Edit"
Href="@($"/teams/edit?id={context.Item.Id}")" />
Href="@($"/teams/edit?id={context.Item.Id}&returnUrl=/teams")" />
<IconButtonWithTooltip Icon="@Icons.Material.Outlined.Delete"
TooltipText="Delete"
HoverColor="Color.Error"
@@ -74,31 +75,60 @@
MudDataGrid<Team> _dataGrid = null!;
private bool _isLoading = true;
private bool _showRegionalOnly = false;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
private async Task ToggleRegionalFilter()
{
if (_isDisposed) return;
try
{
_showRegionalOnly = !_showRegionalOnly;
if (_dataGrid != null)
if (_dataGrid != null && !_isDisposed)
{
await _dataGrid.ReloadServerData();
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
if (!_isDisposed)
{
Snackbar.Add($"Error applying filter: {ex.Message}", Severity.Error);
}
}
}
private async Task<GridData<Team>> ServerReload(GridState<Team> state)
{
if (_isDisposed)
{
return new GridData<Team> { TotalItems = 0, Items = [] };
}
_isLoading = true;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
IQueryable<Team> query
= Context.Teams
.AsNoTracking()
.Include(e => e.Event)
.Include(e => e.Captain)
.Include(e => e.Students)
.ThenInclude(e => e.EventRankings);
@@ -113,7 +143,7 @@
query = query.Where(state.FilterDefinitions);
// Load all data first
var allTeams = await query.ToArrayAsync();
var allTeams = await query.ToArrayAsync(cancellationToken);
// Always sort by EventFormat FIRST to separate group/individual teams
// Sort in memory to ensure this ordering is maintained regardless of user sorts
@@ -173,45 +203,117 @@
Items = pagedData
};
}
catch (TaskCanceledException)
{
// Component was disposed, return empty result
return new GridData<Team> { TotalItems = 0, Items = [] };
}
catch (JSDisconnectedException)
{
// JS connection lost, return empty result
return new GridData<Team> { TotalItems = 0, Items = [] };
}
finally
{
_isLoading = false;
if (!_isDisposed)
{
_isLoading = false;
}
}
}
private async Task DeleteTeam(Team team)
{
//_isRowBlocked = true;
if (_isDisposed) return;
var result = await DialogService
.ShowMessageBox("Delete team",
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
yesText: "Yes",
noText: "Cancel");
if (result == true)
try
{
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
if (team.Identifier == "1" || team.Identifier == "2")
{
var remainingTeam = await Context.Teams
.Include(t => t.Event)
.FirstOrDefaultAsync(t => t.Event.Id == team.Event.Id && t.Id != team.Id);
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
if (remainingTeam != null)
var result = await DialogService
.ShowMessageBox("Delete team",
(MarkupString)$"Are you sure want to delete <b>{team}</b>? This cannot be undone.",
yesText: "Yes",
noText: "Cancel");
if (_isDisposed) return;
if (result == true)
{
// Load the team fresh from database with tracking to avoid tracking conflicts
var teamToDelete = await Context.Teams
.Include(t => t.Event)
.Include(t => t.Students)
.FirstOrDefaultAsync(t => t.Id == team.Id, cancellationToken);
if (_isDisposed) return;
if (teamToDelete == null)
{
remainingTeam.Identifier = null;
Context.Teams.Update(remainingTeam);
if (!_isDisposed)
{
Snackbar.Add("Team not found or already deleted", Severity.Warning);
}
return;
}
// If deleting a numbered team (1 or 2), clear the identifier of the remaining team
if (teamToDelete.Identifier == "1" || teamToDelete.Identifier == "2")
{
var remainingTeam = await Context.Teams
.Include(t => t.Event)
.FirstOrDefaultAsync(t => t.Event.Id == teamToDelete.Event.Id && t.Id != teamToDelete.Id, cancellationToken);
if (_isDisposed) return;
if (remainingTeam != null)
{
remainingTeam.Identifier = null;
Context.Teams.Update(remainingTeam);
}
}
Context.Teams.Remove(teamToDelete);
await Context.SaveChangesAsync(cancellationToken);
if (!_isDisposed)
{
Snackbar.Add($"Team {teamToDelete} deleted", Severity.Info);
}
}
Context.Teams.Remove(team!);
await Context.SaveChangesAsync();
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
if (!_isDisposed)
{
StateHasChanged();
await _dataGrid.ReloadServerData();
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error deleting team: {ex.Message}", Severity.Error);
}
}
}
//_isRowBlocked = false;
StateHasChanged();
await _dataGrid.ReloadServerData();
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@using WebApp.Models
@using WebApp.Components.Shared.Components
@using Core.Utility
@inject IConfiguration Configuration
@inject AppDbContext Context
@@ -56,7 +57,13 @@ else
.Find(e => e.EventDefinition == context.Event)?.Rank ?? int.MaxValue;
<MudTd Class="@(EventRankClass(rank))">
@student.Name @if(context?.Captain == student) {<span> (Cpt)</span>}
@TeamStudentNameFormatter.FormatStudentName(
student,
context,
new TeamStudentNameFormatter.FormatOptions
{
CaptainIndicator = TeamStudentNameFormatter.CaptainIndicatorStyle.Captain
})
</MudTd>
}
else
@@ -191,7 +198,12 @@ else
<span>❔</span>
}
</MudTd>
<MudTd>@string.Join(", ", team.Students.Where(e => e != context).Select(e => e.FirstName))</MudTd>
<MudTd>@TeamStudentNameFormatter.FormatStudentList(
new Team { Students = team.Students.Where(e => e != context).ToList(), Event = team.Event },
new TeamStudentNameFormatter.FormatOptions
{
Ordering = TeamStudentNameFormatter.OrderingStyle.None
})</MudTd>
<MudTd></MudTd>
</RowTemplate>
</MudTable>
@@ -218,6 +230,7 @@ else
{
_teams
= await Context.Teams
.AsNoTracking()
.Include(e => e.Event)
.Include(e => e.Students)
.OrderByEventFormatFirst()
@@ -228,6 +241,7 @@ else
_maxTeamSize = _teams.Max(t => t.Students.Count);
_students =
await Context.Students
.AsNoTracking()
.Include(e => e.Teams)
.ThenInclude(e => e.Captain)
.Include(e => e.EventRankings)
@@ -0,0 +1,88 @@
@using Core.Entities
@using WebApp.Services
@using PSC.Blazor.Components.MarkdownEditor
@using MudBlazor
@inject INotesService NotesService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="_note.Title"
Label="Title"
Variant="Variant.Outlined"
Required="true"
RequiredError="Title is required"
MaxLength="200" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Content (Markdown)</MudText>
<MarkdownEditor Value="@_note.Content"
ValueChanged="@((string? value) => _note.Content = value)"
Placeholder="Enter your markdown content here..."
AutoSaveEnabled="false"
NativeSpellChecker="false" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Save">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public Note Note { get; set; } = null!;
[Parameter]
public bool IsEdit { get; set; }
private Note _note = null!;
protected override void OnInitialized()
{
_note = new Note
{
Id = Note.Id,
Title = Note.Title ?? "",
Content = Note.Content ?? ""
};
}
private async Task Save()
{
if (string.IsNullOrWhiteSpace(_note.Title))
{
Snackbar.Add("Title is required", Severity.Warning);
return;
}
try
{
if (IsEdit)
{
await NotesService.UpdateNoteAsync(_note);
Snackbar.Add("Note updated successfully", Severity.Success);
}
else
{
await NotesService.CreateNoteAsync(_note);
Snackbar.Add("Note created successfully", Severity.Success);
}
MudDialog.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error saving note: {ex.Message}", Severity.Error);
}
}
private void Cancel()
{
MudDialog.Cancel();
}
}
@@ -0,0 +1,125 @@
@using Core.Entities
@using WebApp.Services
@using MudBlazor
@inject INotesService NotesService
@inject IDialogService DialogService
<MudDialog>
<DialogContent>
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_history.Any())
{
<MudText Typo="Typo.body1" Align="Align.Center" Class="my-4">
No history available for this note.
</MudText>
}
else
{
<MudTable Items="@_history" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Date/Time</MudTh>
<MudTh>User</MudTh>
<MudTh>Change Type</MudTh>
<MudTh>Title</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Date/Time">
@context.ModifiedAt.ToString("g")
</MudTd>
<MudTd DataLabel="User">
@(context.ModifiedBy ?? "Unknown")
</MudTd>
<MudTd DataLabel="Change Type">
<MudChip T="string" Size="Size.Small"
Color="@GetChangeTypeColor(context.ChangeType)">
@context.ChangeType
</MudChip>
</MudTd>
<MudTd DataLabel="Title">
@context.Title
</MudTd>
<MudTd DataLabel="Actions">
<MudButton StartIcon="@Icons.Material.Filled.Visibility"
OnClick="() => ViewVersion(context)"
Variant="Variant.Text"
Size="Size.Small">
View
</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public int NoteId { get; set; }
private List<NoteHistory> _history = [];
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
await LoadHistory();
}
private async Task LoadHistory()
{
try
{
_history = (await NotesService.GetNoteHistoryAsync(NoteId)).ToList();
}
catch (Exception ex)
{
// Error handling - could show snackbar if we had access
}
finally
{
_isLoading = false;
}
}
private Color GetChangeTypeColor(string changeType)
{
return changeType switch
{
"Created" => Color.Success,
"Updated" => Color.Info,
"Deleted" => Color.Error,
_ => Color.Default
};
}
private async Task ViewVersion(NoteHistory history)
{
var parameters = new DialogParameters
{
["History"] = history
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
await DialogService.ShowAsync<NoteVersionViewDialog>("View Version", parameters, options);
}
private void Close()
{
MudDialog.Close();
}
}
@@ -0,0 +1,66 @@
@using Core.Entities
@using WebApp.Services
@using MudBlazor
<MudDialog>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.h6">@_history.Title</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
Modified: @_history.ModifiedAt.ToString("g") by @(_history.ModifiedBy ?? "Unknown")
</MudText>
<MudChip T="string" Size="Size.Small" Color="@GetChangeTypeColor(_history.ChangeType)">
@_history.ChangeType
</MudChip>
<MudDivider />
@if (!string.IsNullOrWhiteSpace(_history.Content))
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(_history.Content))
</div>
</MudPaper>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Align="Align.Center" Class="my-4">
No content in this version.
</MudText>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Close">Close</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter]
public NoteHistory History { get; set; } = null!;
private NoteHistory _history = null!;
protected override void OnInitialized()
{
_history = History;
}
private Color GetChangeTypeColor(string changeType)
{
return changeType switch
{
"Created" => Color.Success,
"Updated" => Color.Info,
"Deleted" => Color.Error,
_ => Color.Default
};
}
private void Close()
{
MudDialog.Close();
}
}
+260
View File
@@ -0,0 +1,260 @@
@page "/notes"
@attribute [Authorize]
@implements IAsyncDisposable
@using Core.Entities
@using WebApp.Services
@using WebApp.Components.Shared.Components
@inject INotesService NotesService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<PageHeader Title="Notes">
<ActionButtons>
<MudButton StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenCreateDialog"
Variant="Variant.Filled"
Color="Color.Primary">
Create Note
</MudButton>
</ActionButtons>
</PageHeader>
<MudPaper Elevation="2" Class="pa-3 pa-md-6">
@if (_isLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-4" />
}
else if (!_notes.Any())
{
<MudText Typo="Typo.h6" Align="Align.Center" Class="my-8">
No notes yet. Create your first note to get started!
</MudText>
}
else
{
<MudExpansionPanels MultiExpansion="true">
@foreach (var note in _notes)
{
<MudExpansionPanel Text="@note.Title"
Icon="@Icons.Material.Filled.Note">
<MudStack Spacing="2">
@if (!string.IsNullOrWhiteSpace(note.Content))
{
<MudPaper Elevation="0" Class="pa-3" Style="background-color: var(--mud-palette-background-grey);">
<div class="markdown-content">
@((MarkupString)MarkdownHelper.ToHtml(note.Content))
</div>
</MudPaper>
}
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Last updated: @note.UpdatedAt.ToString("g") by @(note.LastModifiedBy ?? "Unknown")
</MudText>
<MudStack Row="true" Spacing="2">
<MudButton StartIcon="@Icons.Material.Filled.History"
OnClick="() => OpenHistoryDialog(note.Id)"
Variant="Variant.Text"
Size="Size.Small">
History
</MudButton>
<MudButton StartIcon="@Icons.Material.Filled.Edit"
OnClick="() => OpenEditDialog(note)"
Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary">
Edit
</MudButton>
<MudButton StartIcon="@Icons.Material.Outlined.Delete"
OnClick="() => DeleteNote(note)"
Variant="Variant.Text"
Size="Size.Small"
Color="Color.Error">
Delete
</MudButton>
</MudStack>
</MudStack>
</MudStack>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
</MudPaper>
@code {
private List<Note> _notes = [];
private bool _isLoading = true;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isDisposed = false;
protected override void OnInitialized()
{
_cancellationTokenSource = new CancellationTokenSource();
}
protected override async Task OnInitializedAsync()
{
await LoadNotes();
}
private async Task LoadNotes()
{
if (_isDisposed) return;
_isLoading = true;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
_notes = (await NotesService.GetNotesAsync()).ToList();
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error loading notes: {ex.Message}", Severity.Error);
}
}
finally
{
if (!_isDisposed)
{
_isLoading = false;
StateHasChanged();
}
}
}
private async Task OpenCreateDialog()
{
if (_isDisposed) return;
var parameters = new DialogParameters
{
["Note"] = new Note { Title = "", Content = "" },
["IsEdit"] = false
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Create Note", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
{
await LoadNotes();
}
}
private async Task OpenEditDialog(Note note)
{
if (_isDisposed) return;
var parameters = new DialogParameters
{
["Note"] = note,
["IsEdit"] = true
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
var dialog = await DialogService.ShowAsync<NoteEditDialog>("Edit Note", parameters, options);
var result = await dialog.Result;
if (!result.Canceled && !_isDisposed)
{
await LoadNotes();
}
}
private async Task OpenHistoryDialog(int noteId)
{
if (_isDisposed) return;
var parameters = new DialogParameters
{
["NoteId"] = noteId
};
var options = new DialogOptions
{
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseButton = true
};
await DialogService.ShowAsync<NoteHistoryDialog>("Note History", parameters, options);
}
private async Task DeleteNote(Note note)
{
if (_isDisposed) return;
try
{
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
var result = await DialogService.ShowMessageBox(
"Delete Note",
(MarkupString)$"Are you sure you want to delete <b>{note.Title}</b>? This cannot be undone.",
yesText: "Yes",
noText: "Cancel");
if (_isDisposed) return;
if (result == true)
{
await NotesService.DeleteNoteAsync(note.Id);
if (!_isDisposed)
{
Snackbar.Add($"Note '{note.Title}' deleted", Severity.Info);
await LoadNotes();
}
}
}
catch (TaskCanceledException)
{
// Component was disposed, ignore
}
catch (JSDisconnectedException)
{
// JS connection lost, ignore
}
catch (Exception ex)
{
if (!_isDisposed)
{
Snackbar.Add($"Error deleting note: {ex.Message}", Severity.Error);
}
}
}
public async ValueTask DisposeAsync()
{
if (!_isDisposed)
{
_isDisposed = true;
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
}
await ValueTask.CompletedTask;
}
}
+6 -1
View File
@@ -5,7 +5,12 @@
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@{
navigationManager.NavigateTo("/login", true);
var currentUri = navigationManager.Uri;
var baseUri = navigationManager.BaseUri;
var relativePath = currentUri.Replace(baseUri, "").TrimStart('/');
var returnUrl = string.IsNullOrEmpty(relativePath) ? "/" : "/" + relativePath;
var encodedReturnUrl = Uri.EscapeDataString(returnUrl);
navigationManager.NavigateTo($"/login?returnUrl={encodedReturnUrl}", true);
}
</NotAuthorized>
</AuthorizeRouteView>
@@ -0,0 +1,50 @@
@namespace WebApp.Components.Shared.Components
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center"
Style="position: relative; display: inline-flex;"
Class="@WrapperClass"
@onmouseenter="@(() => _isHovered = true)"
@onmouseleave="@(() => _isHovered = false)">
<MudChip T="string"
Size="@Size"
Color="@Color"
Variant="@Variant"
Style="@Style"
Class="@Class">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
@ChildContent
@if (_isHovered && ControlContent != null)
{
@ControlContent
}
</MudStack>
</MudChip>
</MudStack>
@code {
[Parameter]
public required RenderFragment ChildContent { get; set; }
[Parameter]
public RenderFragment? ControlContent { get; set; }
[Parameter]
public Size Size { get; set; } = Size.Small;
[Parameter]
public Color Color { get; set; } = Color.Default;
[Parameter]
public Variant Variant { get; set; } = Variant.Text;
[Parameter]
public string? Style { get; set; }
[Parameter]
public string? Class { get; set; }
[Parameter]
public string? WrapperClass { get; set; }
private bool _isHovered = false;
}
@@ -1,4 +1,4 @@
@using WebApp.Models
@using WebApp.Models
@using WebApp.Authentication
@inject IConfiguration Configuration
@@ -28,6 +28,10 @@
<MudNavLink Href="/events" Icon="@AppIcons.Events">Events</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Tools" Icon="@Icons.Material.Filled.Build" Expanded="false">
<MudNavLink Href="/notes" Icon="@Icons.Material.Filled.Note">Notes</MudNavLink>
</MudNavGroup>
<AuthorizeView Roles="Administrator">
<MudDivider Class="my-2"/>
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
+3 -1
View File
@@ -1,4 +1,4 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@@ -28,3 +28,5 @@
@using Core.Models
@using Data
@using VisNetwork.Blazor
@using PSC.Blazor.Components.MarkdownEditor
@using WebApp.Services
+55 -10
View File
@@ -9,10 +9,12 @@ namespace WebApp;
public sealed class LocalStorageService
{
private readonly IJSRuntime _jsRuntime;
private readonly ILogger<LocalStorageService> _logger;
public LocalStorageService(IJSRuntime jsRuntime)
public LocalStorageService(IJSRuntime jsRuntime, ILogger<LocalStorageService> logger)
{
_jsRuntime = jsRuntime;
_logger = logger;
}
/// <summary>
@@ -47,7 +49,7 @@ public sealed class LocalStorageService
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save boolean to localStorage [{key}]: {ex.Message}");
_logger.LogWarning(ex, "Failed to save boolean to localStorage [{Key}]", key);
}
}
@@ -83,7 +85,7 @@ public sealed class LocalStorageService
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save integer to localStorage [{key}]: {ex.Message}");
_logger.LogWarning(ex, "Failed to save integer to localStorage [{Key}]", key);
}
}
@@ -100,14 +102,14 @@ public sealed class LocalStorageService
if (!string.IsNullOrEmpty(json))
{
var array = JsonSerializer.Deserialize<int[]>(json);
return array ?? Array.Empty<int>();
return array ?? [];
}
return Array.Empty<int>();
return [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load integer array from localStorage [{key}]: {ex.Message}");
return Array.Empty<int>();
_logger.LogWarning(ex, "Failed to load integer array from localStorage [{Key}]", key);
return [];
}
}
@@ -125,7 +127,7 @@ public sealed class LocalStorageService
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save integer array to localStorage [{key}]: {ex.Message}");
_logger.LogWarning(ex, "Failed to save integer array to localStorage [{Key}]", key);
}
}
@@ -141,7 +143,50 @@ public sealed class LocalStorageService
}
catch (Exception ex)
{
Console.WriteLine($"Failed to remove from localStorage [{key}]: {ex.Message}");
_logger.LogWarning(ex, "Failed to remove from localStorage [{Key}]", key);
}
}
/// <summary>
/// Gets a JSON-serialized object from localStorage.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <param name="key">The storage key.</param>
/// <returns>The deserialized object or default value if not found.</returns>
public async Task<T?> GetJsonAsync<T>(string key)
{
try
{
var json = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
if (!string.IsNullOrEmpty(json))
{
return JsonSerializer.Deserialize<T>(json);
}
return default;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load JSON from localStorage [{Key}]", key);
return default;
}
}
/// <summary>
/// Sets a JSON-serialized object in localStorage.
/// </summary>
/// <typeparam name="T">The type to serialize.</typeparam>
/// <param name="key">The storage key.</param>
/// <param name="value">The object to store.</param>
public async Task SetJsonAsync<T>(string key, T value)
{
try
{
var json = JsonSerializer.Serialize(value);
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save JSON to localStorage [{Key}]", key);
}
}
@@ -156,7 +201,7 @@ public sealed class LocalStorageService
}
catch (Exception ex)
{
Console.WriteLine($"Failed to clear localStorage: {ex.Message}");
_logger.LogWarning(ex, "Failed to clear localStorage");
}
}
}
+2 -3
View File
@@ -33,15 +33,14 @@ namespace WebApp.Logging
LogEventLevel.Information,
null, // No exception at Info level
messageTemplate,
new[]
{
[
new LogEventProperty("OriginalMessage",
new ScalarValue(logEvent.MessageTemplate.Render(logEvent.Properties))),
new LogEventProperty("SourceContext",
logEvent.Properties.GetValueOrDefault("SourceContext") ?? new ScalarValue("Unknown")),
new LogEventProperty("RequestPath",
logEvent.Properties.GetValueOrDefault("RequestPath") ?? new ScalarValue("Unknown"))
});
]);
_wrappedSink.Emit(rewrittenEvent);
}
+15
View File
@@ -154,5 +154,20 @@ namespace WebApp.Models
};
return $"{number}<sup>{suffix}</sup>";
}
/// <summary>
/// Returns the standard chip variant for student chips (Outlined).
/// </summary>
public static Variant StudentChipVariant() => Variant.Outlined;
/// <summary>
/// Returns the standard chip variant for event chips (Filled).
/// </summary>
public static Variant EventChipVariant() => Variant.Filled;
/// <summary>
/// Returns the standard chip variant for team chips (Filled).
/// </summary>
public static Variant TeamChipVariant() => Variant.Filled;
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ public class CalendarEventItem : CalendarItem
EventDefinition = occurrence.EventDefinition;
// Set base class properties that the calendar component uses
StudentFirstNames = studentFirstNames?.ToList() ?? [];
Text = occurrence.EventDefinition?.ShortName;
Text = occurrence.EventDefinition?.ShortName ?? string.Empty;
Start = occurrence.StartTime;
End = occurrence.EndTime ?? occurrence.StartTime.AddHours(1);
+1
View File
@@ -193,6 +193,7 @@ 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>();
// State container for maintaining state per user connection (Blazor Server)
builder.Services.AddScoped<StateContainer>();
@@ -40,6 +40,7 @@ public class EventDefinitionService
// Get all existing careers from database (case-insensitive lookup)
var existingCareers = await _context.Careers
.AsNoTracking()
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.ToListAsync();
+36
View File
@@ -0,0 +1,36 @@
using Core.Entities;
namespace WebApp.Services;
public interface INotesService
{
/// <summary>
/// Gets all notes.
/// </summary>
Task<IEnumerable<Note>> GetNotesAsync();
/// <summary>
/// Gets a single note by ID.
/// </summary>
Task<Note?> GetNoteAsync(int id);
/// <summary>
/// Gets all history entries for a note.
/// </summary>
Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId);
/// <summary>
/// Creates a new note and initial history entry.
/// </summary>
Task<Note> CreateNoteAsync(Note note);
/// <summary>
/// Updates an existing note and creates a history entry.
/// </summary>
Task<Note> UpdateNoteAsync(Note note);
/// <summary>
/// Deletes a note and creates a deletion history entry.
/// </summary>
Task DeleteNoteAsync(int id);
}
+48
View File
@@ -0,0 +1,48 @@
using Markdig;
using System.Text.RegularExpressions;
namespace WebApp.Services;
/// <summary>
/// Helper class for rendering markdown to HTML.
/// </summary>
public static class MarkdownHelper
{
private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
/// <summary>
/// Converts markdown text to HTML.
/// </summary>
/// <param name="markdown">The markdown text to convert.</param>
/// <returns>HTML string ready for rendering.</returns>
public static string ToHtml(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
return string.Empty;
var html = Markdown.ToHtml(markdown, _pipeline);
// Add target="_blank" and rel="noopener noreferrer" to all links
html = Regex.Replace(html, @"<a\s+([^>]*?)href=""([^""]*?)""([^>]*?)>",
match =>
{
var beforeHref = match.Groups[1].Value;
var href = match.Groups[2].Value;
var afterHref = match.Groups[3].Value;
// Check if target is already present
if (Regex.IsMatch(beforeHref + afterHref, @"target\s*=", RegexOptions.IgnoreCase))
{
return match.Value; // Return unchanged if target already exists
}
// Add target="_blank" and rel="noopener noreferrer"
return $"<a {beforeHref}href=\"{href}\" target=\"_blank\" rel=\"noopener noreferrer\"{afterHref}>";
},
RegexOptions.IgnoreCase);
return html;
}
}
+162
View File
@@ -0,0 +1,162 @@
using Core.Entities;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace WebApp.Services;
public class NotesService : INotesService
{
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<NotesService> _logger;
public NotesService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<NotesService> logger)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
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<Note>> GetNotesAsync()
{
return await _context.Notes
.AsNoTracking()
.OrderByDescending(n => n.UpdatedAt)
.ToListAsync();
}
public async Task<Note?> GetNoteAsync(int id)
{
return await _context.Notes
.Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt))
.FirstOrDefaultAsync(n => n.Id == id);
}
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{
return await _context.NoteHistories
.AsNoTracking()
.Where(h => h.NoteId == noteId)
.OrderByDescending(h => h.ModifiedAt)
.Take(10)
.ToListAsync();
}
public async Task<Note> CreateNoteAsync(Note note)
{
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
note.CreatedAt = now;
note.UpdatedAt = now;
note.CreatedBy = userEmail;
note.LastModifiedBy = userEmail;
_context.Notes.Add(note);
await _context.SaveChangesAsync();
// Create initial history entry
var history = new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Created"
};
_context.NoteHistories.Add(history);
await _context.SaveChangesAsync();
_logger.LogInformation("Note created: {NoteId} by {User}", note.Id, userEmail);
return note;
}
public async Task<Note> UpdateNoteAsync(Note note)
{
var existingNote = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == note.Id);
if (existingNote == null)
{
throw new InvalidOperationException($"Note with ID {note.Id} not found.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// Create history entry with previous state before updating
var history = new NoteHistory
{
NoteId = existingNote.Id,
Title = existingNote.Title,
Content = existingNote.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Updated"
};
_context.NoteHistories.Add(history);
// Update the note
existingNote.Title = note.Title;
existingNote.Content = note.Content;
existingNote.UpdatedAt = now;
existingNote.LastModifiedBy = userEmail;
await _context.SaveChangesAsync();
_logger.LogInformation("Note updated: {NoteId} by {User}", note.Id, userEmail);
return existingNote;
}
public async Task DeleteNoteAsync(int id)
{
var note = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == id);
if (note == null)
{
throw new InvalidOperationException($"Note with ID {id} not found.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// Create deletion history entry
var history = new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Deleted"
};
_context.NoteHistories.Add(history);
// Delete the note (cascade will handle history, but we want to keep history)
_context.Notes.Remove(note);
await _context.SaveChangesAsync();
_logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail);
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -29,10 +29,12 @@
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="MudBlazor" Version="8.14.0" />
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="PSC.Blazor.Components.MarkdownEditor" Version="8.0.6" />
<PackageReference Include="Markdig" Version="0.37.0" />
</ItemGroup>
<ItemGroup>
+101
View File
@@ -128,4 +128,105 @@
body {
font-size: 0.875rem;
}
}
/* Markdown content styling */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
}
.markdown-content h1 {
font-size: 2em;
border-bottom: 1px solid var(--mud-palette-divider);
padding-bottom: 0.3em;
}
.markdown-content h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--mud-palette-divider);
padding-bottom: 0.3em;
}
.markdown-content p {
margin: 1em 0;
}
.markdown-content ul,
.markdown-content ol {
margin: 1em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.5em 0;
}
.markdown-content a {
color: var(--mud-palette-primary);
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content code {
background-color: var(--mud-palette-background-grey);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background-color: var(--mud-palette-background-grey);
padding: 1em;
border-radius: 4px;
overflow-x: auto;
}
.markdown-content pre code {
background-color: transparent;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid var(--mud-palette-primary);
padding-left: 1em;
margin: 1em 0;
color: var(--mud-palette-text-secondary);
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content table th,
.markdown-content table td {
border: 1px solid var(--mud-palette-divider);
padding: 0.5em;
text-align: left;
}
.markdown-content table th {
background-color: var(--mud-palette-background-grey);
font-weight: 600;
}
.markdown-content img {
max-width: 100%;
height: auto;
}