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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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\"> </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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(" · ", 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>";
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", 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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("\"", """, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FlattenLines(string value) =>
|
||||
string.Join(' ',
|
||||
value.Split(['\r', '\n', '\u2028', '\u2029'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 <b>"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void For_UsesTheSameMarksAsTheLegend()
|
||||
{
|
||||
var evt = EventDefinitionBuilder.Individual("Coding")
|
||||
.AsOnSite()
|
||||
.AsRegionalEvent()
|
||||
.WithPresubmission()
|
||||
.WithLevelOfEffort(2)
|
||||
.Build();
|
||||
|
||||
var fromCatalog = string.Join(
|
||||
" ",
|
||||
EventAttributeMarks.LegendItems.Where(m => m.Applies(evt)).Select(m => m.Symbol));
|
||||
|
||||
Assert.That(EventAttributeMarks.For(evt), Is.EqualTo(fromCatalog));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Legend_IncludesRankDotsAndAttributeMarks()
|
||||
{
|
||||
var html = PrintRankBadgeHtml.Legend();
|
||||
|
||||
Assert.That(html, Does.Contain("print-badge-legend"));
|
||||
Assert.That(html, Does.Contain("·"));
|
||||
Assert.That(html, Does.Not.Contain(EventRankLegend.Ordinal(1)));
|
||||
Assert.That(html, Does.Not.Contain("event-rank-1"));
|
||||
foreach (var mark in EventAttributeMarks.LegendItems)
|
||||
{
|
||||
Assert.That(html, Does.Contain(mark.Symbol));
|
||||
Assert.That(html, Does.Contain(mark.Label));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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| --- | --- |"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+24
-91
@@ -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)
|
||||
public static string LevelOfEffortIcon(int? loe) =>
|
||||
loe switch
|
||||
{
|
||||
|
||||
return 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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user