diff --git a/app/frontend/javascript/controllers/attendance_status_controller.js b/app/frontend/javascript/controllers/attendance_status_controller.js
index 62a4a6b5d..007416009 100644
--- a/app/frontend/javascript/controllers/attendance_status_controller.js
+++ b/app/frontend/javascript/controllers/attendance_status_controller.js
@@ -4,9 +4,30 @@ import { Controller } from "@hotwired/stimulus"
// Swaps the colored icon inside the status select as the dropdown changes. The
// select looks like an ordinary form field (not the roster's autosaving chip),
// so an "Unsaved" hint is shown until the form is saved.
+//
+// The per-day attendance checkboxes drive the status the same way the Onboarding
+// matrix does: toggling a day rolls the status forward/back (registered →
+// incomplete_attendance → attended), but only while the status is an active one —
+// deliberate manual states (cancelled, no_show, transferred_out) are never
+// overridden. Mirrors EventRegistration#sync_attendance_status_to_days!.
export default class extends Controller {
- static targets = ["select", "icon", "dirty"]
- static values = { colors: Object, icons: Object, initial: String }
+ static targets = ["select", "icon", "dirty", "day"]
+ static values = { colors: Object, icons: Object, initial: String, activeStatuses: Array, dayCount: Number }
+
+ // Recompute the status from how many day checkboxes are checked, then reflect it
+ // in the dropdown (which re-renders the icon and the unsaved hint via update()).
+ deriveFromDays() {
+ if (!this.activeStatusesValue.includes(this.selectTarget.value)) return
+
+ const checked = this.dayTargets.filter((day) => day.checked).length
+ let derived = "incomplete_attendance"
+ if (checked === 0) derived = "registered"
+ else if (checked >= this.dayCountValue) derived = "attended"
+
+ if (this.selectTarget.value === derived) return
+ this.selectTarget.value = derived
+ this.update()
+ }
update() {
const status = this.selectTarget.value
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb
index 4a7a474f2..2be2e6675 100644
--- a/app/views/event_registrations/_form.html.erb
+++ b/app/views/event_registrations/_form.html.erb
@@ -32,7 +32,15 @@
} %>
<% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %>
<% current_icon = status_icons[f.object.status] || "fa-question" %>
-
+ <%# The attendance-status controller spans the whole card so the per-day
+ checkmarks in the meta strip below can drive the status select above. %>
+
Registration status
<% end %>
<% end %>
+
+ <%# ---- Per-day attendance — the same checkmarks as the Onboarding
+ matrix. Toggling a day rolls the status select above forward/back
+ (see the attendance-status controller), and the boxes save with the
+ form. ---- %>
+
+ Days attended
+
+ <% (1..f.object.event.day_count).each do |day| %>
+
+ <% end %>
+
+
diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb
index 216bca7b6..ac1e2d4db 100644
--- a/spec/system/event_registration_edit_spec.rb
+++ b/spec/system/event_registration_edit_spec.rb
@@ -182,6 +182,63 @@
end
end
+ describe "per-day attendance checkboxes" do
+ it "renders a checkbox per event day, reflecting stored state, and persists changes" do
+ registration.update!(completed_day_1: true)
+
+ sign_in(admin)
+ visit edit_event_registration_path(registration)
+
+ # The event spans 3 days, so Days 1-3 show (and no Day 4).
+ expect(page).to have_field("Day 1", checked: true)
+ expect(page).to have_field("Day 2", checked: false)
+ expect(page).to have_field("Day 3", checked: false)
+ expect(page).to have_no_field("Day 4")
+
+ check "Day 2", allow_label_click: true
+ click_on "Save changes"
+
+ expect(page).to have_current_path(registrants_event_path(event))
+ expect(registration.reload.completed_day_1).to be(true)
+ expect(registration.completed_day_2).to be(true)
+ end
+
+ it "derives the attendance status from the checked days and saves both together" do
+ sign_in(admin)
+ visit edit_event_registration_path(registration)
+
+ badge = "[data-attendance-status-target='dirty']"
+ expect(page).to have_no_css(badge, visible: true)
+
+ # Checking every day rolls the status forward to Attended (mirrors onboarding).
+ check "Day 1", allow_label_click: true
+ check "Day 2", allow_label_click: true
+ check "Day 3", allow_label_click: true
+
+ expect(page).to have_css(badge, visible: true, text: "Unsaved")
+ expect(page).to have_select("event_registration[status]", selected: "Attended")
+
+ click_on "Save changes"
+
+ # Wait for the save round-trip to land before reading the database.
+ expect(page).to have_current_path(registrants_event_path(event))
+ expect(registration.reload.status).to eq("attended")
+ expect(registration.completed_day_count).to eq(3)
+ end
+
+ it "leaves an inactive status untouched when days are toggled" do
+ registration.update!(status: "cancelled")
+
+ sign_in(admin)
+ visit edit_event_registration_path(registration)
+
+ check "Day 1", allow_label_click: true
+
+ # Cancelled is a deliberate manual state, so toggling a day never overrides it.
+ expect(page).to have_select("event_registration[status]", selected: "Cancelled")
+ end
+ end
+
describe "notifications box" do
it "lists notifications sent to the registrant" do
create(:notification,