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
@@ -313,6 +313,8 @@ else
yield return ("Layout", PrintFieldCatalog.Layout);
yield return ("Chapter", PrintFieldCatalog.Chapter);
yield return (_entityType.ToString(), PrintFieldCatalog.EntityTokens(_entityType));
if (_entityType == PrintEntityType.Student)
yield return ("Event ranks", PrintFieldCatalog.StudentRanks);
if (_entityType == PrintEntityType.Student && _importedTokenNames.Count > 0)
yield return ("Additional fields", _importedTokenNames);
}
+7 -8
View File
@@ -1,15 +1,14 @@
@using WebApp.Models
@using Core.Printing
@using WebApp.Models
<MudPaper>
<h3>Legend</h3>
<MudContainer>
<ul>
<li>@AppIcons.LevelOfEffortIcon(1) - Level of Effort </li>
<li>@AppIcons.IndividualEvent - Individual Event </li>
<li>@AppIcons.RegionalEvent - Regional </li>
<li>@AppIcons.OnSiteActivity - On-site Activity</li>
<li>@AppIcons.PresubmissionEvent - Pre-submission</li>
<li>@AppIcons.PresentationEvent - Interview Or Presentation</li>
@foreach (var mark in EventAttributeMarks.LegendItems)
{
<li>@mark.Symbol - @mark.Label</li>
}
</ul>
</MudContainer>
</MudPaper>
</MudPaper>
+24 -91
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Printing;
using MudBlazor;
namespace WebApp.Models
@@ -14,50 +15,26 @@ namespace WebApp.Models
public static string Captain = Icons.Material.Filled.Star;
public static string Registration = Icons.Material.Filled.AppRegistration;
public static string EventCalendar = Icons.Material.Filled.Event;
public static string LevelOfEffortIcon(int? loe)
{
return loe switch
public static string LevelOfEffortIcon(int? loe) =>
loe switch
{
1 => "○",
2 => "◐",
3 => "⬤",
1 => EventAttributeMarks.LevelOfEffort1,
2 => EventAttributeMarks.LevelOfEffort2,
3 => EventAttributeMarks.LevelOfEffort3,
_ => Icons.Material.Filled.QuestionMark
};
}
/*https://unicodeplus.com/search*/
public static string OnSiteActivity = "ⓐ";
public static string RegionalEvent = "ⓡ";
public static string IndividualEvent = "ⓘ";
public static string PresubmissionEvent = "ⓟ";
public static string PresentationEvent = "";
public static string OnSiteActivity => EventAttributeMarks.OnSite;
public static string RegionalEvent => EventAttributeMarks.Regional;
public static string IndividualEvent => EventAttributeMarks.Individual;
public static string PresubmissionEvent => EventAttributeMarks.Presubmission;
public static string PresentationEvent => "";
// Tooltip mapping for icon unicode characters
public static Dictionary<string, string> IconTooltips => new()
{
{ OnSiteActivity, "On-Site Activity" },
{ RegionalEvent, "Regional Event" },
{ IndividualEvent, "Individual Event" },
{ PresubmissionEvent, "Presubmission" },
{ PresentationEvent, "Presentation/Interview" },
{ "○", "Level of Effort: 1" },
{ "◐", "Level of Effort: 2" },
{ "●", "Level of Effort: 3" }
};
public static IReadOnlyDictionary<string, string> IconTooltips { get; } =
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Label, StringComparer.Ordinal);
// Color mapping for icon unicode characters
public static Dictionary<string, string> IconColors => new()
{
{ OnSiteActivity, "#ff9800" }, // Orange
{ RegionalEvent, "#2196f3" }, // Blue
{ IndividualEvent, "#9c27b0" }, // Purple
{ PresubmissionEvent, "#4caf50" }, // Green
{ PresentationEvent, "#f44336" }, // Red
{ "○", "#757575" }, // Gray
{ "◐", "#616161" }, // Darker Gray
{ "●", "#424242" } // Even Darker Gray
};
public static IReadOnlyDictionary<string, string> IconColors { get; } =
EventAttributeMarks.LegendItems.ToDictionary(m => m.Symbol, m => m.Color, StringComparer.Ordinal);
public static string EventEffort(EventDefinition eventDefinition)
{
@@ -96,63 +73,19 @@ namespace WebApp.Models
};
}
public static string RankedEventColor(int rank)
{
return rank switch
{
1 => "#dd7e6b",
2 => "#ea9999",
3 => "#f9cb9c",
4 => "#ffe599",
5 => "#fff2cc",
6 => "#fffaea",
7 => "#fffefa",
8 => "#fffefc",
9 => "#fffffd",
10 => "#fffffe",
_ => "#ddd"
};
}
public static string RankedEventColor(int rank) => EventRankLegend.ColorHex(rank);
public static string GetOrdinal(int num)
{
if (num <= 0) return num.ToString();
switch (num % 100)
{
case 11:
case 12:
case 13:
return num + "th";
}
switch (num % 10)
{
case 1:
return num + "st";
case 2:
return num + "nd";
case 3:
return num + "rd";
default:
return num + "th";
}
}
public static string GetOrdinal(int num) => EventRankLegend.Ordinal(num);
public static string GetOrdinalSuperscript(int number)
{
var suffix = number switch
{
11 or 12 or 13 => "th",
_ => (number % 10) switch
{
1 => "st",
2 => "nd",
3 => "rd",
_ => "th"
}
};
return $"{number}<sup>{suffix}</sup>";
var ordinal = EventRankLegend.Ordinal(number);
var suffixAt = 0;
while (suffixAt < ordinal.Length && (char.IsDigit(ordinal[suffixAt]) || ordinal[suffixAt] == '-'))
suffixAt++;
return suffixAt is 0 || suffixAt == ordinal.Length
? ordinal
: $"{ordinal[..suffixAt]}<sup>{ordinal[suffixAt..]}</sup>";
}
/// <summary>
+49 -7
View File
@@ -58,6 +58,8 @@ public class NotePrintService : INotePrintService
query = query.Where(s => s.OfficerRole == null);
var students = await query
.Include(s => s.EventRankings)
.ThenInclude(r => r.EventDefinition)
.OrderBy(s => s.LastName)
.ThenBy(s => s.FirstName)
.ToListAsync(cancellationToken);
@@ -79,7 +81,8 @@ public class NotePrintService : INotePrintService
template,
row.Imported,
StudentTokens(row.Student),
chapter))
chapter,
StudentHtmlFragments(row.Student)))
];
}
@@ -140,9 +143,25 @@ public class NotePrintService : INotePrintService
.OrderBy(e => e.Name)
.ToListAsync(cancellationToken);
var rankings = await _context.StudentEventRanking
.AsNoTracking()
.Include(r => r.Student)
.Include(r => r.EventDefinition)
.ToListAsync(cancellationToken);
var rankingsByEventId = rankings
.GroupBy(r => r.EventDefinition.Id)
.ToDictionary(g => g.Key, g => g.ToList());
return
[
.. events.Select(evt => MergePage(evt.Name, template, null, EventTokens(evt), chapter))
.. events.Select(evt => MergePage(
evt.Name,
template,
null,
EventTokens(evt),
chapter,
EventHtmlFragments(rankingsByEventId.GetValueOrDefault(evt.Id))))
];
}
@@ -151,16 +170,31 @@ public class NotePrintService : INotePrintService
string template,
Dictionary<string, string?>? imported,
Dictionary<string, string?> entity,
Dictionary<string, string?> chapter)
Dictionary<string, string?> chapter,
IReadOnlyDictionary<string, string>? htmlFragments = null)
{
var map = PrintTokenMap.Build(imported, entity, chapter);
return new NotePrintPage
{
DisplayName = displayName,
Html = NoteTemplateMerger.ApplyLayout(MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)))
Html = NoteTemplateMerger.ApplyLayout(
MarkdownHelper.ToHtml(NoteTemplateMerger.Merge(template, map)),
htmlFragments)
};
}
private static Dictionary<string, string> StudentHtmlFragments(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedEventsToken] = PrintRankBadgeHtml.ForStudentEvents(student.EventRankings)
};
private static Dictionary<string, string> EventHtmlFragments(IReadOnlyList<StudentEventRanking>? rankings) =>
new(StringComparer.OrdinalIgnoreCase)
{
[NoteTemplateMerger.RankedStudentsToken] = PrintRankBadgeHtml.ForEventStudents(rankings)
};
private Dictionary<string, string?> ChapterTokens()
{
var settings = ChapterSettings.FromConfiguration(_configuration);
@@ -174,8 +208,9 @@ public class NotePrintService : INotePrintService
};
}
private static Dictionary<string, string?> StudentTokens(Student student) =>
new(StringComparer.OrdinalIgnoreCase)
private static Dictionary<string, string?> StudentTokens(Student student)
{
var tokens = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["FirstName"] = student.FirstName,
["LastName"] = student.LastName,
@@ -191,6 +226,12 @@ public class NotePrintService : INotePrintService
["OfficerRole"] = student.OfficerRole?.ToString()
};
foreach (var (key, value) in StudentRankTokens.FromRankings(student.EventRankings))
tokens[key] = value;
return tokens;
}
private static Dictionary<string, string?> TeamTokens(Team team)
{
var evt = team.Event;
@@ -222,7 +263,8 @@ public class NotePrintService : INotePrintService
["TeamSize"] = evt?.TeamSize,
["Eligibility"] = evt?.Eligibility,
["Description"] = evt?.Description,
["Theme"] = evt?.Theme
["Theme"] = evt?.Theme,
["EventAttributes"] = EventAttributeMarks.For(evt)
};
private static Dictionary<string, string?> ImportedTokens(
+50 -2
View File
@@ -61,6 +61,7 @@
}
.print-answer-space {
height: calc(var(--print-answer-lines, 3) * 1.35em);
background-image: repeating-linear-gradient(
to bottom,
transparent,
@@ -88,6 +89,10 @@
.event-rank-4 { background-color: #ffe599; }
.event-rank-5 { background-color: #fff2cc; }
.event-rank-6 { background-color: #fffaea; }
.event-rank-7 { background-color: #fffefa; }
.event-rank-8 { background-color: #fffefc; }
.event-rank-9 { background-color: #fffffd; }
.event-rank-10 { background-color: #fffffe; }
.pre-wrap-text {
@@ -390,9 +395,52 @@
font-size: 0.85em;
}
.print-rank-badges {
margin: 0.2em 0 0.5em;
line-height: 1.6;
}
.print-rank-badge {
display: inline-block;
white-space: nowrap;
line-height: 1.2;
}
.print-rank-dot {
display: inline-block;
width: 0.65em;
height: 0.65em;
border-radius: 50%;
flex-shrink: 0;
background: #ddd;
}
.print-event-attrs {
font-family: monospace;
white-space: pre;
}
.print-badge-legend {
margin: 0.4em 0 0.9em;
font-size: 0.85em;
}
.print-attr-legend {
margin-top: 0.25em;
line-height: 1.6;
}
.print-legend-mark {
display: inline-block;
white-space: nowrap;
}
.print-answer-space {
min-height: calc(var(--print-answer-lines, 3) * 1.35em);
margin: 0.2em 0 0.75em;
display: block;
height: calc(var(--print-answer-lines, 3) * 1.35em);
overflow: hidden;
margin: 0.35em 0 0.75em;
box-sizing: content-box;
background-image: repeating-linear-gradient(
to bottom,
transparent,