From 3712dba974fdd6e806187dbabe27cdd0c1ad26af Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sun, 30 Aug 2026 23:03:10 -0400 Subject: [PATCH] feat: merge event rankings and attribute badges into the page printer Interview notes can print each student's ranked events and a shared attribute legend from the same catalog as the ranking index, without leftover table markup or collapsed answer space. Co-authored-by: Cursor --- Core/Printing/EventAttributeMarks.cs | 45 +++++++ Core/Printing/EventRankLegend.cs | 53 ++++++++ Core/Printing/NoteTemplateMerger.cs | 43 ++++++- Core/Printing/PrintFieldCatalog.cs | 22 +++- Core/Printing/PrintRankBadgeHtml.cs | 89 +++++++++++++ Core/Printing/PrintTokenMap.cs | 23 +++- Core/Printing/StudentRankTokens.cs | 57 +++++++++ Tests/Printing/NoteTemplateMerger_Tests.cs | 49 +++++++ Tests/Printing/PrintFieldCatalog_Tests.cs | 22 ++++ Tests/Printing/PrintRankBadgeHtml_Tests.cs | 120 ++++++++++++++++++ Tests/Printing/PrintTokenMap_Tests.cs | 46 +++++++ Tests/Printing/StudentRankTokens_Tests.cs | 88 +++++++++++++ .../Features/Print/PagePrinter.razor | 2 + WebApp/Components/Pages/Legend.razor | 15 +-- WebApp/Models/AppIcons.cs | 115 ++++------------- WebApp/Services/NotePrintService.cs | 56 +++++++- WebApp/wwwroot/app.css | 52 +++++++- docs/instructions/page-printer.md | 35 ++++- docs/plans/page-printer.md | 3 + 19 files changed, 817 insertions(+), 118 deletions(-) create mode 100644 Core/Printing/EventAttributeMarks.cs create mode 100644 Core/Printing/EventRankLegend.cs create mode 100644 Core/Printing/PrintRankBadgeHtml.cs create mode 100644 Core/Printing/StudentRankTokens.cs create mode 100644 Tests/Printing/PrintRankBadgeHtml_Tests.cs create mode 100644 Tests/Printing/StudentRankTokens_Tests.cs diff --git a/Core/Printing/EventAttributeMarks.cs b/Core/Printing/EventAttributeMarks.cs new file mode 100644 index 0000000..2367450 --- /dev/null +++ b/Core/Printing/EventAttributeMarks.cs @@ -0,0 +1,45 @@ +using Core.Entities; + +namespace Core.Printing; + +public sealed record EventMark( + string Symbol, + string Label, + string Color, + Func Applies); + +/// +/// Compact event-attribute marks used on the event-ranking index chip, +/// print badges, and the shared legend. +/// +public static class EventAttributeMarks +{ + public const string LevelOfEffort1 = "○"; + public const string LevelOfEffort2 = "◐"; + public const string LevelOfEffort3 = "⬤"; + public const string Individual = "ⓘ"; + public const string OnSite = "ⓐ"; + public const string Regional = "ⓡ"; + public const string Presubmission = "ⓟ"; + + public static readonly IReadOnlyList LegendItems = + [ + new(LevelOfEffort1, "Level of Effort: 1", "#757575", e => e.LevelOfEffort == 1), + new(LevelOfEffort2, "Level of Effort: 2", "#616161", e => e.LevelOfEffort == 2), + new(LevelOfEffort3, "Level of Effort: 3", "#424242", e => e.LevelOfEffort == 3), + new(Individual, "Individual Event", "#9c27b0", e => e.EventFormat == EventFormat.Individual), + new(OnSite, "On-Site Activity", "#ff9800", e => e.OnSiteActivity), + new(Regional, "Regional Event", "#2196f3", e => e.RegionalEvent), + new(Presubmission, "Presubmission", "#4caf50", e => e.Presubmission) + ]; + + public static string For(EventDefinition? evt) + { + if (evt is null) + return string.Empty; + + return string.Join( + " ", + LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol)); + } +} diff --git a/Core/Printing/EventRankLegend.cs b/Core/Printing/EventRankLegend.cs new file mode 100644 index 0000000..a419619 --- /dev/null +++ b/Core/Printing/EventRankLegend.cs @@ -0,0 +1,53 @@ +using Core.Entities; + +namespace Core.Printing; + +/// +/// Rank labels and colors shared by the ranking index, print badges, and legend. +/// +public static class EventRankLegend +{ + public static readonly IReadOnlyList<(int Rank, string Label)> Items = + [ + .. Enumerable.Range(1, StudentEventRanking.MaxRank) + .Select(rank => (rank, Ordinal(rank))) + ]; + + public static string Ordinal(int num) + { + if (num <= 0) + return num.ToString(); + + switch (num % 100) + { + case 11: + case 12: + case 13: + return num + "th"; + } + + return (num % 10) switch + { + 1 => num + "st", + 2 => num + "nd", + 3 => num + "rd", + _ => num + "th" + }; + } + + public static string ColorHex(int rank) => + rank switch + { + 1 => "#dd7e6b", + 2 => "#ea9999", + 3 => "#f9cb9c", + 4 => "#ffe599", + 5 => "#fff2cc", + 6 => "#fffaea", + 7 => "#fffefa", + 8 => "#fffefc", + 9 => "#fffffd", + 10 => "#fffffe", + _ => "#ddd" + }; +} diff --git a/Core/Printing/NoteTemplateMerger.cs b/Core/Printing/NoteTemplateMerger.cs index f4ebc69..89ea387 100644 --- a/Core/Printing/NoteTemplateMerger.cs +++ b/Core/Printing/NoteTemplateMerger.cs @@ -7,6 +7,8 @@ namespace Core.Printing; /// Unknown tokens are left unchanged. Known empty values become blank. /// {{PageBreak}} becomes a print page break after HTML conversion. /// {{AnswerSpace}} becomes ruled write-in space after HTML conversion. +/// {{RankedEvents}} and {{RankedStudents}} become ranking-index badges. +/// {{Legend}} becomes the shared attribute-mark legend. /// public static class NoteTemplateMerger { @@ -16,14 +18,29 @@ public static class NoteTemplateMerger public const string AnswerSpaceToken = "AnswerSpace"; public const string AnswerSpaceSentinel = ""; - public const string AnswerSpaceHtml = "
"; + public const string AnswerSpaceHtml = "
 
