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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 23:03:10 -04:00
co-authored by Cursor
parent 3f50d6e635
commit 3712dba974
19 changed files with 817 additions and 118 deletions
+45
View File
@@ -0,0 +1,45 @@
using Core.Entities;
namespace Core.Printing;
public sealed record EventMark(
string Symbol,
string Label,
string Color,
Func<EventDefinition, bool> Applies);
/// <summary>
/// Compact event-attribute marks used on the event-ranking index chip,
/// print badges, and the shared legend.
/// </summary>
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<EventMark> 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));
}
}
+53
View File
@@ -0,0 +1,53 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Rank labels and colors shared by the ranking index, print badges, and legend.
/// </summary>
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"
};
}
+40 -3
View File
@@ -7,6 +7,8 @@ namespace Core.Printing;
/// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space after HTML conversion.
/// <c>{{RankedEvents}}</c> and <c>{{RankedStudents}}</c> become ranking-index badges.
/// <c>{{Legend}}</c> becomes the shared attribute-mark legend.
/// </summary>
public static class NoteTemplateMerger
{
@@ -16,14 +18,29 @@ public static class NoteTemplateMerger
public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\"></div>";
public const string AnswerSpaceHtml = "<div class=\"print-answer-space\">&nbsp;</div>";
public const string LegendToken = "Legend";
public const string LegendSentinel = "<!--tsa-legend-->";
public const string RankedEventsToken = "RankedEvents";
public const string RankedStudentsToken = "RankedStudents";
public static readonly string[] HtmlFragmentTokens =
[
RankedEventsToken,
RankedStudentsToken
];
public static string HtmlFragmentSentinel(string name) => $"<!--tsa-html:{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
/// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary>
public static string ApplyLayout(string? html)
public static string ApplyLayout(
string? html,
IReadOnlyDictionary<string, string>? 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($"<p>{sentinel}</p>", fragment, StringComparison.Ordinal)
.Replace(sentinel, fragment, StringComparison.Ordinal);
}
return html;
}
}
+18 -4
View File
@@ -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"
];
/// <summary>
/// <c>Rank1</c>…<c>Rank10</c> and matching <c>.ShortName</c> tokens from
/// <see cref="StudentRankTokens"/>.
/// </summary>
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<string> EntityTokens(PrintEntityType entityType) =>
@@ -75,5 +85,9 @@ public static class PrintFieldCatalog
};
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) =>
[.. Chapter, .. EntityTokens(entityType)];
entityType switch
{
PrintEntityType.Student => [.. Chapter, .. Student, .. StudentRanks],
_ => [.. Chapter, .. EntityTokens(entityType)]
};
}
+89
View File
@@ -0,0 +1,89 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Ranking-index style badges: colored rank dot plus a short label.
/// Student pages list ranked events; event pages list students who ranked them.
/// </summary>
public static class PrintRankBadgeHtml
{
public static string ForStudentEvents(IEnumerable<StudentEventRanking>? 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<StudentEventRanking>? 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
: $"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(attributes)}</span>";
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
$"<span class=\"print-rank-badge\">" +
$"<span class=\"print-rank-dot event-rank-{rank}\"></span> " +
$"{labelHtml}{extra}" +
"</span>";
}
public static string Legend()
{
var marks = EventAttributeMarks.LegendItems
.Select(mark =>
"<span class=\"print-legend-mark\">" +
$"<span class=\"print-event-attrs\">{PrintTokenMap.EscapeHtml(mark.Symbol)}</span> " +
PrintTokenMap.EscapeHtml(mark.Label) +
"</span>");
return
"<div class=\"print-badge-legend\">" +
$"<div class=\"print-attr-legend\">{string.Join(" &middot; ", marks)}</div>" +
"</div>";
}
private static string Wrap(IReadOnlyList<string> badges, string separator = " ")
{
if (badges.Count == 0)
return string.Empty;
return $"<div class=\"print-rank-badges\">{string.Join(separator, badges)}</div>";
}
}
+22 -1
View File
@@ -36,14 +36,16 @@ public static class PrintTokenMap
/// <summary>
/// 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.
/// </summary>
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("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal);
}
/// <summary>
/// Escapes a value for insertion into generated print HTML (badge labels).
/// </summary>
public static string EscapeHtml(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return FlattenLines(value)
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal);
}
private static string FlattenLines(string value) =>
string.Join(' ',
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
+57
View File
@@ -0,0 +1,57 @@
using Core.Entities;
namespace Core.Printing;
/// <summary>
/// Per-student event-rank merge tokens. <c>Rank1</c> is the official event name
/// at rank 1; <c>Rank1.ShortName</c> is the catalog short name. Every rank
/// through <see cref="StudentEventRanking.MaxRank"/> is always a map key so a
/// missing preference prints blank instead of leaving <c>{{Rank5}}</c> visible.
/// </summary>
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<string> AllNames { get; } =
[
NoteTemplateMerger.RankedEventsToken,
.. Enumerable.Range(1, StudentEventRanking.MaxRank)
.SelectMany(rank => (string[])
[
NameToken(rank),
ShortNameToken(rank),
AttributesToken(rank)
])
];
public static Dictionary<string, string?> FromRankings(IEnumerable<StudentEventRanking>? rankings)
{
var map = new Dictionary<string, string?>(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;
}
}
@@ -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<string, string>(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = "<div class=\"print-rank-badges\">Aria</div>"
};
var raw = NoteTemplateMerger.ApplyLayout($"x{sentinel}y", fragments);
var wrapped = NoteTemplateMerger.ApplyLayout($"<p>{sentinel}</p>", fragments);
var missing = NoteTemplateMerger.ApplyLayout($"x{sentinel}y");
Assert.That(raw, Is.EqualTo("x<div class=\"print-rank-badges\">Aria</div>y"));
Assert.That(wrapped, Is.EqualTo("<div class=\"print-rank-badges\">Aria</div>"));
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(
$"<p>{NoteTemplateMerger.LegendSentinel}</p>");
Assert.That(html, Does.Contain("print-badge-legend"));
Assert.That(html, Does.Contain(EventAttributeMarks.Individual));
Assert.That(html, Does.Not.Contain("event-rank-1"));
}
}
+22
View File
@@ -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]
+120
View File
@@ -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<StudentEventRanking>
{
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 <b>"), Is.EqualTo("A &lt;b&gt;"));
}
[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("&middot;"));
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));
}
}
}
+46
View File
@@ -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<string, string?> { ["Rank1"] = "imported" },
new Dictionary<string, string?> { ["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<string, string?>
{
["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| --- | --- |"));
}
}
+88
View File
@@ -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<StudentEventRanking>
{
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)"));
}
}
@@ -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);
}
+7 -8
View File
@@ -1,15 +1,14 @@
@using WebApp.Models
@using Core.Printing
@using WebApp.Models
<MudPaper>
<h3>Legend</h3>
<MudContainer>
<ul>
<li>@AppIcons.LevelOfEffortIcon(1) - Level of Effort </li>
<li>@AppIcons.IndividualEvent - Individual Event </li>
<li>@AppIcons.RegionalEvent - Regional </li>
<li>@AppIcons.OnSiteActivity - On-site Activity</li>
<li>@AppIcons.PresubmissionEvent - Pre-submission</li>
<li>@AppIcons.PresentationEvent - Interview Or Presentation</li>
@foreach (var mark in EventAttributeMarks.LegendItems)
{
<li>@mark.Symbol - @mark.Label</li>
}
</ul>
</MudContainer>
</MudPaper>
</MudPaper>
+24 -91
View File
@@ -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<string, string> 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<string, string> IconTooltips { get; } =
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal);
// Color mapping for icon unicode characters
public static Dictionary<string, string> 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<string, string> 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}<sup>{suffix}</sup>";
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]}<sup>{ordinal[suffixAt..]}</sup>";
}
/// <summary>
+49 -7
View File
@@ -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<string, string?>? imported,
Dictionary<string, string?> entity,
Dictionary<string, string?> chapter)
Dictionary<string, string?> chapter,
IReadOnlyDictionary<string, string>? 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<string, string> StudentHtmlFragments(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings)
};
private static Dictionary<string, string> EventHtmlFragments(IReadOnlyList<StudentEventRanking>? rankings) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings)
};
private Dictionary<string, string?> ChapterTokens()
{
var settings = ChapterSettings.FromConfiguration(_configuration);
@@ -174,8 +208,9 @@ public class NotePrintService : INotePrintService
};
}
private static Dictionary<string, string?> StudentTokens(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
private static Dictionary<string, string?> StudentTokens(Student student)
{
var tokens = new Dictionary<string, string?>(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<string, string?> 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<string, string?> ImportedTokens(
+50 -2
View File
@@ -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,
+33 -2
View File
@@ -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).
+3
View File
@@ -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)