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;
}
}