"; + + public const string LegendToken = "Legend"; + public const string LegendSentinel = ""; + + public const string RankedEventsToken = "RankedEvents"; + public const string RankedStudentsToken = "RankedStudents"; + + public static readonly string[] HtmlFragmentTokens = + [ + RankedEventsToken, + RankedStudentsToken + ]; + + public static string HtmlFragmentSentinel(string name) => $""; private readonly record struct LayoutToken(string Name, string Sentinel, string Html); private static readonly LayoutToken[] LayoutTokens = [ new(PageBreakToken, PageBreakSentinel, PageBreakHtml), - new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml) + new(AnswerSpaceToken, AnswerSpaceSentinel, AnswerSpaceHtml), + new(LegendToken, LegendSentinel, PrintRankBadgeHtml.Legend()) ]; private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled); @@ -47,6 +64,12 @@ public static class NoteTemplateMerger return layout.Sentinel; } + foreach (var htmlName in HtmlFragmentTokens) + { + if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase)) + return HtmlFragmentSentinel(htmlName); + } + return tokens.TryGetValue(key, out var value) ? value ?? string.Empty : match.Value; @@ -56,7 +79,9 @@ public static class NoteTemplateMerger /// /// Turns layout sentinels into HTML after markdown has been rendered. /// - public static string ApplyLayout(string? html) + public static string ApplyLayout( + string? html, + IReadOnlyDictionary? htmlFragments = null) { if (string.IsNullOrEmpty(html)) return string.Empty; @@ -68,6 +93,18 @@ public static class NoteTemplateMerger .Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal); } + foreach (var name in HtmlFragmentTokens) + { + var sentinel = HtmlFragmentSentinel(name); + var fragment = htmlFragments is not null + && htmlFragments.TryGetValue(name, out var value) + ? value ?? string.Empty + : string.Empty; + html = html + .Replace($"

{sentinel}

