From 1e18ec1cf85ee48d6a514cb20297faa7369e3d68 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Fri, 10 Apr 2026 16:47:07 +0200 Subject: [PATCH] Extract rota display into reusable partials Extract inline rota rendering from Shifts/Index.cshtml into three shared partials: _RotaHeader (name, badges, tags, description), _BuildStrikeRotaTable (all-day shift table with collapsible date ranges and range signup), and _EventRotaTable (timed shift table with individual signup). Reduces the volunteer browse view from 586 to 259 lines with no visual or behavioral changes. Closes #198 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Humans.Web/Models/ShiftViewModels.cs | 37 ++ .../Views/Shared/_BuildStrikeRotaTable.cshtml | 207 ++++++++++ .../Views/Shared/_EventRotaTable.cshtml | 113 ++++++ .../Views/Shared/_RotaHeader.cshtml | 34 ++ src/Humans.Web/Views/Shifts/Index.cshtml | 357 +----------------- 5 files changed, 406 insertions(+), 342 deletions(-) create mode 100644 src/Humans.Web/Views/Shared/_BuildStrikeRotaTable.cshtml create mode 100644 src/Humans.Web/Views/Shared/_EventRotaTable.cshtml create mode 100644 src/Humans.Web/Views/Shared/_RotaHeader.cshtml diff --git a/src/Humans.Web/Models/ShiftViewModels.cs b/src/Humans.Web/Models/ShiftViewModels.cs index 8e8cba3e9..12353dd9c 100644 --- a/src/Humans.Web/Models/ShiftViewModels.cs +++ b/src/Humans.Web/Models/ShiftViewModels.cs @@ -460,6 +460,43 @@ public class ShiftSignupsViewModel public string? DisplayName { get; set; } } +// === Rota Partial View Models === + +/// +/// Model for the _RotaHeader partial — displays rota name, priority, period, visibility, +/// tags, description, and practical info. Used by both volunteer and admin views. +/// +public class RotaHeaderViewModel +{ + public Rota Rota { get; set; } = null!; + public bool ShowPreferenceStar { get; set; } +} + +/// +/// Model for the _BuildStrikeRotaTable partial — all-day shift table with collapsible +/// date ranges and range signup form. Used by the volunteer browse view. +/// +public class BuildStrikeRotaTableViewModel +{ + public RotaShiftGroup RotaGroup { get; set; } = null!; + public EventSettings EventSettings { get; set; } = null!; + public HashSet UserSignupShiftIds { get; set; } = []; + public bool ShowSignups { get; set; } +} + +/// +/// Model for the _EventRotaTable partial — timed shift table with individual signup buttons. +/// Used by the volunteer browse view. +/// +public class EventRotaTableViewModel +{ + public List Shifts { get; set; } = []; + public EventSettings EventSettings { get; set; } = null!; + public HashSet UserSignupShiftIds { get; set; } = []; + public Dictionary UserSignupStatuses { get; set; } = new(); + public bool ShowSignups { get; set; } +} + // === No-Show History === public class NoShowHistoryItem diff --git a/src/Humans.Web/Views/Shared/_BuildStrikeRotaTable.cshtml b/src/Humans.Web/Views/Shared/_BuildStrikeRotaTable.cshtml new file mode 100644 index 000000000..cfeb6ead8 --- /dev/null +++ b/src/Humans.Web/Views/Shared/_BuildStrikeRotaTable.cshtml @@ -0,0 +1,207 @@ +@model Humans.Web.Models.BuildStrikeRotaTableViewModel +@{ + var rotaGroup = Model.RotaGroup; + var es = Model.EventSettings; + var allDayShifts = rotaGroup.Shifts.Where(s => s.Shift.IsAllDay).OrderBy(s => s.Shift.DayOffset).ToList(); + var availableShifts = allDayShifts.Where(s => s.RemainingSlots > 0 && !Model.UserSignupShiftIds.Contains(s.Shift.Id)).ToList(); +} +@if (availableShifts.Count > 0) +{ +
+ @Html.AntiForgeryToken() + +
+ + +
+
+ + +
+
+ +
+
+} +@{ + var dateRanges = new List>(); + List? currentRange = null; + foreach (var s in allDayShifts) + { + if (currentRange == null || s.Shift.DayOffset != currentRange.Last().Shift.DayOffset + 1) + { + currentRange = new List { s }; + dateRanges.Add(currentRange); + } + else + { + currentRange.Add(s); + } + } +} +
+ + + @if (Model.ShowSignups) {} + + + @foreach (var range in dateRanges) + { + if (range.Count > 1) + { + var rangeKey = $"r{range.First().Shift.Id:N}"; + var totalConfirmed = range.Sum(x => x.ConfirmedCount); + var totalMax = range.Sum(x => x.Shift.MaxVolunteers); + var anyUnderstaffed = range.Any(x => x.ConfirmedCount < x.Shift.MinVolunteers); + var daysNeedingHelp = range.Count(x => x.ConfirmedCount < x.Shift.MinVolunteers); + var allRangeFull = range.All(x => x.RemainingSlots <= 0); + var userSignedUpDays = range.Count(x => Model.UserSignupShiftIds.Contains(x.Shift.Id)); + var rangeStart = es.GateOpeningDate.PlusDays(range.First().Shift.DayOffset); + var rangeEnd = es.GateOpeningDate.PlusDays(range.Last().Shift.DayOffset); + + + + + @if (Model.ShowSignups) + { + + } + + @foreach (var item in range) + { + var isFull = item.RemainingSlots <= 0; + var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers; + + + + + @if (Model.ShowSignups) + { + + } + + @if (!string.IsNullOrEmpty(item.Shift.Description)) + { + + + + } + } + } + else + { + var item = range[0]; + var isFull = item.RemainingSlots <= 0; + var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers; + + + + + @if (Model.ShowSignups) + { + + } + + @if (!string.IsNullOrEmpty(item.Shift.Description)) + { + + + + } + } + } + +
DateFilledStatus@Localizer["Shifts_SignedUp"]
+ + @rangeStart.ToDisplayShiftDate()–@rangeEnd.ToDisplayShiftDate() + (@range.Count days) + + @totalConfirmed + / @totalMax total + + @if (userSignedUpDays == range.Count) + { + Signed up (all @range.Count days) + } + else if (userSignedUpDays > 0) + { + Signed up (@userSignedUpDays of @range.Count) + } + else if (allRangeFull) + { + Full + } + else if (daysNeedingHelp > 0) + { + @daysNeedingHelp @(daysNeedingHelp == 1 ? "day needs" : "days need") help + } + click to expand
@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate() + @item.ConfirmedCount + / @item.Shift.MaxVolunteers + + @if (Model.UserSignupShiftIds.Contains(item.Shift.Id)) + { + Signed up + } + else if (isFull) + { + Full + } + else if (understaffed) + { + Needs help + } + +
+ @foreach (var signup in item.Signups) + { + + } +
+
+ @Html.SanitizedMarkdown(item.Shift.Description) +
@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate() + @item.ConfirmedCount + / @item.Shift.MaxVolunteers + + @if (Model.UserSignupShiftIds.Contains(item.Shift.Id)) + { + Signed up + } + else if (isFull) + { + Full + } + else if (understaffed) + { + Needs help + } + +
+ @foreach (var signup in item.Signups) + { + + } +
+
+ @Html.SanitizedMarkdown(item.Shift.Description) +
+
diff --git a/src/Humans.Web/Views/Shared/_EventRotaTable.cshtml b/src/Humans.Web/Views/Shared/_EventRotaTable.cshtml new file mode 100644 index 000000000..c8e30cb20 --- /dev/null +++ b/src/Humans.Web/Views/Shared/_EventRotaTable.cshtml @@ -0,0 +1,113 @@ +@model Humans.Web.Models.EventRotaTableViewModel +@using NodaTime + +@functions { + string GetPhase(Shift shift, EventSettings es) + { + return shift.GetShiftPeriod(es) switch + { + ShiftPeriod.Build => "Set-up", + ShiftPeriod.Event => "Event", + ShiftPeriod.Strike => "Strike", + _ => "" + }; + } + + string GetPhaseBadge(Shift shift, EventSettings es) + { + return shift.GetShiftPeriod(es) switch + { + ShiftPeriod.Build => "bg-info", + ShiftPeriod.Event => "bg-success", + ShiftPeriod.Strike => "bg-secondary", + _ => "bg-secondary" + }; + } +} + +@{ + var es = Model.EventSettings; + var tz = DateTimeZoneProviders.Tzdb[es.TimeZoneId]; +} +
+ + + + + + + + + + @if (Model.ShowSignups) + { + + } + + + + + @foreach (var item in Model.Shifts) + { + var shift = item.Shift; + var isFull = item.RemainingSlots <= 0; + var understaffed = item.ConfirmedCount < shift.MinVolunteers; + + + + + + + + @if (Model.ShowSignups) + { + + } + + + @if (!string.IsNullOrEmpty(shift.Description)) + { + + + + } + } + +
ShiftPhaseDateTimeDurationFilled@Localizer["Shifts_SignedUp"]
@(shift.IsAllDay ? "All day" : $"{shift.StartTime.ToDisplayTime()}–{shift.StartTime.PlusMinutes((int)shift.Duration.TotalMinutes).ToDisplayTime()}")@GetPhase(shift, es)@es.GateOpeningDate.PlusDays(shift.DayOffset).ToDisplayShiftDate()@item.AbsoluteStart.ToDisplayTime(tz)@(shift.IsAllDay ? "Full day" : $"{shift.Duration.TotalHours}h") + @item.ConfirmedCount + / @shift.MinVolunteers–@shift.MaxVolunteers + @if (understaffed) + { + Needs help + } + + @for (var i = 0; i < item.Signups.Count; i++) + { + var signup = item.Signups[i]; + if (i > 0) { , } + @if (signup.Status == SignupStatus.Pending) + { @Localizer["Shifts_Pending"]} + } + + @if (Model.UserSignupShiftIds.Contains(shift.Id)) + { + var status = Model.UserSignupStatuses.GetValueOrDefault(shift.Id); + @status + } + else if (isFull) + { + Full + } + else + { +
+ @Html.AntiForgeryToken() + + +
+ } +
+ @Html.SanitizedMarkdown(shift.Description) +
+
diff --git a/src/Humans.Web/Views/Shared/_RotaHeader.cshtml b/src/Humans.Web/Views/Shared/_RotaHeader.cshtml new file mode 100644 index 000000000..3cb2876ab --- /dev/null +++ b/src/Humans.Web/Views/Shared/_RotaHeader.cshtml @@ -0,0 +1,34 @@ +@model Humans.Web.Models.RotaHeaderViewModel + +
+ @if (Model.ShowPreferenceStar) + { + + } + @Model.Rota.Name + @if (Model.Rota.Priority == ShiftPriority.Essential) + { + Essential + } + else if (Model.Rota.Priority == ShiftPriority.Important) + { + Important + } + "bg-secondary", RotaPeriod.All => "bg-primary", _ => "bg-success" }) ms-1">@(Model.Rota.Period switch { RotaPeriod.Build => "Set-up", RotaPeriod.All => "All Phases", _ => Model.Rota.Period.ToString() }) + @if (!Model.Rota.IsVisibleToVolunteers) + { + Hidden + } + @foreach (var tag in Model.Rota.Tags.OrderBy(t => t.Name, StringComparer.Ordinal)) + { + @tag.Name + } +
+@if (!string.IsNullOrEmpty(Model.Rota.Description)) +{ +
@Html.SanitizedMarkdown(Model.Rota.Description)
+} +@if (!string.IsNullOrEmpty(Model.Rota.PracticalInfo)) +{ +
@Html.SanitizedMarkdown(Model.Rota.PracticalInfo)
+} diff --git a/src/Humans.Web/Views/Shifts/Index.cshtml b/src/Humans.Web/Views/Shifts/Index.cshtml index 83755c5ed..ccd8930f1 100644 --- a/src/Humans.Web/Views/Shifts/Index.cshtml +++ b/src/Humans.Web/Views/Shifts/Index.cshtml @@ -1,10 +1,8 @@ @model Humans.Web.Models.ShiftBrowseViewModel -@using NodaTime @{ ViewData["Title"] = "Shifts"; var es = Model.EventSettings; - var tz = DateTimeZoneProviders.Tzdb[es.TimeZoneId]; // Compute date picker bounds — tighten to active period's range var pickerMin = es.GateOpeningDate.PlusDays(es.BuildStartOffset); @@ -21,30 +19,6 @@ } } -@functions { - string GetPhase(Shift shift, EventSettings es) - { - return shift.GetShiftPeriod(es) switch - { - ShiftPeriod.Build => "Set-up", - ShiftPeriod.Event => "Event", - ShiftPeriod.Strike => "Strike", - _ => "" - }; - } - - string GetPhaseBadge(Shift shift, EventSettings es) - { - return shift.GetShiftPeriod(es) switch - { - ShiftPeriod.Build => "bg-info", - ShiftPeriod.Event => "bg-success", - ShiftPeriod.Strike => "bg-secondary", - _ => "bg-secondary" - }; - } -} -

Browse Volunteer Options

@@ -237,330 +211,29 @@ var isBuildStrike = rotaGroup.Rota.Period != RotaPeriod.Event; var rotaMatchesPreference = Model.UserPreferredTagIds.Count > 0 && rotaGroup.Rota.Tags.Any(t => Model.UserPreferredTagIds.Contains(t.Id)); -
- @if (rotaMatchesPreference) - { - - } - @rotaGroup.Rota.Name - @if (rotaGroup.Rota.Priority == ShiftPriority.Essential) - { - Essential - } - else if (rotaGroup.Rota.Priority == ShiftPriority.Important) - { - Important - } - "bg-secondary", RotaPeriod.All => "bg-primary", _ => "bg-success" }) ms-1">@(rotaGroup.Rota.Period switch { RotaPeriod.Build => "Set-up", RotaPeriod.All => "All Phases", _ => rotaGroup.Rota.Period.ToString() }) - @if (!rotaGroup.Rota.IsVisibleToVolunteers) - { - Hidden - } - @foreach (var tag in rotaGroup.Rota.Tags.OrderBy(t => t.Name, StringComparer.Ordinal)) - { - @tag.Name - } -
- @if (!string.IsNullOrEmpty(rotaGroup.Rota.Description)) - { -
@Html.SanitizedMarkdown(rotaGroup.Rota.Description)
- } - @if (!string.IsNullOrEmpty(rotaGroup.Rota.PracticalInfo)) - { -
@Html.SanitizedMarkdown(rotaGroup.Rota.PracticalInfo)
- } + @await Html.PartialAsync("_RotaHeader", new RotaHeaderViewModel { Rota = rotaGroup.Rota, ShowPreferenceStar = rotaMatchesPreference }) @if (isBuildStrike || hasAllDayShifts) { - @* Build/Strike/All: show date range with per-day fill rates and range signup *@ - var allDayShifts = rotaGroup.Shifts.Where(s => s.Shift.IsAllDay).OrderBy(s => s.Shift.DayOffset).ToList(); - var availableShifts = allDayShifts.Where(s => s.RemainingSlots > 0 && !Model.UserSignupShiftIds.Contains(s.Shift.Id)).ToList(); - @if (availableShifts.Count > 0) - { -
- @Html.AntiForgeryToken() - -
- - -
-
- - -
-
- -
-
- } - var dateRanges = new List>(); - List? currentRange = null; - foreach (var s in allDayShifts) + @await Html.PartialAsync("_BuildStrikeRotaTable", new BuildStrikeRotaTableViewModel { - if (currentRange == null || s.Shift.DayOffset != currentRange.Last().Shift.DayOffset + 1) - { - currentRange = new List { s }; - dateRanges.Add(currentRange); - } - else - { - currentRange.Add(s); - } - } -
- - - @if (Model.ShowSignups) {} - - - @foreach (var range in dateRanges) - { - if (range.Count > 1) - { - var rangeKey = $"r{range.First().Shift.Id:N}"; - var totalConfirmed = range.Sum(x => x.ConfirmedCount); - var totalMax = range.Sum(x => x.Shift.MaxVolunteers); - var anyUnderstaffed = range.Any(x => x.ConfirmedCount < x.Shift.MinVolunteers); - var daysNeedingHelp = range.Count(x => x.ConfirmedCount < x.Shift.MinVolunteers); - var allRangeFull = range.All(x => x.RemainingSlots <= 0); - var userSignedUpDays = range.Count(x => Model.UserSignupShiftIds.Contains(x.Shift.Id)); - var rangeStart = es.GateOpeningDate.PlusDays(range.First().Shift.DayOffset); - var rangeEnd = es.GateOpeningDate.PlusDays(range.Last().Shift.DayOffset); - - - - - @if (Model.ShowSignups) - { - - } - - @foreach (var item in range) - { - var isFull = item.RemainingSlots <= 0; - var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers; - - - - - @if (Model.ShowSignups) - { - - } - - @if (!string.IsNullOrEmpty(item.Shift.Description)) - { - - - - } - } - } - else - { - var item = range[0]; - var isFull = item.RemainingSlots <= 0; - var understaffed = item.ConfirmedCount < item.Shift.MinVolunteers; - - - - - @if (Model.ShowSignups) - { - - } - - @if (!string.IsNullOrEmpty(item.Shift.Description)) - { - - - - } - } - } - -
DateFilledStatus@Localizer["Shifts_SignedUp"]
- - @rangeStart.ToDisplayShiftDate()–@rangeEnd.ToDisplayShiftDate() - (@range.Count days) - - @totalConfirmed - / @totalMax total - - @if (userSignedUpDays == range.Count) - { - Signed up (all @range.Count days) - } - else if (userSignedUpDays > 0) - { - Signed up (@userSignedUpDays of @range.Count) - } - else if (allRangeFull) - { - Full - } - else if (daysNeedingHelp > 0) - { - @daysNeedingHelp @(daysNeedingHelp == 1 ? "day needs" : "days need") help - } - click to expand
@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate() - @item.ConfirmedCount - / @item.Shift.MaxVolunteers - - @if (Model.UserSignupShiftIds.Contains(item.Shift.Id)) - { - Signed up - } - else if (isFull) - { - Full - } - else if (understaffed) - { - Needs help - } - -
- @foreach (var signup in item.Signups) - { - - } -
-
- @Html.SanitizedMarkdown(item.Shift.Description) -
@es.GateOpeningDate.PlusDays(item.Shift.DayOffset).ToDisplayShiftDate() - @item.ConfirmedCount - / @item.Shift.MaxVolunteers - - @if (Model.UserSignupShiftIds.Contains(item.Shift.Id)) - { - Signed up - } - else if (isFull) - { - Full - } - else if (understaffed) - { - Needs help - } - -
- @foreach (var signup in item.Signups) - { - - } -
-
- @Html.SanitizedMarkdown(item.Shift.Description) -
-
- + RotaGroup = rotaGroup, + EventSettings = es, + UserSignupShiftIds = Model.UserSignupShiftIds, + ShowSignups = Model.ShowSignups + }) } @if (!isBuildStrike || hasTimedShifts) { - @* Event/timed shifts: standard per-shift signup *@ var timedShifts = isBuildStrike ? rotaGroup.Shifts.Where(s => !s.Shift.IsAllDay).ToList() : rotaGroup.Shifts.ToList(); -
- - - - - - - - - - @if (Model.ShowSignups) - { - - } - - - - - @foreach (var item in timedShifts) - { - var shift = item.Shift; - var isFull = item.RemainingSlots <= 0; - var understaffed = item.ConfirmedCount < shift.MinVolunteers; - - - - - - - - @if (Model.ShowSignups) - { - - } - - - @if (!string.IsNullOrEmpty(shift.Description)) - { - - - - } - } - -
ShiftPhaseDateTimeDurationFilled@Localizer["Shifts_SignedUp"]
@(shift.IsAllDay ? "All day" : $"{shift.StartTime.ToDisplayTime()}–{shift.StartTime.PlusMinutes((int)shift.Duration.TotalMinutes).ToDisplayTime()}")@GetPhase(shift, es)@es.GateOpeningDate.PlusDays(shift.DayOffset).ToDisplayShiftDate()@item.AbsoluteStart.ToDisplayTime(tz)@(shift.IsAllDay ? "Full day" : $"{shift.Duration.TotalHours}h") - @item.ConfirmedCount - / @shift.MinVolunteers–@shift.MaxVolunteers - @if (understaffed) - { - Needs help - } - - @for (var i = 0; i < item.Signups.Count; i++) - { - var signup = item.Signups[i]; - if (i > 0) { , } - @if (signup.Status == SignupStatus.Pending) - { @Localizer["Shifts_Pending"]} - } - - @if (Model.UserSignupShiftIds.Contains(shift.Id)) - { - var status = Model.UserSignupStatuses.GetValueOrDefault(shift.Id); - @status - } - else if (isFull) - { - Full - } - else - { -
- @Html.AntiForgeryToken() - - -
- } -
- @Html.SanitizedMarkdown(shift.Description) -
-
+ @await Html.PartialAsync("_EventRotaTable", new EventRotaTableViewModel + { + Shifts = timedShifts, + EventSettings = es, + UserSignupShiftIds = Model.UserSignupShiftIds, + UserSignupStatuses = Model.UserSignupStatuses, + ShowSignups = Model.ShowSignups + }) } } }