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. /// Unknown tokens are left unchanged. Known empty values become blank.
/// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion. /// <c>{{PageBreak}}</c> becomes a print page break after HTML conversion.
/// <c>{{AnswerSpace}}</c> becomes ruled write-in space 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> /// </summary>
public static class NoteTemplateMerger public static class NoteTemplateMerger
{ {
@@ -16,14 +18,29 @@ public static class NoteTemplateMerger
public const string AnswerSpaceToken = "AnswerSpace"; public const string AnswerSpaceToken = "AnswerSpace";
public const string AnswerSpaceSentinel = "<!--tsa-answer-space-->"; 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 readonly record struct LayoutToken(string Name, string Sentinel, string Html);
private static readonly LayoutToken[] LayoutTokens = private static readonly LayoutToken[] LayoutTokens =
[ [
new(PageBreakToken, PageBreakSentinel, PageBreakHtml), 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); private static readonly Regex TokenRegex = new(@"\{\{([^}]+)\}\}", RegexOptions.Compiled);
@@ -47,6 +64,12 @@ public static class NoteTemplateMerger
return layout.Sentinel; return layout.Sentinel;
} }
foreach (var htmlName in HtmlFragmentTokens)
{
if (key.Equals(htmlName, StringComparison.OrdinalIgnoreCase))
return HtmlFragmentSentinel(htmlName);
}
return tokens.TryGetValue(key, out var value) return tokens.TryGetValue(key, out var value)
? value ?? string.Empty ? value ?? string.Empty
: match.Value; : match.Value;
@@ -56,7 +79,9 @@ public static class NoteTemplateMerger
/// <summary> /// <summary>
/// Turns layout sentinels into HTML after markdown has been rendered. /// Turns layout sentinels into HTML after markdown has been rendered.
/// </summary> /// </summary>
public static string ApplyLayout(string? html) public static string ApplyLayout(
string? html,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{ {
if (string.IsNullOrEmpty(html)) if (string.IsNullOrEmpty(html))
return string.Empty; return string.Empty;
@@ -68,6 +93,18 @@ public static class NoteTemplateMerger
.Replace(layout.Sentinel, layout.Html, StringComparison.Ordinal); .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; return html;
} }
} }
+18 -4
View File
@@ -8,7 +8,8 @@ public static class PrintFieldCatalog
public static readonly string[] Layout = public static readonly string[] Layout =
[ [
NoteTemplateMerger.PageBreakToken, NoteTemplateMerger.PageBreakToken,
NoteTemplateMerger.AnswerSpaceToken NoteTemplateMerger.AnswerSpaceToken,
NoteTemplateMerger.LegendToken
]; ];
public static readonly string[] Chapter = public static readonly string[] Chapter =
@@ -36,6 +37,12 @@ public static class PrintFieldCatalog
"OfficerRole" "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 = public static readonly string[] Team =
[ [
"Identifier", "Identifier",
@@ -46,7 +53,8 @@ public static class PrintFieldCatalog
"TeamSize", "TeamSize",
"Eligibility", "Eligibility",
"Description", "Description",
"Theme" "Theme",
"EventAttributes"
]; ];
public static readonly string[] Event = public static readonly string[] Event =
@@ -62,7 +70,9 @@ public static class PrintFieldCatalog
"Description", "Description",
"Theme", "Theme",
"Documentation", "Documentation",
"Notes" "Notes",
"EventAttributes",
NoteTemplateMerger.RankedStudentsToken
]; ];
public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) => public static IReadOnlyList<string> EntityTokens(PrintEntityType entityType) =>
@@ -75,5 +85,9 @@ public static class PrintFieldCatalog
}; };
public static IReadOnlyList<string> BuiltInFor(PrintEntityType entityType) => 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> /// <summary>
/// Treats substituted values as plain text so they cannot change markdown or inject HTML. /// 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> /// </summary>
public static string Escape(string? value) public static string Escape(string? value)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
return string.Empty; return string.Empty;
return value return FlattenLines(value)
.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("|", "\\|", StringComparison.Ordinal)
.Replace("*", "\\*", StringComparison.Ordinal) .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("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", 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(raw, Is.EqualTo($"x{NoteTemplateMerger.AnswerSpaceHtml}y"));
Assert.That(wrapped, Is.EqualTo(NoteTemplateMerger.AnswerSpaceHtml)); 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; using Core.Printing;
namespace Tests.Printing; namespace Tests.Printing;
@@ -12,14 +13,34 @@ public class PrintFieldCatalog_Tests
Assert.That(tokens, Does.Contain("FirstName")); Assert.That(tokens, Does.Contain("FirstName"));
Assert.That(tokens, Does.Contain("Chapter.ShortName")); 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")); 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] [Test]
public void BuiltInFor_TeamAndEventHaveExpectedNames() public void BuiltInFor_TeamAndEventHaveExpectedNames()
{ {
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Team), Does.Contain("EventName")); 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("RegionalEvent"));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain(NoteTemplateMerger.RankedStudentsToken));
Assert.That(PrintFieldCatalog.BuiltInFor(PrintEntityType.Event), Does.Contain("EventAttributes"));
} }
[Test] [Test]
@@ -27,6 +48,7 @@ public class PrintFieldCatalog_Tests
{ {
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken)); Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.PageBreakToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken)); Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.AnswerSpaceToken));
Assert.That(PrintFieldCatalog.Layout, Does.Contain(NoteTemplateMerger.LegendToken));
} }
[Test] [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")); 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] [Test]
public void Build_IncludesAllImportedCatalogKeys() public void Build_IncludesAllImportedCatalogKeys()
{ {
@@ -56,4 +67,39 @@ public class PrintTokenMap_Tests
Assert.That(merged, Is.EqualTo("Hi A\\*ria")); 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 ("Layout", PrintFieldCatalog.Layout);
yield return ("Chapter", PrintFieldCatalog.Chapter); yield return ("Chapter", PrintFieldCatalog.Chapter);
yield return (_entityType.ToString(), PrintFieldCatalog.EntityTokens(_entityType)); yield return (_entityType.ToString(), PrintFieldCatalog.EntityTokens(_entityType));
if (_entityType == PrintEntityType.Student)
yield return ("Event ranks", PrintFieldCatalog.StudentRanks);
if (_entityType == PrintEntityType.Student && _importedTokenNames.Count > 0) if (_entityType == PrintEntityType.Student && _importedTokenNames.Count > 0)
yield return ("Additional fields", _importedTokenNames); yield return ("Additional fields", _importedTokenNames);
} }
+6 -7
View File
@@ -1,15 +1,14 @@
@using WebApp.Models @using Core.Printing
@using WebApp.Models
<MudPaper> <MudPaper>
<h3>Legend</h3> <h3>Legend</h3>
<MudContainer> <MudContainer>
<ul> <ul>
<li>@AppIcons.LevelOfEffortIcon(1) - Level of Effort </li> @foreach (var mark in EventAttributeMarks.LegendItems)
<li>@AppIcons.IndividualEvent - Individual Event </li> {
<li>@AppIcons.RegionalEvent - Regional </li> <li>@mark.Symbol - @mark.Label</li>
<li>@AppIcons.OnSiteActivity - On-site Activity</li> }
<li>@AppIcons.PresubmissionEvent - Pre-submission</li>
<li>@AppIcons.PresentationEvent - Interview Or Presentation</li>
</ul> </ul>
</MudContainer> </MudContainer>
</MudPaper> </MudPaper>
+24 -91
View File
@@ -1,4 +1,5 @@
using Core.Entities; using Core.Entities;
using Core.Printing;
using MudBlazor; using MudBlazor;
namespace WebApp.Models namespace WebApp.Models
@@ -14,50 +15,26 @@ namespace WebApp.Models
public static string Captain = Icons.Material.Filled.Star; public static string Captain = Icons.Material.Filled.Star;
public static string Registration = Icons.Material.Filled.AppRegistration; public static string Registration = Icons.Material.Filled.AppRegistration;
public static string EventCalendar = Icons.Material.Filled.Event; public static string EventCalendar = Icons.Material.Filled.Event;
public static string LevelOfEffortIcon(int? loe) public static string LevelOfEffortIcon(int? loe) =>
loe switch
{ {
1 => EventAttributeMarks.LevelOfEffort1,
return loe switch 2 => EventAttributeMarks.LevelOfEffort2,
{ 3 => EventAttributeMarks.LevelOfEffort3,
1 => "○",
2 => "◐",
3 => "⬤",
_ => Icons.Material.Filled.QuestionMark _ => Icons.Material.Filled.QuestionMark
}; };
}
/*https://unicodeplus.com/search*/ public static string OnSiteActivity => EventAttributeMarks.OnSite;
public static string OnSiteActivity = "ⓐ"; public static string RegionalEvent => EventAttributeMarks.Regional;
public static string RegionalEvent = "ⓡ"; public static string IndividualEvent => EventAttributeMarks.Individual;
public static string IndividualEvent = "ⓘ"; public static string PresubmissionEvent => EventAttributeMarks.Presubmission;
public static string PresubmissionEvent = "ⓟ"; public static string PresentationEvent => "";
public static string PresentationEvent = "";
// Tooltip mapping for icon unicode characters public static IReadOnlyDictionary<string, string> IconTooltips { get; } =
public static Dictionary<string, string> IconTooltips => new() EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal);
{
{ 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" }
};
// Color mapping for icon unicode characters public static IReadOnlyDictionary<string, string> IconColors { get; } =
public static Dictionary<string, string> IconColors => new() EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Color, StringComparer.Ordinal);
{
{ 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 string EventEffort(EventDefinition eventDefinition) public static string EventEffort(EventDefinition eventDefinition)
{ {
@@ -96,63 +73,19 @@ namespace WebApp.Models
}; };
} }
public static string RankedEventColor(int rank) public static string RankedEventColor(int rank) => EventRankLegend.ColorHex(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 GetOrdinal(int num) public static string GetOrdinal(int num) => EventRankLegend.Ordinal(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 GetOrdinalSuperscript(int number) public static string GetOrdinalSuperscript(int number)
{ {
var suffix = number switch var ordinal = EventRankLegend.Ordinal(number);
{ var suffixAt = 0;
11 or 12 or 13 => "th", while (suffixAt < ordinal.Length && (char.IsDigit(ordinal[suffixAt]) || ordinal[suffixAt] == '-'))
_ => (number % 10) switch suffixAt++;
{ return suffixAt is 0 || suffixAt == ordinal.Length
1 => "st", ? ordinal
2 => "nd", : $"{ordinal[..suffixAt]}<sup>{ordinal[suffixAt..]}</sup>";
3 => "rd",
_ => "th"
}
};
return $"{number}<sup>{suffix}</sup>";
} }
/// <summary> /// <summary>
+49 -7
View File
@@ -58,6 +58,8 @@ public class NotePrintService : INotePrintService
query = query.Where(s => s.OfficerRole == null); query = query.Where(s => s.OfficerRole == null);
var students = await query var students = await query
.Include(s => s.EventRankings)
.ThenInclude(r => r.EventDefinition)
.OrderBy(s => s.LastName) .OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName) .ThenBy(s => s.FirstName)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -79,7 +81,8 @@ public class NotePrintService : INotePrintService
template, template,
row.Imported, row.Imported,
StudentTokens(row.Student), StudentTokens(row.Student),
chapter)) chapter,
StudentHtmlFragments(row.Student)))
]; ];
} }
@@ -140,9 +143,25 @@ public class NotePrintService : INotePrintService
.OrderBy(e => e.Name) .OrderBy(e => e.Name)
.ToListAsync(cancellationToken); .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 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, string template,
Dictionary<string, string?>? imported, Dictionary<string, string?>? imported,
Dictionary<string, string?> entity, Dictionary<string, string?> entity,
Dictionary<string, string?> chapter) Dictionary<string, string?> chapter,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{ {
var map = PrintTokenMap.Build(imported, entity, chapter); var map = PrintTokenMap.Build(imported, entity, chapter);
return new NotePrintPage return new NotePrintPage
{ {
DisplayName = displayName, 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() private Dictionary<string, string?> ChapterTokens()
{ {
var settings = ChapterSettings.FromConfiguration(_configuration); var settings = ChapterSettings.FromConfiguration(_configuration);
@@ -174,8 +208,9 @@ public class NotePrintService : INotePrintService
}; };
} }
private static Dictionary<string, string?> StudentTokens(Student student) => private static Dictionary<string, string?> StudentTokens(Student student)
new(StringComparer.OrdinalIgnoreCase) {
var tokens = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{ {
["FirstName"] = student.FirstName, ["FirstName"] = student.FirstName,
["LastName"] = student.LastName, ["LastName"] = student.LastName,
@@ -191,6 +226,12 @@ public class NotePrintService : INotePrintService
["OfficerRole"] = student.OfficerRole?.ToString() ["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) private static Dictionary<string, string?> TeamTokens(Team team)
{ {
var evt = team.Event; var evt = team.Event;
@@ -222,7 +263,8 @@ public class NotePrintService : INotePrintService
["TeamSize"] = evt?.TeamSize, ["TeamSize"] = evt?.TeamSize,
["Eligibility"] = evt?.Eligibility, ["Eligibility"] = evt?.Eligibility,
["Description"] = evt?.Description, ["Description"] = evt?.Description,
["Theme"] = evt?.Theme ["Theme"] = evt?.Theme,
["EventAttributes"] = EventAttributeMarks.For(evt)
}; };
private static Dictionary<string, string?> ImportedTokens( private static Dictionary<string, string?> ImportedTokens(
+50 -2
View File
@@ -61,6 +61,7 @@
} }
.print-answer-space { .print-answer-space {
height: calc(var(--print-answer-lines, 3) * 1.35em);
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
to bottom, to bottom,
transparent, transparent,
@@ -88,6 +89,10 @@
.event-rank-4 { background-color: #ffe599; } .event-rank-4 { background-color: #ffe599; }
.event-rank-5 { background-color: #fff2cc; } .event-rank-5 { background-color: #fff2cc; }
.event-rank-6 { background-color: #fffaea; } .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 { .pre-wrap-text {
@@ -390,9 +395,52 @@
font-size: 0.85em; 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 { .print-answer-space {
min-height: calc(var(--print-answer-lines, 3) * 1.35em); display: block;
margin: 0.2em 0 0.75em; 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( background-image: repeating-linear-gradient(
to bottom, to bottom,
transparent, 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? 1. Why did you join TSA?
2. What events interest you? 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 `{{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). 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 - Core merge: `{{tokens}}`, `{{PageBreak}}`, `{{AnswerSpace}}`, print presets JSON
- `/print` UI: entity filters, font size, answer-space lines, Preview/Print, save/load/update/delete presets - `/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 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) - EF migration `AddPrintPresets` (applied on next app start)