", fragment, StringComparison.Ordinal) + .Replace(sentinel, fragment, StringComparison.Ordinal); + } + return html; } } diff --git a/Core/Printing/PrintFieldCatalog.cs b/Core/Printing/PrintFieldCatalog.cs index 63f5953..2b3ef03 100644 --- a/Core/Printing/PrintFieldCatalog.cs +++ b/Core/Printing/PrintFieldCatalog.cs @@ -8,7 +8,8 @@ public static class PrintFieldCatalog public static readonly string[] Layout = [ NoteTemplateMerger.PageBreakToken, - NoteTemplateMerger.AnswerSpaceToken + NoteTemplateMerger.AnswerSpaceToken, + NoteTemplateMerger.LegendToken ]; public static readonly string[] Chapter = @@ -36,6 +37,12 @@ public static class PrintFieldCatalog "OfficerRole" ]; + /// + /// Rank1…Rank10 and matching .ShortName tokens from + /// . + /// + public static readonly string[] StudentRanks = [.. StudentRankTokens.AllNames]; + public static readonly string[] Team = [ "Identifier", @@ -46,7 +53,8 @@ public static class PrintFieldCatalog "TeamSize", "Eligibility", "Description", - "Theme" + "Theme", + "EventAttributes" ]; public static readonly string[] Event = @@ -62,7 +70,9 @@ public static class PrintFieldCatalog "Description", "Theme", "Documentation", - "Notes" + "Notes", + "EventAttributes", + NoteTemplateMerger.RankedStudentsToken ]; public static IReadOnlyList EntityTokens(PrintEntityType entityType) => @@ -75,5 +85,9 @@ public static class PrintFieldCatalog }; public static IReadOnlyList BuiltInFor(PrintEntityType entityType) => - [.. Chapter, .. EntityTokens(entityType)]; + entityType switch + { + PrintEntityType.Student => [.. Chapter, .. Student, .. StudentRanks], + _ => [.. Chapter, .. EntityTokens(entityType)] + }; } diff --git a/Core/Printing/PrintRankBadgeHtml.cs b/Core/Printing/PrintRankBadgeHtml.cs new file mode 100644 index 0000000..9c631f9 --- /dev/null +++ b/Core/Printing/PrintRankBadgeHtml.cs @@ -0,0 +1,89 @@ +using Core.Entities; + +namespace Core.Printing; + +/// +/// Ranking-index style badges: colored rank dot plus a short label. +/// Student pages list ranked events; event pages list students who ranked them. +/// +public static class PrintRankBadgeHtml +{ + public static string ForStudentEvents(IEnumerable? rankings) + { + if (rankings is null) + return string.Empty; + + var badges = rankings + .Where(r => r.Rank is >= 1 and <= StudentEventRanking.MaxRank) + .OrderBy(r => r.Rank) + .Select(EventBadge) + .ToList(); + + return Wrap(badges); + } + + public static string ForEventStudents(IEnumerable? rankings) + { + if (rankings is null) + return string.Empty; + + var badges = rankings + .Where(r => r.Student is not null && r.Rank is >= 1 and <= StudentEventRanking.MaxRank) + .OrderBy(r => r.Rank) + .ThenByDescending(r => r.Student.Grade + r.Student.TsaYear) + .Select(r => StudentBadge(r.Student.FirstName, r.Rank)) + .ToList(); + + return Wrap(badges); + } + + internal static string EventBadge(StudentEventRanking ranking) + { + var evt = ranking.EventDefinition; + var label = !string.IsNullOrWhiteSpace(evt?.ShortName) + ? evt.ShortName + : evt?.Name; + var attributes = EventAttributeMarks.For(evt); + var attrsHtml = string.IsNullOrEmpty(attributes) + ? string.Empty + : $"{PrintTokenMap.EscapeHtml(attributes)}"; + + return Badge(ranking.Rank, PrintTokenMap.EscapeHtml(label), attrsHtml); + } + + internal static string StudentBadge(string? firstName, int rank) => + Badge(rank, PrintTokenMap.EscapeHtml(firstName), string.Empty); + + private static string Badge(int rank, string labelHtml, string extraHtml) + { + var extra = string.IsNullOrEmpty(extraHtml) ? string.Empty : $" {extraHtml}"; + return + $"" + + $" " + + $"{labelHtml}{extra}" + + ""; + } + + public static string Legend() + { + var marks = EventAttributeMarks.LegendItems + .Select(mark => + "" + + $"{PrintTokenMap.EscapeHtml(mark.Symbol)} " + + PrintTokenMap.EscapeHtml(mark.Label) + + ""); + + return + "
" + + $"
{string.Join(" · ", marks)}
" + + "
"; + } + + private static string Wrap(IReadOnlyList badges, string separator = " ") + { + if (badges.Count == 0) + return string.Empty; + + return $"
{string.Join(separator, badges)}
"; + } +} diff --git a/Core/Printing/PrintTokenMap.cs b/Core/Printing/PrintTokenMap.cs index ca27b76..581b8a1 100644 --- a/Core/Printing/PrintTokenMap.cs +++ b/Core/Printing/PrintTokenMap.cs @@ -36,14 +36,16 @@ public static class PrintTokenMap /// /// Treats substituted values as plain text so they cannot change markdown or inject HTML. + /// Line breaks are flattened so a value cannot end a markdown table row. /// public static string Escape(string? value) { if (string.IsNullOrEmpty(value)) return string.Empty; - return value + return FlattenLines(value) .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal) .Replace("*", "\\*", StringComparison.Ordinal) .Replace("_", "\\_", StringComparison.Ordinal) .Replace("`", "\\`", StringComparison.Ordinal) @@ -52,4 +54,23 @@ public static class PrintTokenMap .Replace("<", "<", StringComparison.Ordinal) .Replace(">", ">", StringComparison.Ordinal); } + + /// + /// Escapes a value for insertion into generated print HTML (badge labels). + /// + public static string EscapeHtml(string? value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return FlattenLines(value) + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal); + } + + private static string FlattenLines(string value) => + string.Join(' ', + value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } diff --git a/Core/Printing/StudentRankTokens.cs b/Core/Printing/StudentRankTokens.cs new file mode 100644 index 0000000..6618d75 --- /dev/null +++ b/Core/Printing/StudentRankTokens.cs @@ -0,0 +1,57 @@ +using Core.Entities; + +namespace Core.Printing; + +/// +/// Per-student event-rank merge tokens. Rank1 is the official event name +/// at rank 1; Rank1.ShortName is the catalog short name. Every rank +/// through is always a map key so a +/// missing preference prints blank instead of leaving {{Rank5}} visible. +/// +public static class StudentRankTokens +{ + public static string NameToken(int rank) => $"Rank{rank}"; + + public static string ShortNameToken(int rank) => $"Rank{rank}.ShortName"; + + public static string AttributesToken(int rank) => $"Rank{rank}.Attributes"; + + public static IReadOnlyList AllNames { get; } = + [ + NoteTemplateMerger.RankedEventsToken, + .. Enumerable.Range(1, StudentEventRanking.MaxRank) + .SelectMany(rank => (string[]) + [ + NameToken(rank), + ShortNameToken(rank), + AttributesToken(rank) + ]) + ]; + + public static Dictionary FromRankings(IEnumerable? rankings) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++) + { + map[NameToken(rank)] = null; + map[ShortNameToken(rank)] = null; + map[AttributesToken(rank)] = null; + } + + if (rankings is null) + return map; + + foreach (var ranking in rankings) + { + if (ranking.Rank < 1 || ranking.Rank > StudentEventRanking.MaxRank) + continue; + + var evt = ranking.EventDefinition; + map[NameToken(ranking.Rank)] = evt?.Name; + map[ShortNameToken(ranking.Rank)] = evt?.ShortName; + map[AttributesToken(ranking.Rank)] = EventAttributeMarks.For(evt); + } + + return map; + } +} diff --git a/Tests/Printing/NoteTemplateMerger_Tests.cs b/Tests/Printing/NoteTemplateMerger_Tests.cs index a20ec8e..a0d2a27 100644 --- a/Tests/Printing/NoteTemplateMerger_Tests.cs +++ b/Tests/Printing/NoteTemplateMerger_Tests.cs @@ -116,4 +116,53 @@ public class NoteTemplateMerger_Tests Assert.That(raw, Is.EqualTo($"x{NoteTemplateMerger.AnswerSpaceHtml}y")); Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.AnswerSpaceHtml)); } + + [Test] + public void Merge_RankedStudents_IsHtmlFragmentSentinel() + { + var result = NoteTemplateMerger.Merge( + "{{Name}}\n{{RankedStudents}}", + PrintTokenMap.Create()); + + Assert.That(result, Does.Contain(NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken))); + Assert.That(result, Does.Not.Contain("{{RankedStudents}}")); + } + + [Test] + public void ApplyLayout_ReplacesHtmlFragmentSentinel() + { + var sentinel = NoteTemplateMerger.HtmlFragmentSentinel(NoteTemplateMerger.RankedStudentsToken); + var fragments = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [NoteTemplateMerger.RankedStudentsToken] = "
Aria
" + }; + + var raw = NoteTemplateMerger.ApplyLayout($"x{sentinel}y", fragments); + var wrapped = NoteTemplateMerger.ApplyLayout($"

{sentinel}

", fragments); + var missing = NoteTemplateMerger.ApplyLayout($"x{sentinel}y"); + + Assert.That(raw, Is.EqualTo("x
Aria
y")); + Assert.That(wrapped, Is.EqualTo("
Aria
")); + Assert.That(missing, Is.EqualTo("xy")); + } + + [Test] + public void Merge_Legend_IsSentinel() + { + var result = NoteTemplateMerger.Merge("{{Legend}}", PrintTokenMap.Create()); + + Assert.That(result, Does.Contain(NoteTemplateMerger.LegendSentinel)); + Assert.That(result, Does.Not.Contain("{{Legend}}")); + } + + [Test] + public void ApplyLayout_ReplacesLegendSentinel() + { + var html = NoteTemplateMerger.ApplyLayout( + $"

{NoteTemplateMerger.LegendSentinel}

"); + + Assert.That(html, Does.Contain("print-badge-legend")); + Assert.That(html, Does.Contain(EventAttributeMarks.Individual)); + Assert.That(html, Does.Not.Contain("event-rank-1")); + } } diff --git a/Tests/Printing/PrintFieldCatalog_Tests.cs b/Tests/Printing/PrintFieldCatalog_Tests.cs index b181bd7..38cd95d 100644 --- a/Tests/Printing/PrintFieldCatalog_Tests.cs +++ b/Tests/Printing/PrintFieldCatalog_Tests.cs @@ -1,3 +1,4 @@ +using Core.Entities; using Core.Printing; namespace Tests.Printing; @@ -12,14 +13,34 @@ public class PrintFieldCatalog_Tests Assert.That(tokens, Does.Contain("FirstName")); Assert.That(tokens, Does.Contain("Chapter.ShortName")); + Assert.That(tokens, Does.Contain("Rank1")); + Assert.That(tokens, Does.Contain("Rank10.ShortName")); + Assert.That(tokens, Does.Contain("Rank1.Attributes")); + Assert.That(tokens, Does.Contain(NoteTemplateMerger.RankedEventsToken)); + Assert.That(tokens, Does.Not.Contain("Rank11")); Assert.That(tokens, Does.Not.Contain("Interview Time")); } + [Test] + public void StudentRanks_MatchesMaxRankAndStaysOffEntityTokens() + { + Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1")); + Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.ShortName")); + Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain("Rank1.Attributes")); + Assert.That(PrintFieldCatalog.StudentRanks, Does.Contain(NoteTemplateMerger.RankedEventsToken)); + Assert.That(PrintFieldCatalog.StudentRanks, Has.Length.EqualTo(1 + StudentEventRanking.MaxRank * 3)); + Assert.That(PrintFieldCatalog.EntityTokens(PrintEntityType.Student), Does.Not.Contain("Rank1")); + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Not.Contain("Rank1")); + } + [Test] public void BuiltInFor_TeamAndEventHaveExpectedNames() { Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName")); + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventAttributes")); Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("RegionalEvent")); + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken)); + Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes")); } [Test] @@ -27,6 +48,7 @@ public class PrintFieldCatalog_Tests { Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken)); Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken)); + Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.LegendToken)); } [Test] diff --git a/Tests/Printing/PrintRankBadgeHtml_Tests.cs b/Tests/Printing/PrintRankBadgeHtml_Tests.cs new file mode 100644 index 0000000..7b97856 --- /dev/null +++ b/Tests/Printing/PrintRankBadgeHtml_Tests.cs @@ -0,0 +1,120 @@ +using Core.Entities; +using Core.Printing; +using Tests.Builders; + +namespace Tests.Printing; + +[TestFixture] +public class PrintRankBadgeHtml_Tests +{ + [Test] + public void ForStudentEvents_Empty_IsEmpty() + { + Assert.That(PrintRankBadgeHtml.ForStudentEvents([]), Is.EqualTo(string.Empty)); + Assert.That(PrintRankBadgeHtml.ForStudentEvents(null), Is.EqualTo(string.Empty)); + } + + [Test] + public void ForStudentEvents_RendersShortNameDotAndAttributes() + { + var coding = EventDefinitionBuilder.Individual("Coding") + .WithShortName("Code") + .AsRegionalEvent() + .Build(); + var student = StudentBuilder.Create("Aria", "Cole") + .WithRanking(coding, 1) + .Build(); + + var html = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings); + + Assert.That(html, Does.Contain("print-rank-badges")); + Assert.That(html, Does.Contain("event-rank-1")); + Assert.That(html, Does.Contain("Code")); + Assert.That(html, Does.Contain(EventAttributeMarks.Individual)); + Assert.That(html, Does.Contain(EventAttributeMarks.Regional)); + Assert.That(html, Does.Not.Contain("{{")); + } + + [Test] + public void ForEventStudents_SortsByRankThenSeniority() + { + var evt = EventDefinitionBuilder.Individual("Coding").Build(); + var younger = StudentBuilder.Create("Bea", "Young").Build(); + younger.Grade = 9; + younger.TsaYear = 1; + var older = StudentBuilder.Create("Aria", "Cole").Build(); + older.Grade = 12; + older.TsaYear = 4; + + var rankings = new List + { + new() { Student = younger, EventDefinition = evt, Rank = 1 }, + new() { Student = older, EventDefinition = evt, Rank = 1 } + }; + + var html = PrintRankBadgeHtml.ForEventStudents(rankings); + var ariaAt = html.IndexOf("Aria", StringComparison.Ordinal); + var beaAt = html.IndexOf("Bea", StringComparison.Ordinal); + + Assert.That(ariaAt, Is.GreaterThanOrEqualTo(0)); + Assert.That(beaAt, Is.GreaterThan(ariaAt)); + } + + [Test] + public void EventAttributeMarks_IncludesEffortAndFlags() + { + var evt = EventDefinitionBuilder.Individual("Coding") + .AsOnSite() + .AsRegionalEvent() + .WithPresubmission() + .WithLevelOfEffort(2) + .Build(); + + var marks = EventAttributeMarks.For(evt); + + Assert.That(marks, Does.Contain(EventAttributeMarks.LevelOfEffort2)); + Assert.That(marks, Does.Contain(EventAttributeMarks.Individual)); + Assert.That(marks, Does.Contain(EventAttributeMarks.OnSite)); + Assert.That(marks, Does.Contain(EventAttributeMarks.Regional)); + Assert.That(marks, Does.Contain(EventAttributeMarks.Presubmission)); + } + + [Test] + public void EscapeHtml_MasksTags() + { + Assert.That(PrintTokenMap.EscapeHtml("A "), Is.EqualTo("A <b>")); + } + + [Test] + public void For_UsesTheSameMarksAsTheLegend() + { + var evt = EventDefinitionBuilder.Individual("Coding") + .AsOnSite() + .AsRegionalEvent() + .WithPresubmission() + .WithLevelOfEffort(2) + .Build(); + + var fromCatalog = string.Join( + " ", + EventAttributeMarks.LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol)); + + Assert.That(EventAttributeMarks.For(evt), Is.EqualTo(fromCatalog)); + } + + [Test] + public void Legend_IncludesRankDotsAndAttributeMarks() + { + var html = PrintRankBadgeHtml.Legend(); + + Assert.That(html, Does.Contain("print-badge-legend")); + Assert.That(html, Does.Contain("·")); + Assert.That(html, Does.Not.Contain(EventRankLegend.Ordinal(1))); + Assert.That(html, Does.Not.Contain("event-rank-1")); + foreach (var mark in EventAttributeMarks.LegendItems) + { + Assert.That(html, Does.Contain(mark.Symbol)); + Assert.That(html, Does.Contain(mark.Label)); + } + } +} diff --git a/Tests/Printing/PrintTokenMap_Tests.cs b/Tests/Printing/PrintTokenMap_Tests.cs index 57aba76..3c1abb3 100644 --- a/Tests/Printing/PrintTokenMap_Tests.cs +++ b/Tests/Printing/PrintTokenMap_Tests.cs @@ -16,6 +16,17 @@ public class PrintTokenMap_Tests Assert.That(map["Grade"], Is.EqualTo("9")); } + [Test] + public void Build_BuiltInRankTokenWinsOverImportedSameName() + { + var map = PrintTokenMap.Build( + new Dictionary { ["Rank1"] = "imported" }, + new Dictionary { ["Rank1"] = "Coding" }, + null); + + Assert.That(map["Rank1"], Is.EqualTo("Coding")); + } + [Test] public void Build_IncludesAllImportedCatalogKeys() { @@ -56,4 +67,39 @@ public class PrintTokenMap_Tests Assert.That(merged, Is.EqualTo("Hi A\\*ria")); } + + [Test] + public void Escape_FlattensNewlinesSoTableRowsStayIntact() + { + var escaped = PrintTokenMap.Escape("Drone Challenge (UAV)\r\n\r\n"); + + Assert.That(escaped, Is.EqualTo("Drone Challenge (UAV)")); + Assert.That(escaped, Does.Not.Contain('\n')); + Assert.That(escaped, Does.Not.Contain('\r')); + } + + [Test] + public void Escape_EscapesPipeForMarkdownTables() + { + Assert.That(PrintTokenMap.Escape("A | B"), Is.EqualTo("A \\| B")); + } + + [Test] + public void Merge_DroneNameWithTrailingNewlines_StaysOnOneTableRow() + { + var map = PrintTokenMap.Build( + null, + new Dictionary + { + ["Rank1"] = "Drone Challenge (UAV)\n\n", + ["Rank2"] = "Off the Grid" + }, + null); + + var merged = NoteTemplateMerger.Merge( + "| {{Rank1}} | {{Rank2}} |\n| --- | --- |", + map); + + Assert.That(merged, Is.EqualTo("| Drone Challenge (UAV) | Off the Grid |\n| --- | --- |")); + } } diff --git a/Tests/Printing/StudentRankTokens_Tests.cs b/Tests/Printing/StudentRankTokens_Tests.cs new file mode 100644 index 0000000..210d542 --- /dev/null +++ b/Tests/Printing/StudentRankTokens_Tests.cs @@ -0,0 +1,88 @@ +using Core.Entities; +using Core.Printing; +using Tests.Builders; + +namespace Tests.Printing; + +[TestFixture] +public class StudentRankTokens_Tests +{ + [Test] + public void FromRankings_AlwaysIncludesEveryRankThroughMax() + { + var map = StudentRankTokens.FromRankings([]); + + for (var rank = 1; rank <= StudentEventRanking.MaxRank; rank++) + { + Assert.That(map.ContainsKey(StudentRankTokens.NameToken(rank)), Is.True); + Assert.That(map.ContainsKey(StudentRankTokens.ShortNameToken(rank)), Is.True); + Assert.That(map[StudentRankTokens.NameToken(rank)], Is.Null); + } + } + + [Test] + public void FromRankings_FillsNameAndShortNameForPresentRanks() + { + var coding = EventDefinitionBuilder.Individual("Coding") + .WithShortName("Code") + .Build(); + var flight = EventDefinitionBuilder.Individual("Flight Endurance") + .WithShortName("Flight") + .Build(); + var student = StudentBuilder.Create("Aria", "Cole") + .WithRanking(coding, 1) + .WithRanking(flight, 3) + .Build(); + + var map = StudentRankTokens.FromRankings(student.EventRankings); + + Assert.That(map["Rank1"], Is.EqualTo("Coding")); + Assert.That(map["Rank1.ShortName"], Is.EqualTo("Code")); + Assert.That(map["Rank1.Attributes"], Is.EqualTo(EventAttributeMarks.For(coding))); + Assert.That(map["Rank3"], Is.EqualTo("Flight Endurance")); + Assert.That(map["Rank3.ShortName"], Is.EqualTo("Flight")); + Assert.That(map["Rank2"], Is.Null); + Assert.That(map["Rank2.Attributes"], Is.Null); + } + + [Test] + public void FromRankings_IgnoresRanksOutsideOneToMax() + { + var evt = EventDefinitionBuilder.Individual("Coding").Build(); + var rankings = new List + { + new() { EventDefinition = evt, Rank = 0 }, + new() { EventDefinition = evt, Rank = StudentEventRanking.MaxRank + 1 } + }; + + var map = StudentRankTokens.FromRankings(rankings); + + Assert.That(map["Rank1"], Is.Null); + } + + [Test] + public void Merge_KnownEmptyRankPrintsBlank() + { + var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings([]), null); + + var merged = NoteTemplateMerger.Merge("1. {{Rank1}}", map); + + Assert.That(merged, Is.EqualTo("1. ")); + } + + [Test] + public void Merge_ReplacesRankTokens() + { + var evt = EventDefinitionBuilder.Individual("Video Game Design") + .WithShortName("VGD") + .Build(); + var student = StudentBuilder.Create("Aria", "Cole") + .WithRanking(evt, 1) + .Build(); + var map = PrintTokenMap.Build(null, StudentRankTokens.FromRankings(student.EventRankings), null); + + var merged = NoteTemplateMerger.Merge("{{Rank1}} ({{Rank1.ShortName}})", map); + + Assert.That(merged, Is.EqualTo("Video Game Design (VGD)")); + } +} diff --git a/WebApp/Components/Features/Print/PagePrinter.razor b/WebApp/Components/Features/Print/PagePrinter.razor index 20d221e..da281fc 100644 --- a/WebApp/Components/Features/Print/PagePrinter.razor +++ b/WebApp/Components/Features/Print/PagePrinter.razor @@ -313,6 +313,8 @@ else yield return ("Layout", PrintFieldCatalog.Layout); yield return ("Chapter", PrintFieldCatalog.Chapter); yield return (_entityType.ToString(), PrintFieldCatalog.EntityTokens(_entityType)); + if (_entityType == PrintEntityType.Student) + yield return ("Event ranks", PrintFieldCatalog.StudentRanks); if (_entityType == PrintEntityType.Student && _importedTokenNames.Count > 0) yield return ("Additional fields", _importedTokenNames); } diff --git a/WebApp/Components/Pages/Legend.razor b/WebApp/Components/Pages/Legend.razor index 868882d..7f9a47f 100644 --- a/WebApp/Components/Pages/Legend.razor +++ b/WebApp/Components/Pages/Legend.razor @@ -1,15 +1,14 @@ -@using WebApp.Models +@using Core.Printing +@using WebApp.Models

Legend

    -
  • @AppIcons.LevelOfEffortIcon(1) - Level of Effort
  • -
  • @AppIcons.IndividualEvent - Individual Event
  • -
  • @AppIcons.RegionalEvent - Regional
  • -
  • @AppIcons.OnSiteActivity - On-site Activity
  • -
  • @AppIcons.PresubmissionEvent - Pre-submission
  • -
  • @AppIcons.PresentationEvent - Interview Or Presentation
  • + @foreach (var mark in EventAttributeMarks.LegendItems) + { +
  • @mark.Symbol - @mark.Label
  • + }
-
\ No newline at end of file + diff --git a/WebApp/Models/AppIcons.cs b/WebApp/Models/AppIcons.cs index b889659..fd620a0 100644 --- a/WebApp/Models/AppIcons.cs +++ b/WebApp/Models/AppIcons.cs @@ -1,4 +1,5 @@ using Core.Entities; +using Core.Printing; using MudBlazor; namespace WebApp.Models @@ -14,50 +15,26 @@ namespace WebApp.Models public static string Captain = Icons.Material.Filled.Star; public static string Registration = Icons.Material.Filled.AppRegistration; public static string EventCalendar = Icons.Material.Filled.Event; - public static string LevelOfEffortIcon(int? loe) - { - - return loe switch + public static string LevelOfEffortIcon(int? loe) => + loe switch { - 1 => "○", - 2 => "◐", - 3 => "⬤", + 1 => EventAttributeMarks.LevelOfEffort1, + 2 => EventAttributeMarks.LevelOfEffort2, + 3 => EventAttributeMarks.LevelOfEffort3, _ => Icons.Material.Filled.QuestionMark }; - } - /*https://unicodeplus.com/search*/ - public static string OnSiteActivity = "ⓐ"; - public static string RegionalEvent = "ⓡ"; - public static string IndividualEvent = "ⓘ"; - public static string PresubmissionEvent = "ⓟ"; - public static string PresentationEvent = ""; + public static string OnSiteActivity => EventAttributeMarks.OnSite; + public static string RegionalEvent => EventAttributeMarks.Regional; + public static string IndividualEvent => EventAttributeMarks.Individual; + public static string PresubmissionEvent => EventAttributeMarks.Presubmission; + public static string PresentationEvent => ""; - // Tooltip mapping for icon unicode characters - public static Dictionary IconTooltips => new() - { - { OnSiteActivity, "On-Site Activity" }, - { RegionalEvent, "Regional Event" }, - { IndividualEvent, "Individual Event" }, - { PresubmissionEvent, "Presubmission" }, - { PresentationEvent, "Presentation/Interview" }, - { "○", "Level of Effort: 1" }, - { "◐", "Level of Effort: 2" }, - { "●", "Level of Effort: 3" } - }; + public static IReadOnlyDictionary IconTooltips { get; } = + EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal); - // Color mapping for icon unicode characters - public static Dictionary IconColors => new() - { - { OnSiteActivity, "#ff9800" }, // Orange - { RegionalEvent, "#2196f3" }, // Blue - { IndividualEvent, "#9c27b0" }, // Purple - { PresubmissionEvent, "#4caf50" }, // Green - { PresentationEvent, "#f44336" }, // Red - { "○", "#757575" }, // Gray - { "◐", "#616161" }, // Darker Gray - { "●", "#424242" } // Even Darker Gray - }; + public static IReadOnlyDictionary IconColors { get; } = + EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Color, StringComparer.Ordinal); public static string EventEffort(EventDefinition eventDefinition) { @@ -96,63 +73,19 @@ namespace WebApp.Models }; } - public static string RankedEventColor(int rank) - { - return rank switch - { - 1 => "#dd7e6b", - 2 => "#ea9999", - 3 => "#f9cb9c", - 4 => "#ffe599", - 5 => "#fff2cc", - 6 => "#fffaea", - 7 => "#fffefa", - 8 => "#fffefc", - 9 => "#fffffd", - 10 => "#fffffe", - _ => "#ddd" - }; - } + public static string RankedEventColor(int rank) => EventRankLegend.ColorHex(rank); - public static string GetOrdinal(int num) - { - if (num <= 0) return num.ToString(); - - switch (num % 100) - { - case 11: - case 12: - case 13: - return num + "th"; - } - - switch (num % 10) - { - case 1: - return num + "st"; - case 2: - return num + "nd"; - case 3: - return num + "rd"; - default: - return num + "th"; - } - } + public static string GetOrdinal(int num) => EventRankLegend.Ordinal(num); public static string GetOrdinalSuperscript(int number) { - var suffix = number switch - { - 11 or 12 or 13 => "th", - _ => (number % 10) switch - { - 1 => "st", - 2 => "nd", - 3 => "rd", - _ => "th" - } - }; - return $"{number}{suffix}"; + var ordinal = EventRankLegend.Ordinal(number); + var suffixAt = 0; + while (suffixAt < ordinal.Length && (char.IsDigit(ordinal[suffixAt]) || ordinal[suffixAt] == '-')) + suffixAt++; + return suffixAt is 0 || suffixAt == ordinal.Length + ? ordinal + : $"{ordinal[..suffixAt]}{ordinal[suffixAt..]}"; } /// diff --git a/WebApp/Services/NotePrintService.cs b/WebApp/Services/NotePrintService.cs index a59eb28..051fcde 100644 --- a/WebApp/Services/NotePrintService.cs +++ b/WebApp/Services/NotePrintService.cs @@ -58,6 +58,8 @@ public class NotePrintService : INotePrintService query = query.Where(s => s.OfficerRole == null); var students = await query + .Include(s => s.EventRankings) + .ThenInclude(r => r.EventDefinition) .OrderBy(s => s.LastName) .ThenBy(s => s.FirstName) .ToListAsync(cancellationToken); @@ -79,7 +81,8 @@ public class NotePrintService : INotePrintService template, row.Imported, StudentTokens(row.Student), - chapter)) + chapter, + StudentHtmlFragments(row.Student))) ]; } @@ -140,9 +143,25 @@ public class NotePrintService : INotePrintService .OrderBy(e => e.Name) .ToListAsync(cancellationToken); + var rankings = await _context.StudentEventRanking + .AsNoTracking() + .Include(r => r.Student) + .Include(r => r.EventDefinition) + .ToListAsync(cancellationToken); + + var rankingsByEventId = rankings + .GroupBy(r => r.EventDefinition.Id) + .ToDictionary(g => g.Key, g => g.ToList()); + return [ - .. events.Select(evt => MergePage(evt.Name, template, null, EventTokens(evt), chapter)) + .. events.Select(evt => MergePage( + evt.Name, + template, + null, + EventTokens(evt), + chapter, + EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id)))) ]; } @@ -151,16 +170,31 @@ public class NotePrintService : INotePrintService string template, Dictionary? imported, Dictionary entity, - Dictionary chapter) + Dictionary chapter, + IReadOnlyDictionary? htmlFragments = null) { var map = PrintTokenMap.Build(imported, entity, chapter); return new NotePrintPage { DisplayName = displayName, - Html = NoteTemplateMerger.ApplyLayout(MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map))) + Html = NoteTemplateMerger.ApplyLayout( + MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)), + htmlFragments) }; } + private static Dictionary StudentHtmlFragments(Student student) => + new(StringComparer.OrdinalIgnoreCase) + { + [NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings) + }; + + private static Dictionary EventHtmlFragments(IReadOnlyList? rankings) => + new(StringComparer.OrdinalIgnoreCase) + { + [NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings) + }; + private Dictionary ChapterTokens() { var settings = ChapterSettings.FromConfiguration(_configuration); @@ -174,8 +208,9 @@ public class NotePrintService : INotePrintService }; } - private static Dictionary StudentTokens(Student student) => - new(StringComparer.OrdinalIgnoreCase) + private static Dictionary StudentTokens(Student student) + { + var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["FirstName"] = student.FirstName, ["LastName"] = student.LastName, @@ -191,6 +226,12 @@ public class NotePrintService : INotePrintService ["OfficerRole"] = student.OfficerRole?.ToString() }; + foreach (var (key, value) in StudentRankTokens.FromRankings(student.EventRankings)) + tokens[key] = value; + + return tokens; + } + private static Dictionary TeamTokens(Team team) { var evt = team.Event; @@ -222,7 +263,8 @@ public class NotePrintService : INotePrintService ["TeamSize"] = evt?.TeamSize, ["Eligibility"] = evt?.Eligibility, ["Description"] = evt?.Description, - ["Theme"] = evt?.Theme + ["Theme"] = evt?.Theme, + ["EventAttributes"] = EventAttributeMarks.For(evt) }; private static Dictionary ImportedTokens( diff --git a/WebApp/wwwroot/app.css b/WebApp/wwwroot/app.css index 047394b..426a23d 100644 --- a/WebApp/wwwroot/app.css +++ b/WebApp/wwwroot/app.css @@ -61,6 +61,7 @@ } .print-answer-space { + height: calc(var(--print-answer-lines, 3) * 1.35em); background-image: repeating-linear-gradient( to bottom, transparent, @@ -88,6 +89,10 @@ .event-rank-4 { background-color: #ffe599; } .event-rank-5 { background-color: #fff2cc; } .event-rank-6 { background-color: #fffaea; } +.event-rank-7 { background-color: #fffefa; } +.event-rank-8 { background-color: #fffefc; } +.event-rank-9 { background-color: #fffffd; } +.event-rank-10 { background-color: #fffffe; } .pre-wrap-text { @@ -390,9 +395,52 @@ font-size: 0.85em; } +.print-rank-badges { + margin: 0.2em 0 0.5em; + line-height: 1.6; +} + +.print-rank-badge { + display: inline-block; + white-space: nowrap; + line-height: 1.2; +} + +.print-rank-dot { + display: inline-block; + width: 0.65em; + height: 0.65em; + border-radius: 50%; + flex-shrink: 0; + background: #ddd; +} + +.print-event-attrs { + font-family: monospace; + white-space: pre; +} + +.print-badge-legend { + margin: 0.4em 0 0.9em; + font-size: 0.85em; +} + +.print-attr-legend { + margin-top: 0.25em; + line-height: 1.6; +} + +.print-legend-mark { + display: inline-block; + white-space: nowrap; +} + .print-answer-space { - min-height: calc(var(--print-answer-lines, 3) * 1.35em); - margin: 0.2em 0 0.75em; + display: block; + height: calc(var(--print-answer-lines, 3) * 1.35em); + overflow: hidden; + margin: 0.35em 0 0.75em; + box-sizing: content-box; background-image: repeating-linear-gradient( to bottom, transparent, diff --git a/docs/instructions/page-printer.md b/docs/instructions/page-printer.md index 17b3c7d..ea52c8d 100644 --- a/docs/instructions/page-printer.md +++ b/docs/instructions/page-printer.md @@ -21,13 +21,44 @@ Create a standalone note on **Notes** (not a page note or student note). Use `{{ 1. Why did you join TSA? 2. What events interest you? + +**Event preferences** + +| 1st | 2nd | 3rd | 4th | 5th | 6th | +| --- | --- | --- | --- | --- | --- | +| {{Rank1}} | {{Rank2}} | {{Rank3}} | {{Rank4}} | {{Rank5}} | {{Rank6}} | +| {{Rank1.Attributes}} | {{Rank2.Attributes}} | {{Rank3.Attributes}} | {{Rank4.Attributes}} | {{Rank5.Attributes}} | {{Rank6.Attributes}} | + +{{Legend}} ``` -Unknown tokens stay visible so typos are obvious. Empty values (including a missing Interview Time) print blank. +Or use `{{RankedEvents}}` for the ranking-index badge row instead of the table. + +Unknown tokens stay visible so typos are obvious. Empty values (including a missing Interview Time or an unset rank) print blank. + +Student pages also have event-rank tokens from **Student Event Ranks**. See [event-ranking-import.md](event-ranking-import.md). + +- `{{RankedEvents}}` prints that student’s preferences as ranking-index badges (colored rank dot, short name, attribute marks). +- `{{Rank1}}` through `{{Rank10}}` print the official event name at that preference. +- `{{Rank1.ShortName}}` / `{{Rank1.Attributes}}` (through 10) print the catalog short name and the same attribute marks shown on **Student Event Ranks** (level of effort, individual, on-site, regional, presubmission). + +Event pages can put the matching student list under the event name: + +```markdown +# {{Name}} + +{{EventAttributes}} + +{{RankedStudents}} +``` + +`{{RankedStudents}}` prints everyone who ranked that event as badges (colored rank dot and first name), same sort as **Events by Student** on the ranking index: rank, then grade + TSA year. `{{EventAttributes}}` is also available on team pages. + +Put `{{Legend}}` where you want the attribute-mark key (same marks as the Teams printout legend). Put `{{PageBreak}}` on its own line to force a new printed sheet **inside** one record (for example, questions on page 1 and a scoring rubric on page 2). -Put `{{AnswerSpace}}` where you want ruled write-in lines. Markdown collapses blank lines, so extra empty lines in the note will not leave room to write. +Put `{{AnswerSpace}}` where you want ruled write-in lines. Markdown collapses blank lines, so extra empty lines in the note will not leave room to write. **Answer lines** on the printer page sets the height of each token; add another `{{AnswerSpace}}` to stack a second block. Additional-field tokens use the same names as the Students index columns (for example `{{Interview Time}}`). Those values come from each student's `#Student:{id}` `## Additional fields` table. See [student-notes-import.md](student-notes-import.md). diff --git a/docs/plans/page-printer.md b/docs/plans/page-printer.md index 8b45f12..2a73016 100644 --- a/docs/plans/page-printer.md +++ b/docs/plans/page-printer.md @@ -11,4 +11,7 @@ User-facing steps: [docs/instructions/page-printer.md](../instructions/page-prin - Core merge: `{{tokens}}`, `{{PageBreak}}`, `{{AnswerSpace}}`, print presets JSON - `/print` UI: entity filters, font size, answer-space lines, Preview/Print, save/load/update/delete presets - Student pages sort by last name, then first name +- Student event-rank tokens: `{{Rank1}}`–`{{Rank10}}`, `.ShortName` / `.Attributes`, and `{{RankedEvents}}` badges +- Event `{{RankedStudents}}` badges (students who ranked the event) and `{{EventAttributes}}` +- `{{Legend}}` from the shared attribute-mark catalog (`EventAttributeMarks`) - EF migration `AddPrintPresets` (applied on next app start)