From 6e5147e217d5fd429ba534ff5f30cef4bce57b3b Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:10:58 -0400 Subject: [PATCH 01/27] Add EventAttendanceTimeEntry model + per-day sign-in window Introduce generic day-of-event attendance timekeeping: one sign-in/sign-out pair per row (many per day for breaks/lunch), audited by created_by/updated_by for staff edits. Event#attendance_sign_in_open? derives a per-day window from the event's single start/end time-of-day, since events store no per-day schedule. Backs the CE sign-in/out flow that replaces AWBW's paper CE hour sign-in sheet. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + app/models/event.rb | 45 +++++++++++++ app/models/event_attendance_time_entry.rb | 48 ++++++++++++++ app/models/event_registration.rb | 23 +++++++ ...38_create_event_attendance_time_entries.rb | 23 +++++++ db/schema.rb | 15 +++++ .../event_attendance_time_entries.rb | 11 ++++ .../event_attendance_time_entry_spec.rb | 66 +++++++++++++++++++ spec/models/event_registration_spec.rb | 31 +++++++++ spec/models/event_spec.rb | 52 +++++++++++++++ 10 files changed, 315 insertions(+) create mode 100644 app/models/event_attendance_time_entry.rb create mode 100644 db/migrate/20260804000438_create_event_attendance_time_entries.rb create mode 100644 spec/factories/event_attendance_time_entries.rb create mode 100644 spec/models/event_attendance_time_entry_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 9bda67752b..9150c10a3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ This codebase (Rails 8.1) | `Event` | Events with registrations, featured/published states | | `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" | | `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix | +| `EventAttendanceTimeEntry` | One sign-in/sign-out pair for a registrant on a day of an event (`signed_in_at`, `signed_out_at` — nil while "open"/still signed in; `created_by`/`updated_by` stamped only on staff edits, nil for registrant self-service). Generic day-of-event timekeeping (many per day for breaks/lunch), currently surfaced only on the CE callout when CE is paid; `EventAttendanceReport` totals minutes per day. Sign-in window derives from `Event#attendance_sign_in_open?` | | `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) | | `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page | | `Story` | Editorial content with facilitators, primary/gallery assets | diff --git a/app/models/event.rb b/app/models/event.rb index 952cc07aac..5230980baa 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -188,6 +188,44 @@ def day_count span.clamp(1, 5) end + # Attendance sign-in opens this long before a training day's start, so early + # arrivals can sign in (the CE sheet shows people arriving ~10 min early). Sign-out + # isn't windowed — an open entry can always be closed, since forgetting to sign out + # is the common failure. + ATTENDANCE_SIGN_IN_LEAD = 30.minutes + + # The calendar dates this event runs, inclusive, capped to day_count. Events store + # only one start_date/end_date, so multi-day events are assumed to run on + # consecutive days — the same assumption day_count already makes. + def event_dates + return [] if start_date.blank? + + first = start_date.in_time_zone(Time.zone).to_date + (0...day_count).map { |offset| first + offset } + end + + # A day's start datetime: that date at start_date's time-of-day, in the app zone. + # Every event day is assumed to start at the same time (the only time we have). + def daily_start_at(date) + combine_date_and_time(date, start_date) + end + + # A day's end datetime: that date at end_date's time-of-day (falling back to the + # start time for events with no end), in the app zone. + def daily_end_at(date) + combine_date_and_time(date, end_date.presence || start_date) + end + + # Whether a registrant may start a new sign-in right now: it's an event day and + # now falls within [day start − lead, day end]. Sign-out is deliberately not + # gated by this (see ATTENDANCE_SIGN_IN_LEAD). + def attendance_sign_in_open?(at = Time.current) + date = event_dates.find { |d| d == at.in_time_zone(Time.zone).to_date } + return false unless date + + at.between?(daily_start_at(date) - ATTENDANCE_SIGN_IN_LEAD, daily_end_at(date)) + end + def time_title "(#{ start_text }) #{ name }" end @@ -327,6 +365,13 @@ def merge_date_time(field) self[field] = build_datetime(date_val, time_val) end + # Combine a Date with the time-of-day of a datetime source, in the app zone — + # e.g. "day 2's date" + "the event's 9:00am start" → that day at 9:00am. + def combine_date_and_time(date, source) + time = source.in_time_zone(Time.zone) + Time.zone.local(date.year, date.month, date.day, time.hour, time.min) + end + def build_datetime(date_str, time_str) return nil if date_str.blank? && time_str.blank? return Time.zone.parse(date_str) if date_str.present? && time_str.blank? diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb new file mode 100644 index 0000000000..f0c7796442 --- /dev/null +++ b/app/models/event_attendance_time_entry.rb @@ -0,0 +1,48 @@ +# One sign-in/sign-out pair for a registrant on a day of an event. Generic +# attendance timekeeping — many entries per day (people sign out for breaks and +# lunch and back in) — surfaced today only on the CE callout, but not CE-specific +# so any event day can use it. `signed_out_at` is nil while the person is still +# signed in (an "open" entry). Times are stored UTC and displayed in the app zone +# (Pacific), matching the paper CE sign-in sheet this replaces. +class EventAttendanceTimeEntry < ApplicationRecord + belongs_to :event_registration + # Registrant self-service sign-ins happen on the public (login-free) callout, so + # created_by is nil for those; it's stamped only when staff add/edit an entry on + # the CE edit form. + belongs_to :created_by, class_name: "User", optional: true + belongs_to :updated_by, class_name: "User", optional: true + + validates :signed_in_at, presence: true + validate :signed_out_after_signed_in + + scope :open, -> { where(signed_out_at: nil) } + scope :closed, -> { where.not(signed_out_at: nil) } + scope :chronological, -> { order(:signed_in_at) } + + # Still signed in — no sign-out recorded yet. + def open? + signed_out_at.nil? + end + + # Whole minutes between sign-in and sign-out; nil while still open. Rounded to + # the minute like the paper sheet, which staff totalled by the minute. + def duration_minutes + return nil unless signed_out_at && signed_in_at + ((signed_out_at - signed_in_at) / 60).round + end + + # The event day (a Date, in the app zone) this entry's sign-in falls on — how + # the report groups entries into days. + def attendance_date + signed_in_at&.in_time_zone(Time.zone)&.to_date + end + + private + + def signed_out_after_signed_in + return if signed_out_at.blank? || signed_in_at.blank? + return if signed_out_at > signed_in_at + + errors.add(:signed_out_at, "must be after the sign-in time") + end +end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index d830fc3133..5a441958b7 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -11,12 +11,17 @@ class EventRegistration < ApplicationRecord has_many :organizations, through: :event_registration_organizations has_many :allocations, as: :allocatable has_many :continuing_education_registrations, dependent: :destroy + has_many :event_attendance_time_entries, dependent: :destroy has_many :scholarships, -> { distinct }, through: :allocations, source: :source, source_type: "Scholarship" has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } + # Staff correct/add attendance times on the CE edit form; a row with no sign-in + # time is an untouched blank and dropped. + accepts_nested_attributes_for :event_attendance_time_entries, allow_destroy: true, + reject_if: proc { |attrs| attrs["signed_in_at"].blank? } # Lets the registration edit form edit the registrant's shout-out text (which # lives on the Person) inline, alongside the registration's own shout-out flag. accepts_nested_attributes_for :registrant @@ -482,6 +487,24 @@ def cost_cents event.cost_cents end + # The registrant's currently-open attendance entry (signed in, not yet out), or + # nil when they're not signed in. Drives which sign-in/out button the CE callout + # shows. Uses the most recent open entry if more than one somehow exists. + def open_attendance_entry + event_attendance_time_entries.open.chronological.last + end + + # Whether the registrant is currently signed in. + def signed_in? + open_attendance_entry.present? + end + + # This registration's attendance entries for one event day (a Date), in + # sign-in order — the day's rows on the CE callout and the report. + def attendance_entries_on(date) + event_attendance_time_entries.chronological.select { |entry| entry.attendance_date == date } + end + # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. diff --git a/db/migrate/20260804000438_create_event_attendance_time_entries.rb b/db/migrate/20260804000438_create_event_attendance_time_entries.rb new file mode 100644 index 0000000000..1e99d89689 --- /dev/null +++ b/db/migrate/20260804000438_create_event_attendance_time_entries.rb @@ -0,0 +1,23 @@ +class CreateEventAttendanceTimeEntries < ActiveRecord::Migration[7.2] + def up + create_table :event_attendance_time_entries do |t| + t.references :event_registration, null: false, foreign_key: true, index: true + t.datetime :signed_in_at, null: false + t.datetime :signed_out_at + t.integer :created_by_id + t.integer :updated_by_id + + t.timestamps + end + + add_index :event_attendance_time_entries, :created_by_id + add_index :event_attendance_time_entries, :updated_by_id + # Fetching a registration's open (not-yet-signed-out) entry is the hot path. + add_index :event_attendance_time_entries, [ :event_registration_id, :signed_out_at ], + name: "index_attendance_entries_on_registration_and_signed_out" + end + + def down + drop_table :event_attendance_time_entries, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f9fa601f23..5d8d4ab5fe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -445,6 +445,20 @@ t.datetime "updated_at", null: false end + create_table "event_attendance_time_entries", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "created_by_id" + t.bigint "event_registration_id", null: false + t.datetime "signed_in_at", null: false + t.datetime "signed_out_at" + t.datetime "updated_at", null: false + t.integer "updated_by_id" + t.index ["created_by_id"], name: "index_event_attendance_time_entries_on_created_by_id" + t.index ["event_registration_id", "signed_out_at"], name: "index_attendance_entries_on_registration_and_signed_out" + t.index ["event_registration_id"], name: "index_event_attendance_time_entries_on_event_registration_id" + t.index ["updated_by_id"], name: "index_event_attendance_time_entries_on_updated_by_id" + end + create_table "event_forms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "event_id", null: false @@ -1790,6 +1804,7 @@ add_foreign_key "contact_methods", "addresses" add_foreign_key "continuing_education_registrations", "event_registrations" add_foreign_key "continuing_education_registrations", "professional_licenses" + add_foreign_key "event_attendance_time_entries", "event_registrations" add_foreign_key "event_forms", "events" add_foreign_key "event_forms", "forms" add_foreign_key "event_registration_checklist_completions", "event_registrations" diff --git a/spec/factories/event_attendance_time_entries.rb b/spec/factories/event_attendance_time_entries.rb new file mode 100644 index 0000000000..72a7757bc1 --- /dev/null +++ b/spec/factories/event_attendance_time_entries.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :event_attendance_time_entry do + association :event_registration + signed_in_at { 2.hours.ago } + signed_out_at { 1.hour.ago } + + trait :open do + signed_out_at { nil } + end + end +end diff --git a/spec/models/event_attendance_time_entry_spec.rb b/spec/models/event_attendance_time_entry_spec.rb new file mode 100644 index 0000000000..eb46f2e0a4 --- /dev/null +++ b/spec/models/event_attendance_time_entry_spec.rb @@ -0,0 +1,66 @@ +require "rails_helper" + +RSpec.describe EventAttendanceTimeEntry, type: :model do + describe "validations" do + it "requires a sign-in time" do + entry = build(:event_attendance_time_entry, signed_in_at: nil) + expect(entry).not_to be_valid + expect(entry.errors[:signed_in_at]).to be_present + end + + it "is valid while still open (no sign-out yet)" do + expect(build(:event_attendance_time_entry, :open)).to be_valid + end + + it "rejects a sign-out at or before the sign-in" do + at = Time.current + expect(build(:event_attendance_time_entry, signed_in_at: at, signed_out_at: at)).not_to be_valid + expect(build(:event_attendance_time_entry, signed_in_at: at, signed_out_at: at - 1.minute)).not_to be_valid + end + end + + describe "#open?" do + it "is true only without a sign-out time" do + expect(build(:event_attendance_time_entry, :open)).to be_open + expect(build(:event_attendance_time_entry)).not_to be_open + end + end + + describe "#duration_minutes" do + it "returns whole minutes between sign-in and sign-out" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), + signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + expect(entry.duration_minutes).to eq(104) + end + + it "rounds to the nearest minute" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50, 0), + signed_out_at: Time.zone.local(2026, 7, 23, 8, 51, 40)) + expect(entry.duration_minutes).to eq(2) + end + + it "is nil while open" do + expect(build(:event_attendance_time_entry, :open).duration_minutes).to be_nil + end + end + + describe "#attendance_date" do + it "is the sign-in's calendar date in the app zone" do + entry = build(:event_attendance_time_entry, signed_in_at: Time.zone.local(2026, 7, 23, 8, 50)) + expect(entry.attendance_date).to eq(Date.new(2026, 7, 23)) + end + end + + describe "scopes" do + it "separates open from closed entries" do + reg = create(:event_registration) + open_entry = create(:event_attendance_time_entry, :open, event_registration: reg) + closed_entry = create(:event_attendance_time_entry, event_registration: reg) + + expect(reg.event_attendance_time_entries.open).to contain_exactly(open_entry) + expect(reg.event_attendance_time_entries.closed).to contain_exactly(closed_entry) + end + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index f99007b349..bade2cecec 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1159,4 +1159,35 @@ def registration_for(person) expect(preloaded.paid_in_full?).to be(true) end end + + describe "attendance time entries" do + let(:registration) { create(:event_registration) } + + describe "#signed_in? / #open_attendance_entry" do + it "is signed in while an entry has no sign-out" do + entry = create(:event_attendance_time_entry, :open, event_registration: registration) + expect(registration.signed_in?).to be(true) + expect(registration.open_attendance_entry).to eq(entry) + end + + it "is not signed in once every entry is closed" do + create(:event_attendance_time_entry, event_registration: registration) + expect(registration.signed_in?).to be(false) + expect(registration.open_attendance_entry).to be_nil + end + end + + describe "#attendance_entries_on" do + it "returns that day's entries in sign-in order" do + second = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 11, 0), signed_out_at: Time.zone.local(2026, 7, 23, 12, 0)) + first = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 8, 50), signed_out_at: Time.zone.local(2026, 7, 24, 10, 0)) + + expect(registration.attendance_entries_on(Date.new(2026, 7, 23))).to eq([ first, second ]) + end + end + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index a731ecc234..5711cdeade 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -448,4 +448,56 @@ expect(create(:event, cost_cents: nil)).not_to be_scholarship_eligible end end + + describe "attendance sign-in window" do + # A two-day training running 9:00am–4:00pm each day. + let(:event) do + create(:event, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 24, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + + describe "#event_dates" do + it "lists each consecutive calendar day, inclusive" do + expect(event.event_dates).to eq([ Date.new(2026, 7, 23), Date.new(2026, 7, 24) ]) + end + + it "is empty without a start date" do + expect(build(:event, start_date: nil).event_dates).to eq([]) + end + end + + describe "#daily_start_at / #daily_end_at" do + it "applies the event's start/end time-of-day to each day" do + day2 = Date.new(2026, 7, 24) + expect(event.daily_start_at(day2)).to eq(Time.zone.local(2026, 7, 24, 9, 0)) + expect(event.daily_end_at(day2)).to eq(Time.zone.local(2026, 7, 24, 16, 0)) + end + end + + describe "#attendance_sign_in_open?" do + it "opens 30 minutes before a day's start" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 8, 30))).to be(true) + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 8, 29))).to be(false) + end + + it "stays open through the day's end time" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 16, 0))).to be(true) + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 16, 1))).to be(false) + end + + it "applies the same window to every event day" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 24, 9, 0))).to be(true) + end + + it "is closed overnight between event days" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 20, 0))).to be(false) + end + + it "is closed on non-event days" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 25, 9, 0))).to be(false) + end + end + end end From 21462f4c1a63c6ed9b74f23d375f16985d0bd77a Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:17:50 -0400 Subject: [PATCH 02/27] Add CE sign-in/out to the callout + live card reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrants sign in/out from their private CE callout once CE is paid in full: one button by state (Sign in inside the day's window, Sign out whenever an entry is open). The CE callout card shows a live nudge — "Sign in for today" (orange) or "Signed in" (teal) — mirroring the payment card's due badge. Today's entries and running total render on the CE page. Self-service is public and unattributed. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 47 ++++++++ .../event_attendance_time_entry_decorator.rb | 27 +++++ app/helpers/event_attendance_helper.rb | 11 ++ app/services/builtin_callout_cards.rb | 43 ++++++- app/views/events/callouts/ce.html.erb | 69 +++++++++++ config/routes.rb | 2 + spec/requests/events/ce_attendance_spec.rb | 111 ++++++++++++++++++ spec/services/builtin_callout_cards_spec.rb | 43 +++++++ 8 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 app/decorators/event_attendance_time_entry_decorator.rb create mode 100644 app/helpers/event_attendance_helper.rb create mode 100644 spec/requests/events/ce_attendance_spec.rb diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 64353b4487..836cd6d455 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -137,6 +137,42 @@ def request_ce redirect_to registration_ce_path(@event_registration.slug), notice: "Continuing education credit requested." end + # Record the registrant signing in from their CE callout. Self-service and + # public (no login), so created_by stays nil — only staff edits are attributed. + # Gated on CE being paid in full and the day's sign-in window being open; a + # second sign-in while already signed in is a no-op. + def sign_in_ce + return redirect_to(registration_ce_path(@event_registration.slug)) if sample_preview? + unless attendance_enabled? + return redirect_to registration_ce_path(@event_registration.slug), alert: "Signing in isn't available yet." + end + if @event_registration.signed_in? + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: "You're already signed in." + end + unless @event.attendance_sign_in_open? + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: "Sign-in is only open during the training day." + end + + entry = @event_registration.event_attendance_time_entries.create!(signed_in_at: Time.current) + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + notice: "Signed in at #{local_time(entry.signed_in_at)}." + end + + # Close the registrant's open attendance entry. Not windowed — a forgotten + # sign-out can always be recorded (staff can correct times later on the report). + def sign_out_ce + return redirect_to(registration_ce_path(@event_registration.slug)) if sample_preview? + entry = @event_registration.open_attendance_entry + unless entry + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), alert: "You're not signed in." + end + + entry.update!(signed_out_at: Time.current) + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + notice: "Signed out at #{local_time(entry.signed_out_at)}." + end + # Handouts page: callout-card links to the training worksheet/handout # resources, in display order, each opening its own registrant resource page # (PDF preview + download, with a back-to-handouts eyebrow). Cards read their @@ -189,6 +225,17 @@ def faq private + # Attendance sign-in/out is offered only once CE is paid in full — it's the CE + # sign-in sheet, so it follows the CE payment, and mirrors the callout view's gate. + def attendance_enabled? + @event_registration.ce_registered? && @event_registration.ce_paid_in_full? + end + + # A datetime rendered in the app zone as "9:02 AM", for sign-in/out flash notices. + def local_time(time) + time.in_time_zone(Time.zone).strftime("%-l:%M %p") + end + # Whether the event's built-in callout for this key is materialized and # published (visible). These public pages gate on that alone now — the admin's # published/hidden choice on the row decides whether the page is reachable, so diff --git a/app/decorators/event_attendance_time_entry_decorator.rb b/app/decorators/event_attendance_time_entry_decorator.rb new file mode 100644 index 0000000000..e8f7eb5a42 --- /dev/null +++ b/app/decorators/event_attendance_time_entry_decorator.rb @@ -0,0 +1,27 @@ +class EventAttendanceTimeEntryDecorator < ApplicationDecorator + delegate_all + + # Clock time of the sign-in, in the app zone — e.g. "8:50 AM". + def signed_in_label + format_time(signed_in_at) + end + + # Clock time of the sign-out, or an em dash while still signed in. + def signed_out_label + signed_out_at ? format_time(signed_out_at) : "—" + end + + # Elapsed time as "1h 44m" (or "44m" under an hour); "In progress" while open. + def duration_label + minutes = duration_minutes + return "In progress" unless minutes + + h.attendance_duration_label(minutes) + end + + private + + def format_time(time) + time.in_time_zone(Time.zone).strftime("%-l:%M %p") + end +end diff --git a/app/helpers/event_attendance_helper.rb b/app/helpers/event_attendance_helper.rb new file mode 100644 index 0000000000..c79e6901de --- /dev/null +++ b/app/helpers/event_attendance_helper.rb @@ -0,0 +1,11 @@ +module EventAttendanceHelper + # A minutes count as "6h 51m" (or "51m" under an hour, "0m" for zero) — how the + # CE sign-in report totals attended time, replacing the paper sheet's minute math. + def attendance_duration_label(minutes) + minutes = minutes.to_i + hours, mins = minutes.divmod(60) + return "#{mins}m" if hours.zero? + + "#{hours}h #{mins}m" + end +end diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index fdcbfdc8a5..3b7d8ac021 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -268,16 +268,47 @@ def ce_hours_card # An outstanding CE balance turns the card orange (an action card), matching # the payment card, rather than the resting teal. due = registration.continuing_education_registrations.first&.remaining_cost.to_i.positive? - Card.new(icon_class: "fa-solid fa-graduation-cap", color: due ? "orange" : "teal", + # Once CE is paid, on a training day the badge becomes a live sign-in nudge + # (like the payment card's "$X due"), overriding the resting CE status chip. + reminder = ce_attendance_reminder + Card.new(icon_class: "fa-solid fa-graduation-cap", color: ce_card_color(due, reminder), title: event.ce_hours_label, subtitle: ce_hours_subtitle, href: registration_ce_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right", - badge: ce_hours_badge(complete), - # Amber while money is due or hours/license are still needed (nil - # badge_classes falls back to amber in _callout_card); teal once it's - # complete and paid. - badge_classes: complete && !due ? "bg-teal-100 text-teal-800 border border-teal-300" : nil) + badge: ce_hours_reminder_badge(reminder) || ce_hours_badge(complete), + # Amber while money is due, hours/license are still needed, or it's time + # to sign in (nil badge_classes falls back to amber in _callout_card); + # teal once complete and paid, or while currently signed in. + badge_classes: ce_card_badge_classes(complete, due, reminder)) + end + + # The live attendance nudge for the CE card on a training day, once CE is paid — + # :signed_in while an entry is open, :sign_in while sign-in is open and they're + # not signed in, nil otherwise (so the resting CE status chip shows instead). + def ce_attendance_reminder + return unless registration.ce_paid_in_full? + return :signed_in if registration.signed_in? + + :sign_in if event.attendance_sign_in_open? + end + + def ce_hours_reminder_badge(reminder) + { signed_in: "Signed in", sign_in: "Sign in for today" }[reminder] + end + + def ce_card_color(due, reminder) + return "teal" if reminder == :signed_in + return "orange" if reminder == :sign_in || due + + "teal" + end + + def ce_card_badge_classes(complete, due, reminder) + return "bg-teal-100 text-teal-800 border border-teal-300" if reminder == :signed_in + return if reminder == :sign_in # amber default (action) via _callout_card + + complete && !due ? "bg-teal-100 text-teal-800 border border-teal-300" : nil end # Before the registrant has requested CE, an invite card linking to the CE page diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 585acf77c5..d638871b46 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -231,6 +231,75 @@ <% end %> <% end %> + + <%# + Training sign-in/out — the in-portal replacement for the paper CE hour + sign-in sheet, shown only once CE is paid in full. One button at a time: + Sign in when signed out (only inside the day's window), Sign out while signed + in (always, so a forgotten sign-out can still be closed). Many entries per day + is expected (breaks, lunch). Staff correct times on the admin CE edit page. + %> + <% if ce_registration&.paid_in_full? %> + <% signed_in = @event_registration.signed_in? %> + <% open_entry = @event_registration.open_attendance_entry %> + <% todays_entries = @event_registration.attendance_entries_on(Time.zone.today) %> + <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %> + +
+

Training sign-in

+ +
+ <% if signed_in %> + + + Signed in at <%= open_entry.decorate.signed_in_label %> + + <% if sample_preview? %> + + <% else %> + <%= button_to "Sign out", registration_ce_sign_out_path(@event_registration.slug), data: { turbo: false }, + class: "shrink-0 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 cursor-pointer" %> + <% end %> + <% elsif @event.attendance_sign_in_open? %> + Signed out + <% if sample_preview? %> + + <% else %> + <%= button_to "Sign in", registration_ce_sign_in_path(@event_registration.slug), data: { turbo: false }, + class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <% end %> + <% else %> +

Sign-in opens 30 minutes before each training day starts.

+ <% end %> +
+ + <% if todays_entries.any? %> +
+
+
Time in
+
Time out
+
Duration
+
+ <% todays_entries.each do |entry| %> + <% entry = entry.decorate %> +
+
<%= entry.signed_in_label %>
+
<%= entry.signed_out_label %>
+
<%= entry.duration_label %>
+
+ <% end %> +
+
Today's total
+
<%= attendance_duration_label(todays_minutes) %>
+
+
+ <% end %> + +

Sign in when you arrive and sign out when you leave — including breaks and lunch — so your hours are recorded accurately.

+
+ <% end %> <% else %>

You haven't requested continuing education credit for this training. <%= @event.ce_hours_cost_cents.to_i.positive? ? "CE hours are available for #{dollars_from_cents(@event.ce_hours_cost_cents)}." : "CE hours are available for an additional fee." %>

diff --git a/config/routes.rb b/config/routes.rb index c4dbdfacf6..83725e4251 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,8 @@ post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request post "registration/:slug/ce/pay", to: "events/callouts#pay_ce", as: :registration_ce_pay + post "registration/:slug/ce/sign-in", to: "events/callouts#sign_in_ce", as: :registration_ce_sign_in + post "registration/:slug/ce/sign-out", to: "events/callouts#sign_out_ce", as: :registration_ce_sign_out get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource get "registration/:slug/videoconference", to: "events/callouts#videoconference", as: :registration_videoconference diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb new file mode 100644 index 0000000000..95bf4e8268 --- /dev/null +++ b/spec/requests/events/ce_attendance_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +# Registrant self-service CE sign-in/out from the public CE callout (slug is the +# authorization, no login). The paper CE hour sign-in sheet, moved into the portal. +RSpec.describe "Events::Callouts CE attendance", type: :request do + # A one-day training running 9:00am–4:00pm; "now" is mid-morning, inside the window. + let(:event) do + create(:event, + ce_hours_offered: 6, ce_hours_cost_cents: 15_000, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 23, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let(:registration) { create(:event_registration, event: event) } + + before { travel_to Time.zone.local(2026, 7, 23, 10, 0) } + after { travel_back } + + # A CE registration paid in full — the gate for the whole attendance surface. + def pay_ce! + license = create(:professional_license, person: registration.registrant, number: "LIC123") + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:allocation, source: create(:payment), allocatable: ce, amount: ce.cost_cents) + registration.reload + end + + describe "POST /registration/:slug/ce/sign-in" do + it "records an open entry and redirects with a notice while CE is paid and in-window" do + pay_ce! + expect { + post registration_ce_sign_in_path(registration.slug) + }.to change { registration.event_attendance_time_entries.count }.by(1) + + entry = registration.event_attendance_time_entries.last + expect(entry).to be_open + expect(entry.signed_in_at).to eq(Time.current) + expect(entry.created_by).to be_nil # public self-service isn't attributed + expect(response).to redirect_to(registration_ce_path(registration.slug, anchor: "attendance")) + expect(flash[:notice]).to include("Signed in") + end + + it "does nothing when CE isn't paid in full" do + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + expect(flash[:alert]).to be_present + end + + it "does nothing outside the day's sign-in window" do + pay_ce! + travel_to Time.zone.local(2026, 7, 23, 6, 0) + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + expect(flash[:alert]).to include("training day") + end + + it "doesn't open a second entry while already signed in" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration) + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + end + end + + describe "POST /registration/:slug/ce/sign-out" do + it "closes the open entry" do + pay_ce! + entry = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.current - 1.hour) + + post registration_ce_sign_out_path(registration.slug) + + expect(entry.reload.signed_out_at).to eq(Time.current) + expect(response).to redirect_to(registration_ce_path(registration.slug, anchor: "attendance")) + expect(flash[:notice]).to include("Signed out") + end + + it "reports when there's nothing to sign out of" do + pay_ce! + post registration_ce_sign_out_path(registration.slug) + expect(flash[:alert]).to be_present + end + end + + describe "GET /registration/:slug/ce (attendance section)" do + it "shows a Sign in button once CE is paid and the window is open" do + pay_ce! + get registration_ce_path(registration.slug) + expect(response.body).to include("Training sign-in") + expect(response.body).to include("Sign in") + end + + it "shows a Sign out button and today's entries while signed in" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.current - 30.minutes) + get registration_ce_path(registration.slug) + expect(response.body).to include("Sign out") + expect(response.body).to include("Signed in at") + end + + it "hides the attendance section until CE is paid in full" do + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + get registration_ce_path(registration.slug) + expect(response.body).not_to include("Training sign-in") + end + end +end diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index 52ac877ff9..c4246a5d53 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -303,4 +303,47 @@ def add_scholarship_form(event) expect(card.badge).to eq("$150 due by Aug 15") # app keeps the live deadline badge end end + + describe "CE card attendance reminder" do + # Paid CE registration on a training day so the sign-in nudge is live. + let(:event) do + create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 15_000, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 23, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + + before { travel_to Time.zone.local(2026, 7, 23, 10, 0) } + after { travel_back } + + def pay_ce! + license = create(:professional_license, person: registration.registrant, number: "LIC123") + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:allocation, source: create(:payment), allocatable: ce, amount: ce.cost_cents) + registration.reload + end + + it "nudges to sign in during the window once paid, in orange" do + pay_ce! + ce_card = card(registration, event.ce_hours_label) + expect(ce_card.badge).to eq("Sign in for today") + expect(ce_card.theme).to eq(DomainTheme.swatch("orange")) + end + + it "shows a signed-in chip while an entry is open, in teal" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration) + ce_card = card(registration.reload, event.ce_hours_label) + expect(ce_card.badge).to eq("Signed in") + expect(ce_card.theme).to eq(DomainTheme.swatch("teal")) + expect(ce_card.badge_classes).to include("teal") + end + + it "falls back to the resting CE status badge outside the sign-in window" do + pay_ce! + travel_to Time.zone.local(2026, 7, 23, 20, 0) + # Fully paid with no license number needed → no resting badge, and no nudge. + expect(card(registration.reload, event.ce_hours_label).badge).to be_nil + end + end end From 9f8cdb4943628eb170a51fbe28dcf34052df56bd Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:24:07 -0400 Subject: [PATCH 03/27] Let staff edit attendance times on the CE edit form Add editable in/out rows (correct, add, remove) to the CE registration edit page, mapped onto the registration's attendance entries and attributed to the editing admin via created_by/updated_by. Surfaces the failing record's validation errors on save (e.g. sign-out before sign-in) instead of an empty alert. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...uing_education_registrations_controller.rb | 34 +++++++++-- .../_attendance_entries.html.erb | 60 +++++++++++++++++++ .../edit.html.erb | 1 + ...continuing_education_registrations_spec.rb | 58 ++++++++++++++++++ 4 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 app/views/continuing_education_registrations/_attendance_entries.html.erb diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index de76f7eaa2..07fc94f5a1 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -22,8 +22,8 @@ def create @ce_registration.save! end redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = e.record.errors.full_messages.to_sentence render :new, status: :unprocessable_content end @@ -37,10 +37,11 @@ def update ActiveRecord::Base.transaction do apply_ce_params(@ce_registration) @ce_registration.save! + apply_time_entries(@ce_registration.event_registration) end redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = e.record.errors.full_messages.to_sentence render :edit, status: :unprocessable_content end @@ -96,4 +97,29 @@ def apply_ce_params(ce_registration) .permit(comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ])[:comments_attributes] ce_registration.comments_attributes = comments if comments.present? end + + # Staff corrections to the registrant's attendance times, submitted alongside the + # CE form under continuing_education_registration[time_entries]. Mapped onto the + # registration's nested-attributes setter (create/update/destroy), then attributed + # to current_user — these are the only attributed entries (self-service is not). + def apply_time_entries(registration) + rows = time_entries_attributes + return if rows.blank? + + registration.assign_attributes(event_attendance_time_entries_attributes: rows) + registration.event_attendance_time_entries.each do |entry| + next if entry.marked_for_destruction? + + entry.created_by ||= current_user if entry.new_record? + entry.updated_by = current_user if entry.new_record? || entry.changed? + end + registration.save! + end + + def time_entries_attributes + params.fetch(:continuing_education_registration, {}) + .permit(time_entries: [ :id, :signed_in_at, :signed_out_at, :_destroy ]) + .fetch(:time_entries, {}) + .values + end end diff --git a/app/views/continuing_education_registrations/_attendance_entries.html.erb b/app/views/continuing_education_registrations/_attendance_entries.html.erb new file mode 100644 index 0000000000..198eab353f --- /dev/null +++ b/app/views/continuing_education_registrations/_attendance_entries.html.erb @@ -0,0 +1,60 @@ +<%# + Staff-editable attendance times, submitted with the CE form under + continuing_education_registration[time_entries][…] and mapped onto the + registration's event_attendance_time_entries in the controller. Registrants log + their own in/out on the CE callout; here staff correct a forgotten sign-out or a + wrong time, remove a stray entry, or backfill a missed one. Times are in the + event's local (Pacific) zone, matching the callout and report. + locals: registration (EventRegistration). +%> +<% entries = registration.event_attendance_time_entries.chronological.to_a %> +<% dt = ->(time) { time&.in_time_zone(Time.zone)&.strftime("%Y-%m-%dT%H:%M") } %> +<% input_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + +
+
+ + + +

Attendance sign-in times

+
+ +
+ + + <% entries.each_with_index do |entry, i| %> +
+ + + +
<%= entry.decorate.duration_label %>
+ +
+ <% end %> + + <%# Blank rows to add entries (leave empty to ignore). Save again for more. %> + <% 3.times do |j| %> + <% idx = entries.size + j %> +
+ + +
+
+
+ <% end %> + +

Add an entry by filling a blank row. Tick Remove to delete one. Sign-out must be after sign-in.

+
+
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index 78d3ca80a2..ed408ec21d 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -33,6 +33,7 @@ <%= simple_form_for @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), html: { id: "ce_registration_form", data: { turbo: false } } do |f| %> <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> + <%= render "attendance_entries", registration: registration %> <%= render "payment_history", ce_registration: @ce_registration %> <%# ---- Comments (saved with the CE registration, like the other forms) ---- %> diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index eb9e624721..362d8774cf 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -192,6 +192,64 @@ expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(true) expect(flash[:alert]).to match(/has payments/) end + + describe "attendance time entries" do + it "adds an entry from a blank row, attributed to the editing admin" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(1) + + entry = registration.event_attendance_time_entries.last + # Datetime-local values are parsed in the editing admin's zone (Pacific). + pt = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + expect(entry.signed_in_at.in_time_zone(pt).strftime("%FT%R")).to eq("2026-07-23T08:50") + expect(entry.signed_out_at.in_time_zone(pt).strftime("%FT%R")).to eq("2026-07-23T10:34") + expect(entry.created_by).to eq(admin) + expect(entry.updated_by).to eq(admin) + end + + it "corrects an existing entry's time and stamps updated_by" do + entry = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 0)) + + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: entry.id, signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34" } } } } + + expect(entry.reload.signed_out_at.in_time_zone("Pacific Time (US & Canada)").strftime("%FT%R")).to eq("2026-07-23T10:34") + expect(entry.updated_by).to eq(admin) + end + + it "removes an entry when its _destroy box is ticked" do + entry = create(:event_attendance_time_entry, event_registration: registration) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: entry.id, signed_in_at: "2026-07-23T08:50", _destroy: "1" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(-1) + end + + it "ignores blank rows" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "", signed_out_at: "" } } } } + }.not_to change { registration.event_attendance_time_entries.count } + end + + it "rejects a sign-out before the sign-in with a helpful error" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T10:00", signed_out_at: "2026-07-23T09:00" } } } } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to match(/after the sign-in/) + expect(registration.event_attendance_time_entries).to be_empty + end + end end it "forbids non-admins" do From 6371edcbbfd2c0d5656ba830266a280ec123a062 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:29:21 -0400 Subject: [PATCH 04/27] Add per-event attendance report, linked from participation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventAttendanceReport groups a training's sign-in/out entries by day then registrant, with per-day and grand-total minutes — the in-portal CE hour sign-in sheet. `?ce=true` scopes to CE registrants and shows license number and awarded hours; the generic view covers anyone who logged time. Reached at attendance_event_path (dashboard-level auth) and linked from CE-eligible events on the participation report. Also shows a "X of Y signed in" nudge for chasing sign-ins during the training. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + app/controllers/events_controller.rb | 10 +- app/policies/event_policy.rb | 6 + app/services/event_attendance_report.rb | 94 +++++++++++++ app/views/events/attendance.html.erb | 127 ++++++++++++++++++ app/views/events/participation.html.erb | 10 +- config/routes.rb | 1 + spec/requests/events/attendance_spec.rb | 47 +++++++ spec/services/event_attendance_report_spec.rb | 91 +++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 10 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 app/services/event_attendance_report.rb create mode 100644 app/views/events/attendance.html.erb create mode 100644 spec/requests/events/attendance_spec.rb create mode 100644 spec/services/event_attendance_report_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 9150c10a3a..9ab1d0c73e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,7 @@ action, or `authorize! :workshop, to: :summary?`). - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards - `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventAttendanceReport` — Per-event attendance sign-in/out report from `EventAttendanceTimeEntry`, grouped by day then registrant with per-day and grand-total minutes; `ce_only:` scopes to CE registrants and surfaces license number + awarded hours. The in-portal CE hour sign-in sheet, linked from the participation report (`?ce=true`) at `attendance_event_path` - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 3baa2c9cc5..bf55fead79 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -2,7 +2,7 @@ class EventsController < ApplicationController include AhoyTracking, TagAssignable skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] - before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ] + before_action :set_event, only: %i[ show edit update destroy preview dashboard attendance sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ] before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ] def index @@ -57,6 +57,14 @@ def scholarships @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end + # Per-event attendance sign-in/out report, grouped by day then registrant — the + # in-portal CE hour sign-in sheet. `?ce=true` scopes to CE registrants and shows + # their license number and awarded hours. + def attendance + authorize! @event + @report = EventAttendanceReport.new(@event, ce_only: params[:ce] == "true") + end + def new authorize! @event = Event.new.decorate diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 0c23915d1b..224abba927 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -87,6 +87,12 @@ def dashboard? admin? || owner? end + # The per-event attendance (CE sign-in) report shows registrant PII, so it's + # gated like the dashboard — admins and the event's owner. + def attendance? + admin? || owner? + end + def background? admin? || owner? end diff --git a/app/services/event_attendance_report.rb b/app/services/event_attendance_report.rb new file mode 100644 index 0000000000..fd2bbda336 --- /dev/null +++ b/app/services/event_attendance_report.rb @@ -0,0 +1,94 @@ +# Attendance sign-in/out for one event, grouped by day then registrant — the +# in-portal record that replaces AWBW's paper CE hour sign-in sheet (per-day tabs, +# each in/out pair, minutes totalled by staff). Generic: `ce_only:` scopes to CE +# registrants and surfaces their license number and awarded hours (what the CE +# board audits); the plain report covers anyone who logged time on any event day. +# +# All grouping/totals run over preloaded associations in Ruby (a training is a few +# dozen people with a handful of entries each), so building the whole report is a +# fixed handful of queries. Times use Time.zone — during a request that's the +# viewing admin's zone (Pacific), matching the callout and the paper sheet. +class EventAttendanceReport + def initialize(event, ce_only: false) + @event = event + @ce_only = ce_only + end + + attr_reader :event + + def ce_only? + @ce_only + end + + # The event's calendar days — the report's top-level grouping (Day 1, Day 2, …). + def dates + event.event_dates + end + + # Reported registrations, sorted by registrant name. The CE report lists every CE + # registrant even before they've logged anything (so staff can chase sign-ins + # during the training); the generic report lists only people who logged time. + def registrations + @registrations ||= scoped_registrations.sort_by { |reg| reg.registrant.full_name.to_s.downcase } + end + + def any? + registrations.any? + end + + # Whether anyone logged any time at all (the report can list CE registrants with + # no entries, so registrations.any? isn't the same question). + def any_entries? + registrations.any? { |reg| reg.event_attendance_time_entries.any? } + end + + # One registration's entries on one date, decorated and in sign-in order. + def entries_for(registration, date) + entries_on(registration, date).sort_by(&:signed_in_at).map(&:decorate) + end + + def day_minutes(registration, date) + entries_on(registration, date).sum { |entry| entry.duration_minutes.to_i } + end + + def total_minutes(registration) + registration.event_attendance_time_entries.sum { |entry| entry.duration_minutes.to_i } + end + + def grand_total_minutes + registrations.sum { |reg| total_minutes(reg) } + end + + # A registration with an entry still open (signed in, no sign-out) — flagged on + # the report so a forgotten sign-out is fixable rather than silently under-counted. + def open?(registration) + registration.event_attendance_time_entries.any?(&:open?) + end + + # CE-only columns. + def license_numbers(registration) + registration.continuing_education_registrations.filter_map { |ce| ce.professional_license&.number }.uniq + end + + def ce_hours(registration) + registration.continuing_education_registrations.sum { |ce| ce.hours.to_d } + end + + private + + def entries_on(registration, date) + registration.event_attendance_time_entries.select { |entry| entry.attendance_date == date } + end + + def scoped_registrations + list = event.event_registrations + .includes(:registrant, :event_attendance_time_entries, + continuing_education_registrations: :professional_license) + .to_a + if ce_only? + list.select { |reg| reg.continuing_education_registrations.any? } + else + list.select { |reg| reg.event_attendance_time_entries.any? } + end + end +end diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb new file mode 100644 index 0000000000..9eddffc64b --- /dev/null +++ b/app/views/events/attendance.html.erb @@ -0,0 +1,127 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:full_width, true) %> +<% title = @report.ce_only? ? "CE sign-in report" : "Attendance sign-in" %> +<% content_for(:page_title, "#{title} — #{@event.title}") %> +<% event = @event.decorate %> + +
+
+
+ <%# Return to wherever the report was opened from (the participation report by + default; the event dashboard when linked from there). %> + <% if params[:return_to] == "dashboard" %> + <%= link_to "← Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% else %> + <%= link_to "← Events participation", participation_events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> + <% end %> +
+ +
+
+

<%= title %>

+

<%= @event.title %> · <%= event.date_range %>

+
+ <% if @report.ce_only? %> + + + Continuing education + + <% end %> +
+ + <% if @report.any? %> + <% @report.dates.each_with_index do |date, index| %> + <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %> +
+
+

Day <%= index + 1 %> · <%= date.strftime("%A, %b %-d") %>

+ <%= signed_in_count %> of <%= @report.registrations.size %> signed in +
+ +
+
"> +
Name
+ <% if @report.ce_only? %>
License #
<% end %> +
Sessions
+
Day total
+
+ +
+ <% @report.registrations.each do |reg| %> + <% entries = @report.entries_for(reg, date) %> +
"> +
+ <%= reg.registrant.full_name %> + <% if @report.open?(reg) %> + signed in + <% end %> +
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+ <% end %> +
+ <% if entries.any? %> + <% entries.each do |entry| %> + + <%= entry.signed_in_label %>–<%= entry.signed_out_label %> · <%= entry.duration_label %> + + <% end %> + <% else %> + Not signed in + <% end %> +
+
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
+
+ <% end %> +
+
+
+ <% end %> + + <%# Totals across all days — the figure the CE board certifies, alongside the + hours the event awards so staff can reconcile the two. %> +
+

Totals

+
+
"> +
Name
+ <% if @report.ce_only? %>
License #
Hours awarded
<% end %> +
Total logged
+
+
+ <% @report.registrations.each do |reg| %> +
"> +
<%= reg.registrant.full_name %>
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+
<%= plain_number(@report.ce_hours(reg)) %>
+ <% end %> +
<%= attendance_duration_label(@report.total_minutes(reg)) %>
+
+ <% end %> +
+
"> +
All registrants
+ <% if @report.ce_only? %>
<% end %> +
<%= attendance_duration_label(@report.grand_total_minutes) %>
+
+
+
+ +

+ Registrants sign in and out from their private CE page; times here are in Pacific. + A signed in tag means an open entry with no sign-out yet — + correct it on the registrant's CE edit page. Totals sum every completed in/out pair. +

+ <% else %> +
+ <%= @report.ce_only? ? "No one has registered for CE credit on this event yet." : "No attendance has been logged for this event yet." %> +
+ <% end %> +
+
diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index c20eca07da..1089b48dd7 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -154,8 +154,14 @@ <% end %> <% end %> - <%= link_to "Open event dashboard →", dashboard_event_path(row.event), - class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ <%= link_to "Open event dashboard →", dashboard_event_path(row.event), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> + <% if row.event.ce_eligible? %> + <%= link_to "CE sign-in report →", attendance_event_path(row.event, ce: "true", return_to: "participation"), + class: "text-xs font-medium text-teal-700 hover:underline" %> + <% end %> +
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 83725e4251..f19b7dbef7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -158,6 +158,7 @@ end member do get :dashboard + get :attendance get :sample_ticket # Admin-only in-memory previews of the behavioral built-in callout pages, # linked from the sample ticket. They reuse Events::CalloutsController's diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb new file mode 100644 index 0000000000..5874e33eaa --- /dev/null +++ b/spec/requests/events/attendance_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "Events attendance report", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) do + create(:event, ce_hours_offered: 6, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 23, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let(:registration) do + create(:event_registration, event: event, registrant: create(:person, first_name: "Alice", last_name: "Adams")) + end + + def log_ce_time! + license = create(:professional_license, person: registration.registrant, number: "AAA111") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + end + + describe "as an admin" do + before { sign_in admin } + + it "renders the CE report with license number and hours when ce=true" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response).to have_http_status(:ok) + expect(response.body).to include("CE sign-in report") + expect(response.body).to include("Alice Adams") + expect(response.body).to include("AAA111") + end + + it "renders the generic attendance report without CE scoping" do + get attendance_event_path(event) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Attendance sign-in") + expect(response.body).not_to include("CE sign-in report") + end + end + + it "forbids users who are neither admin nor the event owner" do + sign_in create(:user) + get attendance_event_path(event, ce: "true") + expect(response).not_to have_http_status(:ok) + end +end diff --git a/spec/services/event_attendance_report_spec.rb b/spec/services/event_attendance_report_spec.rb new file mode 100644 index 0000000000..c8739141dd --- /dev/null +++ b/spec/services/event_attendance_report_spec.rb @@ -0,0 +1,91 @@ +require "rails_helper" + +RSpec.describe EventAttendanceReport do + # A two-day training, 9:00am–4:00pm each day. + let(:event) do + create(:event, ce_hours_offered: 6, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 24, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + + def registration_for(first, last) + create(:event_registration, event: event, registrant: create(:person, first_name: first, last_name: last)) + end + + def make_ce(registration, number:, hours: 6) + license = create(:professional_license, person: registration.registrant, number: number) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: hours) + end + + def entry(registration, in_at, out_at) + create(:event_attendance_time_entry, event_registration: registration, signed_in_at: in_at, signed_out_at: out_at) + end + + describe "#dates" do + it "is each event day" do + expect(described_class.new(event).dates).to eq([ Date.new(2026, 7, 23), Date.new(2026, 7, 24) ]) + end + end + + describe "CE report (ce_only: true)" do + let!(:alice) { registration_for("Alice", "Adams") } + let!(:bob) { registration_for("Bob", "Baker") } + let!(:carol) { registration_for("Carol", "Cole") } + + before do + make_ce(alice, number: "AAA111") + make_ce(bob, number: "BBB222") + # Carol is not a CE registrant — excluded from the CE report. + entry(alice, Time.zone.local(2026, 7, 23, 8, 50), Time.zone.local(2026, 7, 23, 10, 34)) # 104m + entry(alice, Time.zone.local(2026, 7, 23, 10, 44), Time.zone.local(2026, 7, 23, 12, 8)) # 84m + entry(alice, Time.zone.local(2026, 7, 24, 9, 0), Time.zone.local(2026, 7, 24, 16, 0)) # 420m + entry(carol, Time.zone.local(2026, 7, 23, 9, 0), Time.zone.local(2026, 7, 23, 10, 0)) + end + + subject(:report) { described_class.new(event, ce_only: true) } + + it "lists every CE registrant sorted by name, even with no entries yet" do + expect(report.registrations).to eq([ alice, bob ]) + end + + it "groups a registrant's entries by day in sign-in order" do + day1 = report.entries_for(alice, Date.new(2026, 7, 23)) + expect(day1.map(&:signed_in_label)).to eq([ "8:50 AM", "10:44 AM" ]) + end + + it "totals minutes per day and overall" do + expect(report.day_minutes(alice, Date.new(2026, 7, 23))).to eq(188) + expect(report.day_minutes(alice, Date.new(2026, 7, 24))).to eq(420) + expect(report.total_minutes(alice)).to eq(608) + expect(report.total_minutes(bob)).to eq(0) + expect(report.grand_total_minutes).to eq(608) + end + + it "surfaces license numbers and awarded hours" do + expect(report.license_numbers(alice)).to eq([ "AAA111" ]) + expect(report.ce_hours(alice)).to eq(6) + end + + it "flags a registrant with an open (not signed out) entry" do + create(:event_attendance_time_entry, :open, event_registration: bob) + expect(report.open?(bob)).to be(true) + expect(report.open?(alice)).to be(false) + end + end + + describe "generic report (ce_only: false)" do + let!(:alice) { registration_for("Alice", "Adams") } + let!(:carol) { registration_for("Carol", "Cole") } + + before do + registration_for("Bob", "Baker") # no entries → excluded + entry(alice, Time.zone.local(2026, 7, 23, 8, 50), Time.zone.local(2026, 7, 23, 10, 34)) + entry(carol, Time.zone.local(2026, 7, 23, 9, 0), Time.zone.local(2026, 7, 23, 10, 0)) + end + + it "lists only registrations that logged time" do + expect(described_class.new(event).registrations).to eq([ alice, carol ]) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index dd5f31fec7..1a8d193a52 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -108,6 +108,7 @@ "app/views/categories/index.html.erb" => "admin-only bg-blue-100", "app/views/category_types/index.html.erb" => "admin-only bg-blue-100", "app/views/events/dashboard.html.erb" => "admin-only bg-blue-100", + "app/views/events/attendance.html.erb" => "admin-only bg-blue-100", "app/views/events/sample_ticket.html.erb" => "admin-only bg-blue-100", "app/views/events/bulk_payments/index.html.erb" => "admin-only bg-blue-100", "app/views/events/background.html.erb" => "admin-only bg-blue-100", From 75ad435ae8626bd38ed7ec9751677004affa68c0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:31:06 -0400 Subject: [PATCH 05/27] Cover EventPolicy#attendance? in the policy spec Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/policies/event_policy_spec.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/policies/event_policy_spec.rb b/spec/policies/event_policy_spec.rb index fa3a1d7f15..81250f61e3 100644 --- a/spec/policies/event_policy_spec.rb +++ b/spec/policies/event_policy_spec.rb @@ -174,6 +174,34 @@ def policy_for(record: nil, user:) end end + describe "#attendance?" do + let(:owned_event) { build_stubbed :event, created_by: regular_user } + + context "with admin user" do + subject { policy_for(record: published_event, user: admin_user) } + + it { is_expected.to be_allowed_to(:attendance?) } + end + + context "with owner" do + subject { policy_for(record: owned_event, user: regular_user) } + + it { is_expected.to be_allowed_to(:attendance?) } + end + + context "with non-owner regular user" do + subject { policy_for(record: published_event, user: regular_user) } + + it { is_expected.not_to be_allowed_to(:attendance?) } + end + + context "with no user" do + subject { policy_for(record: published_event, user: nil) } + + it { is_expected.not_to be_allowed_to(:attendance?) } + end + end + describe "#form_submissions?" do let(:owned_event) { build_stubbed :event, created_by: regular_user } From 3761822910411043ee846c941984acbd9f42bc93 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 23:29:09 -0400 Subject: [PATCH 06/27] Gate CE certificate on logged time approximating awarded hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When attendance time has been tracked for a CE registrant, the certificate now also requires the logged minutes to cover ~90% of the awarded contact hours — you can't certify hours the sign-in sheet doesn't support. Events that never tracked time (no entries) are unaffected: day-level attendance alone still governs, so this never retroactively blocks existing certificates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../continuing_education_registration.rb | 27 +++++++++++++++++-- app/models/event_registration.rb | 7 +++++ .../continuing_education_registration_spec.rb | 21 +++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 4b5bee69b2..0c420eff38 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -40,13 +40,36 @@ class ContinuingEducationRegistration < ApplicationRecord # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. + # The logged sign-in time must cover at least this fraction of the awarded CE + # contact hours before the certificate unlocks — a little slack for slightly-late + # sign-ins/early sign-outs. You can't certify hours the sign-in sheet doesn't support. + ATTENDANCE_COVERAGE_THRESHOLD = 0.9 + # CE certificate eligibility — its own rule (not shared): the event grants CE, - # the registrant attended, the training has ended, and the CE balance is paid. + # the registrant attended, the training has ended, the CE balance is paid, and + # (when attendance was tracked) the logged time approximately covers the hours. def certificate_available? event = event_registration&.event return false unless event&.ce_eligible? + return false unless event.end_date&.past? && event_registration.attended? && paid_in_full? + + attendance_time_sufficient? + end + + # When attendance time has been logged for this registrant, it must approximately + # cover the awarded hours before the certificate unlocks. With nothing logged (the + # portal sign-in wasn't used for this event), day-level attendance alone governs, + # so this doesn't block — it never retroactively gates events that never tracked time. + def attendance_time_sufficient? + logged = event_registration.attendance_minutes_total + return true if logged.zero? + + logged >= required_attendance_minutes + end - event.end_date&.past? && event_registration.attended? && paid_in_full? + # Minutes of logged attendance needed to certify the awarded hours (with tolerance). + def required_attendance_minutes + (hours.to_d * 60 * ATTENDANCE_COVERAGE_THRESHOLD).round end # Point this registration at a license for the typed type + number. `license_id` diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 5a441958b7..eacb678797 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -505,6 +505,13 @@ def attendance_entries_on(date) event_attendance_time_entries.chronological.select { |entry| entry.attendance_date == date } end + # Total completed (signed-out) attendance minutes across all days — the figure the + # CE certificate gate compares against the awarded hours. Open entries contribute + # nothing until they're signed out. + def attendance_minutes_total + event_attendance_time_entries.sum { |entry| entry.duration_minutes.to_i } + end + # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index 516bab7581..ddba9c232d 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -122,6 +122,27 @@ def ce_reg_for(event:, status:, cost_cents: 0) expect(ce_reg_for(event: event, status: "attended", cost_cents: 10_000).certificate_available?).to be(false) end + it "requires logged attendance to approximately cover the awarded hours once time is tracked" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + ce_reg = ce_reg_for(event: event, status: "attended") # 6h awarded → needs 324 min (90%) + reg = ce_reg.event_registration + + # Only 5 hours (300 min) logged — short of the 324-minute threshold. + create(:event_attendance_time_entry, event_registration: reg, + signed_in_at: 2.days.ago.change(hour: 9), signed_out_at: 2.days.ago.change(hour: 14)) + expect(ce_reg.certificate_available?).to be(false) + + # Another 30 minutes clears the threshold (330 ≥ 324). + create(:event_attendance_time_entry, event_registration: reg, + signed_in_at: 2.days.ago.change(hour: 14), signed_out_at: 2.days.ago.change(hour: 14, min: 30)) + expect(ce_reg.reload.certificate_available?).to be(true) + end + + it "isn't gated on logged time when no attendance was tracked" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(true) + end + it "records delivery via certificate_sent_at" do ce_reg = create(:continuing_education_registration) expect(ce_reg.certificate_sent?).to be(false) From 50dc567b925b8dd27248ef6ae055fcf1bdad397d Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 23:29:09 -0400 Subject: [PATCH 07/27] Link the attendance report from registrants + add per-CE edit Add a "CE sign-in report" entry to the registrants bulk-actions dropdown (CE events only), returning to the registrants page. On the report, each CE registrant gets an Edit link to their CE edit page. Report exposes ce_registration_for. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_attendance_report.rb | 6 ++++++ app/views/events/_bulk_actions_menu.html.erb | 3 +++ app/views/events/attendance.html.erb | 16 ++++++++++++++-- spec/requests/events/attendance_spec.rb | 12 ++++++++++++ spec/requests/events_spec.rb | 17 +++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/services/event_attendance_report.rb b/app/services/event_attendance_report.rb index fd2bbda336..706febc4d6 100644 --- a/app/services/event_attendance_report.rb +++ b/app/services/event_attendance_report.rb @@ -74,6 +74,12 @@ def ce_hours(registration) registration.continuing_education_registrations.sum { |ce| ce.hours.to_d } end + # The registration's CE record, for the report's per-row "Edit" link to the CE + # edit page. Nil for a non-CE registrant on the generic report. + def ce_registration_for(registration) + registration.continuing_education_registrations.first + end + private def entries_on(registration, date) diff --git a/app/views/events/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 54a03473bc..7687d7eb63 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -13,6 +13,9 @@ data-dropdown-target="content" class="hidden absolute right-0 z-10 mt-1 bg-white border border-gray-200 rounded-md shadow-lg py-1 min-w-[180px]"> <%= link_to "Onboarding tracker", onboarding_event_path(@event), class: item_class %> + <% if @event.ce_eligible? %> + <%= link_to "CE sign-in report", attendance_event_path(@event, ce: "true", return_to: "registrants"), class: item_class %> + <% end %> <%= link_to "Send bulk emails", preview_reminder_event_path(@event), class: item_class %> <%= link_to "Bulk payments", bulk_payments_event_path(@event), class: item_class %> <%= link_to registrants_event_path(@event, format: :csv), class: item_class, data: { turbo_frame: "_top" } do %> diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index 9eddffc64b..9aab27e45c 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -9,8 +9,11 @@
<%# Return to wherever the report was opened from (the participation report by default; the event dashboard when linked from there). %> - <% if params[:return_to] == "dashboard" %> + <% case params[:return_to] %> + <% when "dashboard" %> <%= link_to "← Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% when "registrants" %> + <%= link_to "← Registrants", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> <% else %> <%= link_to "← Events participation", participation_events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> <% end %> @@ -95,7 +98,16 @@ <% @report.registrations.each do |reg| %>
"> -
<%= reg.registrant.full_name %>
+
+ <%= reg.registrant.full_name %> + <% ce_reg = @report.ce_registration_for(reg) %> + <% if @report.ce_only? && ce_reg %> + <%= link_to edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), + class: "shrink-0 inline-flex items-center gap-1 text-xs text-teal-700 hover:underline" do %> + Edit + <% end %> + <% end %> +
<% if @report.ce_only? %>
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
<%= plain_number(@report.ce_hours(reg)) %>
diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index 5874e33eaa..5c29a1b5ad 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -37,6 +37,18 @@ def log_ce_time! expect(response.body).to include("Attendance sign-in") expect(response.body).not_to include("CE sign-in report") end + + it "shows a per-registrant Edit link to the CE edit page in the CE report" do + log_ce_time! + ce = registration.continuing_education_registrations.first + get attendance_event_path(event, ce: "true") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "returns to the registrants page when opened from there" do + get attendance_event_path(event, ce: "true", return_to: "registrants") + expect(response.body).to include("← Registrants") + end end it "forbids users who are neither admin nor the event owner" do diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 8a0530fcbf..9d38e067e8 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -994,6 +994,23 @@ def add_ce_registrant(target_event) end end + context "CE sign-in report link in bulk actions" do + it "links to the CE attendance report for a CE-eligible event" do + event.update!(ce_hours_offered: 6) + get registrants_event_path(event) + + expect(response.body).to include("CE sign-in report") + expect(response.body).to include(attendance_event_path(event)) + end + + it "omits the link when the event offers no CE" do + event.update!(ce_hours_offered: 0) + get registrants_event_path(event) + + expect(response.body).not_to include("CE sign-in report") + end + end + context "with unknown filter params" do it "does not crash on an invalid payment_status" do get registrants_event_path(event, payment_status: "bogus") From 04d906c639f7c123ce7c971c9b501a7466052e19 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 01:12:48 -0400 Subject: [PATCH 08/27] Address review: report round-trip, 5-day cap warning, index + dead code - CE edit opened from the sign-in report now returns there (eyebrow, Cancel, and after-save/destroy redirects all honor return_to=attendance, anchored to the totals section) - The report warns when an event outruns event_dates' 5-day cap, so missing day sections aren't mistaken for missing data - Drop the redundant single-column FK index (the composite covers it, declared in-table so MySQL doesn't auto-create one) and the unused EventAttendanceReport#any_entries? Co-Authored-By: Claude Fable 5 --- ...uing_education_registrations_controller.rb | 15 +++++++++++++-- app/services/event_attendance_report.rb | 12 ++++++++---- .../edit.html.erb | 16 +++++++++++++--- app/views/events/attendance.html.erb | 12 ++++++++++-- ...38_create_event_attendance_time_entries.rb | 12 ++++++++---- db/schema.rb | 1 - ...continuing_education_registrations_spec.rb | 19 +++++++++++++++++++ spec/requests/events/attendance_spec.rb | 6 ++++++ spec/services/event_attendance_report_spec.rb | 14 ++++++++++++++ 9 files changed, 91 insertions(+), 16 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 07fc94f5a1..d0964f778b 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -39,7 +39,7 @@ def update @ce_registration.save! apply_time_entries(@ce_registration.event_registration) end - redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other + redirect_to after_ce_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other rescue ActiveRecord::RecordInvalid => e flash.now[:alert] = e.record.errors.full_messages.to_sentence render :edit, status: :unprocessable_content @@ -55,7 +55,7 @@ def destroy registration = @ce_registration.event_registration @ce_registration.destroy! - redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other + redirect_to after_ce_path(registration), notice: "CE registration removed.", status: :see_other end def toggle_certificate @@ -68,6 +68,17 @@ def toggle_certificate private + # After save/destroy, return to the CE sign-in report when the page was opened + # from there (return_to=attendance, kept in sync with the edit view's eyebrow); + # otherwise the registration edit page. + def after_ce_path(registration) + if params[:return_to] == "attendance" + attendance_event_path(registration.event, ce: "true", anchor: "totals") + else + edit_event_registration_path(registration) + end + end + def set_ce_registration @ce_registration = ContinuingEducationRegistration.find(params[:id]) end diff --git a/app/services/event_attendance_report.rb b/app/services/event_attendance_report.rb index 706febc4d6..96b2a1da20 100644 --- a/app/services/event_attendance_report.rb +++ b/app/services/event_attendance_report.rb @@ -36,10 +36,14 @@ def any? registrations.any? end - # Whether anyone logged any time at all (the report can list CE registrants with - # no entries, so registrations.any? isn't the same question). - def any_entries? - registrations.any? { |reg| reg.event_attendance_time_entries.any? } + # Whether the event actually runs past the last reported date — event_dates is + # capped at 5 days (Event#day_count's clamp), so a longer event has no sign-in + # window or day section past day 5. The view warns when this is true. + def dates_truncated? + last_day = event.end_date&.in_time_zone(Time.zone)&.to_date + return false unless last_day && dates.any? + + last_day > dates.last end # One registration's entries on one date, decorated and in sign-in order. diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index ed408ec21d..fbdbbb09ba 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -1,12 +1,22 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> <% registration = @ce_registration.event_registration %> <% license = @ce_registration.professional_license %> +<%# Back to wherever this page was opened from: the CE sign-in report passes + return_to=attendance (kept in sync with the controller's after-save/destroy + redirects); the default origin is the registration edit page. %> +<% if params[:return_to] == "attendance" %> + <% back_path = attendance_event_path(registration.event, ce: "true", anchor: "totals") %> + <% back_label = "CE sign-in report" %> +<% else %> + <% back_path = edit_event_registration_path(registration) %> + <% back_label = "Registration" %> +<% end %>
<%# Top bar: back link + secondary links, matching the scholarship edit page %>
- <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> - Registration + <%= link_to back_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= back_label %> <% end %>
<%# Admin jump to the registrant-facing CE callout (what the registrant sees / @@ -116,7 +126,7 @@ <% end %>
- <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> + <%= link_to "Cancel", back_path, class: "btn btn-secondary-outline" %> <%# Submits the CE details form above (which the certificate section sits outside of). %>
diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index 9aab27e45c..2531fe7937 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -32,6 +32,13 @@ <% end %>
+ <% if @report.dates_truncated? %> +
+ +

This event runs past <%= @report.dates.last.strftime("%b %-d") %>, but sign-in and this report cover only the first 5 days. Times entered for later days count toward totals without a day section.

+
+ <% end %> + <% if @report.any? %> <% @report.dates.each_with_index do |date, index| %> <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %> @@ -84,8 +91,9 @@ <% end %> <%# Totals across all days — the figure the CE board certifies, alongside the - hours the event awards so staff can reconcile the two. %> -
+ hours the event awards so staff can reconcile the two. The id anchors the + return trip from the per-row CE edit links. %> +

Totals

Date: Tue, 4 Aug 2026 01:50:20 -0400 Subject: [PATCH 09/27] Harden CE attendance edits and clarify the sign-in gating A stale remove (a second tab, browser-back on the turbo:false form, or a double-submit) sent a _destroy for an entry that was already gone, so nested attributes raised RecordNotFound and 500'd the save. Drop rows pointing at entries no longer on the registration so a stale remove is a quiet no-op. Also: one blank add-row instead of three (only saved entries get a Remove box, so three empties read as broken); the three form sections were touching, so wrap them in space-y-6; and when the sign-in window isn't open, show a standard gating notice naming the concrete opening (event zone, labeled) plus the rule, and hide the arrive/leave hint until sign-in is actually usable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/copilot-instructions.md | 1 + CLAUDE.md | 1 + ...uing_education_registrations_controller.rb | 7 ++++++ app/models/event.rb | 8 +++++++ .../_attendance_entries.html.erb | 24 +++++++++---------- .../edit.html.erb | 8 ++++--- app/views/events/callouts/ce.html.erb | 16 +++++++++++-- spec/models/event_spec.rb | 16 +++++++++++++ ...continuing_education_registrations_spec.rb | 10 ++++++++ 9 files changed, 73 insertions(+), 18 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e6e6909e38..351b9ce17e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -229,6 +229,7 @@ this). Match the existing pattern: - ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`) - Use `const` for fixed values — not `SCREAMING_SNAKE_CASE` constants (e.g., `const styleId = "foo"` not `const STYLE_ID = "foo"`) +- **Default to no new JavaScript.** Prefer a server-rendered (ERB/decorator/helper) or Turbo solution over adding a new Stimulus controller. Only reach for JS when the behavior genuinely can't be done server-side or with Turbo (e.g. it needs live client-side state, the browser's own time zone, or DOM the server can't produce). If a change seems to need JS, first ask whether rendering it on the server — even with a small trade-off — is acceptable, and flag that trade-off. When JS is truly required, reuse or generalize an existing controller before writing a new one. - **Strongly prefer Stimulus** for JavaScript behavior — do not write raw/inline JS or jQuery - **Always use Tailwind CSS** utility classes for styling — do not write custom CSS unless absolutely necessary - **Prefer static Tailwind classes over dynamically-constructed ones.** Tailwind's JIT scanner only generates classes it finds as complete literal strings in the source — a class built by interpolation (e.g. `bg-#{color}-500`, `text-${size}`, `class="w-#{n}"`) won't be generated and silently renders unstyled. Write the full class names out, and select between complete literals (e.g. a lookup hash mapping a value to a whole class string, or a ternary picking between two literal classes) rather than splicing fragments. Only build a class dynamically when the set of values is open-ended and can't be enumerated; in that case add the candidates to the Tailwind safelist. diff --git a/CLAUDE.md b/CLAUDE.md index ffe2db4f00..742fd55b3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,7 @@ this). Match the existing pattern: - ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`) - Use `const` for fixed values — not `SCREAMING_SNAKE_CASE` constants (e.g., `const styleId = "foo"` not `const STYLE_ID = "foo"`) +- **Default to no new JavaScript.** Prefer a server-rendered (ERB/decorator/helper) or Turbo solution over adding a new Stimulus controller. Only reach for JS when the behavior genuinely can't be done server-side or with Turbo (e.g. it needs live client-side state, the browser's own time zone, or DOM the server can't produce). If a change seems to need JS, first ask whether rendering it on the server — even with a small trade-off — is acceptable, and flag that trade-off. When JS is truly required, reuse or generalize an existing controller before writing a new one. - **Strongly prefer Stimulus** for JavaScript behavior — do not write raw/inline JS or jQuery - **Always use Tailwind CSS** utility classes for styling — do not write custom CSS unless absolutely necessary - **Prefer static Tailwind classes over dynamically-constructed ones.** Tailwind's JIT scanner only generates classes it finds as complete literal strings in the source — a class built by interpolation (e.g. `bg-#{color}-500`, `text-${size}`, `class="w-#{n}"`) won't be generated and silently renders unstyled. Write the full class names out, and select between complete literals (e.g. a lookup hash mapping a value to a whole class string, or a ternary picking between two literal classes) rather than splicing fragments. Only build a class dynamically when the set of values is open-ended and can't be enumerated; in that case add the candidates to the Tailwind safelist. diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index d0964f778b..54a3309ab4 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -117,6 +117,13 @@ def apply_time_entries(registration) rows = time_entries_attributes return if rows.blank? + # Drop rows pointing at an entry that's no longer on this registration — a stale + # form or double-submit (it was already removed). Left in, nested attributes raise + # RecordNotFound and blow up the save. + existing_ids = registration.event_attendance_time_entries.pluck(:id).map(&:to_s) + rows = rows.reject { |row| row["id"].present? && existing_ids.exclude?(row["id"].to_s) } + return if rows.blank? + registration.assign_attributes(event_attendance_time_entries_attributes: rows) registration.event_attendance_time_entries.each do |entry| next if entry.marked_for_destruction? diff --git a/app/models/event.rb b/app/models/event.rb index 5230980baa..3b90a4f41e 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -226,6 +226,14 @@ def attendance_sign_in_open?(at = Time.current) at.between?(daily_start_at(date) - ATTENDANCE_SIGN_IN_LEAD, daily_end_at(date)) end + # When sign-in next becomes available — the earliest upcoming day's window opening + # (day start − lead). Nil once the last day's window has already opened (or passed). + def next_attendance_sign_in_opens_at(at = Time.current) + event_dates + .map { |date| daily_start_at(date) - ATTENDANCE_SIGN_IN_LEAD } + .find { |opens_at| opens_at > at } + end + def time_title "(#{ start_text }) #{ name }" end diff --git a/app/views/continuing_education_registrations/_attendance_entries.html.erb b/app/views/continuing_education_registrations/_attendance_entries.html.erb index 198eab353f..edb3d0f001 100644 --- a/app/views/continuing_education_registrations/_attendance_entries.html.erb +++ b/app/views/continuing_education_registrations/_attendance_entries.html.erb @@ -42,19 +42,17 @@
<% end %> - <%# Blank rows to add entries (leave empty to ignore). Save again for more. %> - <% 3.times do |j| %> - <% idx = entries.size + j %> -
- - -
-
-
- <% end %> + <%# One blank row to add an entry — fill it and save; a fresh blank returns for the next. %> + <% idx = entries.size %> +
+ + +
+
+
-

Add an entry by filling a blank row. Tick Remove to delete one. Sign-out must be after sign-in.

+

Fill the blank row and save to add an entry. Tick Remove to delete one. Sign-out must be after sign-in.

diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index fbdbbb09ba..3c5fa98c27 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -42,9 +42,11 @@
<%= simple_form_for @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), html: { id: "ce_registration_form", data: { turbo: false } } do |f| %> - <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> - <%= render "attendance_entries", registration: registration %> - <%= render "payment_history", ce_registration: @ce_registration %> +
+ <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> + <%= render "attendance_entries", registration: registration %> + <%= render "payment_history", ce_registration: @ce_registration %> +
<%# ---- Comments (saved with the CE registration, like the other forms) ---- %>
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index d638871b46..8d1e92dbe6 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -271,7 +271,17 @@ class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> <% end %> <% else %> -

Sign-in opens 30 minutes before each training day starts.

+ <%# Sign-in window not open yet: standard gating notice (matching the + videoconference pending-note style) naming the concrete opening plus the + rule. Shown in the event's zone (Pacific), labeled — no JS to detect the + viewer's own zone. %> + <% opens_at = @event.next_attendance_sign_in_opens_at %> +

+ + Sign-in opens<% if opens_at %> + —<% end %> + 30 minutes before each training day starts. +

<% end %>
@@ -297,7 +307,9 @@ <% end %> -

Sign in when you arrive and sign out when you leave — including breaks and lunch — so your hours are recorded accurately.

+ <% if signed_in || @event.attendance_sign_in_open? %> +

Sign in when you arrive and sign out when you leave.
Include breaks and lunch so your hours are recorded accurately.

+ <% end %>
<% end %> <% else %> diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 5711cdeade..1baa3b25af 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -499,5 +499,21 @@ expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 25, 9, 0))).to be(false) end end + + describe "#next_attendance_sign_in_opens_at" do + it "returns the first day's opening before the event" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 23, 7, 0))) + .to eq(Time.zone.local(2026, 7, 23, 8, 30)) + end + + it "skips to the next day's opening once the current window has opened" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 23, 10, 0))) + .to eq(Time.zone.local(2026, 7, 24, 8, 30)) + end + + it "is nil once the last day's window has opened" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 24, 12, 0))).to be_nil + end + end end end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 3fb2cd0e18..8554f65d38 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -251,6 +251,16 @@ }.to change { registration.event_attendance_time_entries.count }.by(-1) end + it "ignores a remove for an entry that no longer exists (stale form / double submit)" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: "999999", signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34", _destroy: "1" } } } } + }.not_to raise_error + + expect(response).to redirect_to(edit_event_registration_path(registration)) + end + it "ignores blank rows" do expect { patch continuing_education_registration_path(ce_registration), From 996b9ce97da2996500bc68e4f84942aa8a72029c Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 01:54:40 -0400 Subject: [PATCH 10/27] Lead the attendance report with totals, teal-set-apart Move the all-days totals above the per-day sections and give them a teal treatment so the headline CE figures read first, with Day 1 / Day 2 below under a "By day" heading. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/attendance.html.erb | 87 ++++++++++++++-------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index 2531fe7937..b2f14701f9 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -40,6 +40,50 @@ <% end %> <% if @report.any? %> + <%# Totals across all days lead the report — the headline figures the CE board + certifies — set apart from the per-day tables with a teal treatment. The id + anchors the return trip from the per-row CE edit links. %> +
+

Totals

+
+
"> +
Name
+ <% if @report.ce_only? %>
License #
Hours awarded
<% end %> +
Total logged
+
+
+ <% @report.registrations.each do |reg| %> +
"> +
+ <%= reg.registrant.full_name %> + <% ce_reg = @report.ce_registration_for(reg) %> + <% if @report.ce_only? && ce_reg %> + <%= link_to edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), + class: "shrink-0 inline-flex items-center gap-1 text-xs text-teal-700 hover:underline" do %> + Edit + <% end %> + <% end %> +
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+
<%= plain_number(@report.ce_hours(reg)) %>
+ <% end %> +
<%= attendance_duration_label(@report.total_minutes(reg)) %>
+
+ <% end %> +
+
"> +
All registrants
+ <% if @report.ce_only? %>
<% end %> +
<%= attendance_duration_label(@report.grand_total_minutes) %>
+
+
+
+ +

By day

<% @report.dates.each_with_index do |date, index| %> <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %>
@@ -90,49 +134,6 @@
<% end %> - <%# Totals across all days — the figure the CE board certifies, alongside the - hours the event awards so staff can reconcile the two. The id anchors the - return trip from the per-row CE edit links. %> -
-

Totals

-
-
"> -
Name
- <% if @report.ce_only? %>
License #
Hours awarded
<% end %> -
Total logged
-
-
- <% @report.registrations.each do |reg| %> -
"> -
- <%= reg.registrant.full_name %> - <% ce_reg = @report.ce_registration_for(reg) %> - <% if @report.ce_only? && ce_reg %> - <%= link_to edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), - class: "shrink-0 inline-flex items-center gap-1 text-xs text-teal-700 hover:underline" do %> - Edit - <% end %> - <% end %> -
- <% if @report.ce_only? %> -
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
-
<%= plain_number(@report.ce_hours(reg)) %>
- <% end %> -
<%= attendance_duration_label(@report.total_minutes(reg)) %>
-
- <% end %> -
-
"> -
All registrants
- <% if @report.ce_only? %>
<% end %> -
<%= attendance_duration_label(@report.grand_total_minutes) %>
-
-
-
-

Registrants sign in and out from their private CE page; times here are in Pacific. A signed in tag means an open entry with no sign-out yet — From fcded4890b0aaee91c5fc2fc9db44290da92b1b0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 08:44:11 -0400 Subject: [PATCH 11/27] Guard attendance entries against >24h/day and same-day overlaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject an entry (or nested batch) that pushes a day's logged time past 24 hours, or that overlaps another sign-in on the same day — you can't be signed in twice at once. Cross-entry checks read persisted rows plus the in-memory nested batch, so both self-service sign-in and the CE edit form are covered; self-service now redirects with the error instead of raising. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 6 ++ app/models/event_attendance_time_entry.rb | 73 +++++++++++++++++++ .../event_attendance_time_entry_spec.rb | 72 ++++++++++++++++++ ...continuing_education_registrations_spec.rb | 15 ++++ 4 files changed, 166 insertions(+) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 836cd6d455..6ee9fdf522 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -157,6 +157,9 @@ def sign_in_ce entry = @event_registration.event_attendance_time_entries.create!(signed_in_at: Time.current) redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: "Signed in at #{local_time(entry.signed_in_at)}." + rescue ActiveRecord::RecordInvalid => e + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: e.record.errors.full_messages.to_sentence end # Close the registrant's open attendance entry. Not windowed — a forgotten @@ -171,6 +174,9 @@ def sign_out_ce entry.update!(signed_out_at: Time.current) redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: "Signed out at #{local_time(entry.signed_out_at)}." + rescue ActiveRecord::RecordInvalid => e + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: e.record.errors.full_messages.to_sentence end # Handouts page: callout-card links to the training worksheet/handout diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index f0c7796442..7b13d6a65f 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -12,8 +12,13 @@ class EventAttendanceTimeEntry < ApplicationRecord belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true + # A single day can't hold more than a real day's worth of logged time. + MAX_DAILY_MINUTES = 24 * 60 + validates :signed_in_at, presence: true validate :signed_out_after_signed_in + validate :within_daily_limit + validate :does_not_overlap_same_day scope :open, -> { where(signed_out_at: nil) } scope :closed, -> { where.not(signed_out_at: nil) } @@ -45,4 +50,72 @@ def signed_out_after_signed_in errors.add(:signed_out_at, "must be after the sign-in time") end + + # The day's total logged time (this entry plus its same-day siblings) can't exceed + # 24 hours — catches fat-fingered edits like a 19-hour session. + def within_daily_limit + return unless own_range_valid? + + total = duration_minutes.to_i + same_day_siblings.sum { |entry| entry.duration_minutes.to_i } + return if total <= MAX_DAILY_MINUTES + + errors.add(:base, "Total time on #{day_label} can't exceed 24 hours.") + end + + # An entry can't fall within (or straddle) another sign-in's timeframe on the same + # day — you can't be signed in twice at once. + def does_not_overlap_same_day + return unless own_range_valid? + + my_end = signed_out_at || signed_in_at + clash = same_day_siblings.find do |entry| + entry_end = entry.signed_out_at || entry.signed_in_at + signed_in_at < entry_end && entry.signed_in_at < my_end + end + return unless clash + + errors.add(:base, "This sign-in overlaps another entry on #{day_label}.") + end + + # Only run the cross-entry guards on a well-formed range (presence + order are + # checked separately), so we never compare against a backwards interval. + def own_range_valid? + return false if signed_in_at.blank? + + signed_out_at.blank? || signed_out_at > signed_in_at + end + + # This registration's other entries on the same day. Starts from the persisted + # rows (queried fresh, not the possibly-stale association cache) and overlays the + # in-memory collection when it's loaded — so the CE edit form, which assigns every + # row through nested attributes, compares against siblings-in-progress (and their + # unsaved edits) too. Excludes self and rows being removed. + def same_day_siblings + registration = event_registration + return [] unless registration && attendance_date + + by_key = {} + if registration.persisted? + EventAttendanceTimeEntry.where(event_registration_id: registration.id).find_each do |entry| + by_key[entry.id] = entry + end + end + if registration.event_attendance_time_entries.loaded? + registration.event_attendance_time_entries.target.each do |entry| + by_key[entry.id || entry.object_id] = entry + end + end + + by_key.values.reject do |entry| + entry.equal?(self) || + (persisted? && entry.id == id) || + entry.marked_for_destruction? || + entry.signed_in_at.blank? || + entry.attendance_date != attendance_date + end + end + + def day_label + attendance_date.strftime("%b %-d") + end end diff --git a/spec/models/event_attendance_time_entry_spec.rb b/spec/models/event_attendance_time_entry_spec.rb index eb46f2e0a4..bc136d5440 100644 --- a/spec/models/event_attendance_time_entry_spec.rb +++ b/spec/models/event_attendance_time_entry_spec.rb @@ -53,6 +53,78 @@ end end + describe "cross-entry guards" do + let(:registration) { create(:event_registration) } + + def at(hour, min, day: 23) + Time.zone.local(2026, 7, day, hour, min) + end + + describe "24-hour daily limit" do + it "rejects a single entry longer than 24 hours" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(1, 0, day: 24)) + expect(entry).not_to be_valid + expect(entry.errors[:base].join).to match(/24 hours/) + end + + it "rejects when same-day siblings push the total past 24 hours" do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(3, 0)) # 3h on the 23rd + # 22h more, still the 23rd (attendance date = sign-in day) and adjacent, so no + # overlap — but 25h total on the day. + cross = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(3, 0), signed_out_at: at(1, 0, day: 24)) + expect(cross).not_to be_valid + expect(cross.errors[:base].join).to match(/24 hours/) + end + + it "allows a day that totals exactly 24 hours" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(0, 0, day: 24)) + expect(entry).to be_valid + end + end + + describe "same-day overlap" do + before do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(9, 0), signed_out_at: at(12, 0)) + end + + it "rejects an entry that overlaps an existing session" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(11, 0), signed_out_at: at(13, 0)) + expect(entry).not_to be_valid + expect(entry.errors[:base].join).to match(/overlaps/) + end + + it "rejects an entry fully inside an existing session" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(10, 0), signed_out_at: at(11, 0)) + expect(entry).not_to be_valid + end + + it "rejects an open (not-yet-signed-out) entry inside an existing session" do + entry = build(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: at(10, 0)) + expect(entry).not_to be_valid + end + + it "allows a back-to-back entry that only touches at the edge" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(12, 0), signed_out_at: at(13, 0)) + expect(entry).to be_valid + end + + it "allows the same clock times on a different day" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(9, 0, day: 24), signed_out_at: at(12, 0, day: 24)) + expect(entry).to be_valid + end + end + end + describe "scopes" do it "separates open from closed entries" do reg = create(:event_registration) diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 8554f65d38..72fab9e9d6 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -269,6 +269,21 @@ }.not_to change { registration.event_attendance_time_entries.count } end + it "rejects overlapping times on the same day with a helpful error" do + pt = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: pt.local(2026, 7, 23, 9, 0), signed_out_at: pt.local(2026, 7, 23, 12, 0)) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T11:00", signed_out_at: "2026-07-23T13:00" } } } } + }.not_to change { registration.event_attendance_time_entries.count } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to match(/overlaps/) + end + it "rejects a sign-out before the sign-in with a helpful error" do patch continuing_education_registration_path(ce_registration), params: { continuing_education_registration: { hours: "6", cost_dollars: "120", From e44340c25749b211d274e8e4cb538f223b47e020 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 08:44:11 -0400 Subject: [PATCH 12/27] Attendance report: per-day logged columns, consistent totals, row/name links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Day N logged columns (and per-day + hours-awarded totals in the All row) to the Totals table. Scope a registrant's Total logged to the event's days so it always equals the day columns — time logged on non-event dates no longer inflates it invisibly. Each row now links to the CE edit page and the name to that registrant's CE callout. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_attendance_report.rb | 16 ++- app/views/events/attendance.html.erb | 105 ++++++++++++------ spec/requests/events/attendance_spec.rb | 5 +- spec/services/event_attendance_report_spec.rb | 13 +++ 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/app/services/event_attendance_report.rb b/app/services/event_attendance_report.rb index 96b2a1da20..fee3347275 100644 --- a/app/services/event_attendance_report.rb +++ b/app/services/event_attendance_report.rb @@ -55,14 +55,28 @@ def day_minutes(registration, date) entries_on(registration, date).sum { |entry| entry.duration_minutes.to_i } end + # Sum of the per-day (event-day) minutes, so a registrant's Total logged always + # equals its day columns. Time logged on dates outside the training's days isn't + # part of the training, so it's excluded here (the certificate gate keeps its own + # broader tally on EventRegistration). def total_minutes(registration) - registration.event_attendance_time_entries.sum { |entry| entry.duration_minutes.to_i } + dates.sum { |date| day_minutes(registration, date) } end def grand_total_minutes registrations.sum { |reg| total_minutes(reg) } end + # Everyone's logged minutes on one day — the day column's total in the All row. + def day_grand_minutes(date) + registrations.sum { |reg| day_minutes(reg, date) } + end + + # Total CE hours awarded across all reported registrants — the All row's awarded figure. + def total_hours_awarded + registrations.sum { |reg| ce_hours(reg) } + end + # A registration with an entry still open (signed in, no sign-out) — flagged on # the report so a forgotten sign-out is fixable rather than silently under-counted. def open?(registration) diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index b2f14701f9..b19b030f01 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -35,7 +35,7 @@ <% if @report.dates_truncated? %>

-

This event runs past <%= @report.dates.last.strftime("%b %-d") %>, but sign-in and this report cover only the first 5 days. Times entered for later days count toward totals without a day section.

+

This event runs past <%= @report.dates.last.strftime("%b %-d") %>, but sign-in and this report cover only the first 5 days. Times entered for later days aren't shown or totalled here.

<% end %> @@ -43,42 +43,68 @@ <%# Totals across all days lead the report — the headline figures the CE board certifies — set apart from the per-day tables with a teal treatment. The id anchors the return trip from the per-row CE edit links. %> + <%# One "Day N logged" column per event day, between Hours awarded and Total + logged. Shared column template keeps header, rows, and the All row aligned. %> + <% day_col = "minmax(5rem,auto) " %> + <% totals_cols = @report.ce_only? ? + "2fr minmax(6rem,1fr) minmax(6rem,auto) #{day_col * @report.dates.size}minmax(6rem,auto)" : + "2fr #{day_col * @report.dates.size}minmax(6rem,auto)" %>

Totals

-
-
"> -
Name
- <% if @report.ce_only? %>
License #
Hours awarded
<% end %> -
Total logged
-
-
- <% @report.registrations.each do |reg| %> -
"> -
- <%= reg.registrant.full_name %> - <% ce_reg = @report.ce_registration_for(reg) %> +
+
+
+
Name
+ <% if @report.ce_only? %>
License #
Hours awarded
<% end %> + <% @report.dates.each_with_index do |_date, index| %> +
Day <%= index + 1 %> logged
+ <% end %> +
Total logged
+
+
+ <% @report.registrations.each do |reg| %> + <% ce_reg = @report.ce_registration_for(reg) %> +
" + style="grid-template-columns: <%= totals_cols %>"> + <%# Whole row → CE edit page; the name link (z-10, above) → the + registrant's CE callout page instead. %> <% if @report.ce_only? && ce_reg %> - <%= link_to edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), - class: "shrink-0 inline-flex items-center gap-1 text-xs text-teal-700 hover:underline" do %> - Edit + <%= link_to "", edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), + class: "absolute inset-0", "aria-label": "Edit CE registration for #{reg.registrant.full_name}" %> + <% end %> +
+ <% if @report.ce_only? && ce_reg %> + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "ce_registration"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% else %> + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> <% end %> +
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+
<%= plain_number(@report.ce_hours(reg)) %>
+ <% end %> + <% @report.dates.each do |date| %> +
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
<% end %> +
<%= attendance_duration_label(@report.total_minutes(reg)) %>
- <% if @report.ce_only? %> -
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
-
<%= plain_number(@report.ce_hours(reg)) %>
- <% end %> -
<%= attendance_duration_label(@report.total_minutes(reg)) %>
-
- <% end %> -
-
"> -
All registrants
- <% if @report.ce_only? %>
<% end %> -
<%= attendance_duration_label(@report.grand_total_minutes) %>
+ <% end %> +
+
+
All registrants
+ <% if @report.ce_only? %> +
+
<%= plain_number(@report.total_hours_awarded) %>
+ <% end %> + <% @report.dates.each do |date| %> +
<%= attendance_duration_label(@report.day_grand_minutes(date)) %>
+ <% end %> +
<%= attendance_duration_label(@report.grand_total_minutes) %>
+
@@ -104,10 +130,21 @@
<% @report.registrations.each do |reg| %> <% entries = @report.entries_for(reg, date) %> -
+
" style="grid-template-columns: <%= @report.ce_only? ? "minmax(9rem,1.2fr) minmax(6rem,0.8fr) 3fr minmax(5rem,auto)" : "minmax(9rem,1.4fr) 3fr minmax(5rem,auto)" %>"> -
- <%= reg.registrant.full_name %> + <% if @report.ce_only? && ce_reg %> + <%= link_to "", edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), + class: "absolute inset-0", "aria-label": "Edit CE registration for #{reg.registrant.full_name}" %> + <% end %> +
+ <% if @report.ce_only? && ce_reg %> + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "ce_registration"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% else %> + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% end %> <% if @report.open?(reg) %> signed in <% end %> diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index bb7ce12fec..40babc1fcc 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -38,11 +38,12 @@ def log_ce_time! expect(response.body).not_to include("CE sign-in report") end - it "shows a per-registrant Edit link to the CE edit page in the CE report" do + it "makes each row link to the CE edit page and the name link to the CE callout" do log_ce_time! ce = registration.continuing_education_registrations.first get attendance_event_path(event, ce: "true") - expect(response.body).to include(edit_continuing_education_registration_path(ce)) + expect(response.body).to include(edit_continuing_education_registration_path(ce)) # whole-row link + expect(response.body).to include(registration_ce_path(registration.slug)) # name link end it "returns to the registrants page when opened from there" do diff --git a/spec/services/event_attendance_report_spec.rb b/spec/services/event_attendance_report_spec.rb index b5a8c6c9cc..e9115fbebe 100644 --- a/spec/services/event_attendance_report_spec.rb +++ b/spec/services/event_attendance_report_spec.rb @@ -81,6 +81,19 @@ def entry(registration, in_at, out_at) expect(report.ce_hours(alice)).to eq(6) end + it "totals minutes per day and hours awarded across all registrants" do + expect(report.day_grand_minutes(Date.new(2026, 7, 23))).to eq(188) + expect(report.day_grand_minutes(Date.new(2026, 7, 24))).to eq(420) + expect(report.total_hours_awarded).to eq(12) # Alice 6 + Bob 6 + end + + it "excludes time logged outside the event's days from a registrant's total" do + # 8 hours on a date the training doesn't run — must not inflate the total. + create(:event_attendance_time_entry, event_registration: alice, + signed_in_at: Time.zone.local(2026, 8, 1, 9, 0), signed_out_at: Time.zone.local(2026, 8, 1, 17, 0)) + expect(report.total_minutes(alice)).to eq(608) # still just Jul 23 (188) + Jul 24 (420) + end + it "flags a registrant with an open (not signed out) entry" do create(:event_attendance_time_entry, :open, event_registration: bob) expect(report.open?(bob)).to be(true) From 19c284ecfad1c1a4cb95f5fca62074240db28069 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 08:51:22 -0400 Subject: [PATCH 13/27] Stack the Totals header labels for readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Put the "… logged" / "awarded" qualifier on a second line in smaller lowercase text, so the day columns stop crowding into one hard-to-read caps line. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/attendance.html.erb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index b19b030f01..1d004d1520 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -53,14 +53,20 @@

Totals

-
+ <% qualifier = "block normal-case font-normal tracking-normal text-[0.625rem] text-teal-600" %> +
Name
- <% if @report.ce_only? %>
License #
Hours awarded
<% end %> + <% if @report.ce_only? %> +
License #
+
Hoursawarded
+ <% end %> <% @report.dates.each_with_index do |_date, index| %> -
Day <%= index + 1 %> logged
+
Day <%= index + 1 %>logged
<% end %> -
Total logged
+
Totallogged
<% @report.registrations.each do |reg| %> From 3f865859cc822bb12a26540b076b08cd65f206bb Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 08:55:05 -0400 Subject: [PATCH 14/27] Show daily training hours on the attendance report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff reading the sign-in sheet need the day's expected hours next to the logged times to spot short days at a glance — reuse the event decorator's times in the page header and each day header. Also tighten the CE callout's sign-in-window note ("30 minutes before."), since the concrete opening time already says when. Co-Authored-By: Claude Fable 5 --- app/views/events/attendance.html.erb | 4 ++-- app/views/events/callouts/ce.html.erb | 2 +- spec/requests/events/attendance_spec.rb | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index 1d004d1520..c215bf92b5 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -22,7 +22,7 @@

<%= title %>

-

<%= @event.title %> · <%= event.date_range %>

+

<%= @event.title %> · <%= event.date_range %> · <%= event.times %>

<% if @report.ce_only? %> @@ -120,7 +120,7 @@ <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %>
-

Day <%= index + 1 %> · <%= date.strftime("%A, %b %-d") %>

+

Day <%= index + 1 %> · <%= date.strftime("%A, %b %-d") %> · <%= event.times %>

<%= signed_in_count %> of <%= @report.registrations.size %> signed in
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 8d1e92dbe6..969f74d11f 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -280,7 +280,7 @@ Sign-in opens<% if opens_at %> —<% end %> - 30 minutes before each training day starts. + 30 minutes before.

<% end %>
diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index 40babc1fcc..6e27e5edac 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -51,6 +51,16 @@ def log_ce_time! expect(response.body).to include("← Registrants") end + it "shows the event's daily times in the page header and each day header" do + # Pin the viewer to UTC so the times render exactly as the event was built + # (requests otherwise display in the admin's zone, Pacific by default). + sign_in create(:user, :admin, time_zone: "UTC") + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{event.decorate.date_range} · 9 am - 4 pm UTC") + expect(response.body).to include("Day 1 · #{Date.new(2026, 7, 23).strftime("%A, %b %-d")} · 9 am - 4 pm UTC") + end + it "warns when the event runs longer than the report's 5-day cap" do event.update!(end_date: Time.zone.local(2026, 7, 30, 16, 0)) get attendance_event_path(event) From 8887bbd47f833002580f7a5aa831b84b4e8cad99 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:00:19 -0400 Subject: [PATCH 15/27] Callout header date line + concrete sign-in/event times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Callout page header: event dates move to their own line (the combined title · date line wrapped awkwardly) and gain the daily hours - The pre-window CE note names the opening time and, on its own line, the event's actual start ("Event begins 30 min later, at 9:00 PDT") — a countdown was tried and cut in favor of the concrete times Co-Authored-By: Claude Fable 5 --- .../events/callouts/_callout_page.html.erb | 5 ++++- app/views/events/callouts/ce.html.erb | 17 +++++++++++------ spec/requests/events/ce_attendance_spec.rb | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index 02ac778268..bc50450eef 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -31,7 +31,10 @@ <%# @event is decorated in some callout actions and raw in others; the date range lives on the decorator, so decorate only when it isn't already. %> <% event = @event.respond_to?(:short_date_range) ? @event : @event.decorate %> -

<%= event.title %><% if event.start_date.present? %> · <%= event.short_date_range %><% end %>

+

<%= event.title %>

+ <% if event.start_date.present? %> +

<%= event.short_date_range %> · <%= event.times %>

+ <% end %>
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 969f74d11f..8db047fbbc 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -272,15 +272,20 @@ <% end %> <% else %> <%# Sign-in window not open yet: standard gating notice (matching the - videoconference pending-note style) naming the concrete opening plus the - rule. Shown in the event's zone (Pacific), labeled — no JS to detect the - viewer's own zone. %> + videoconference pending-note style) naming the concrete opening time, + with the event's own start beneath it. Shown in the event's zone + (Pacific), labeled — no JS to detect the viewer's own zone. %> <% opens_at = @event.next_attendance_sign_in_opens_at %>

- Sign-in opens<% if opens_at %> - —<% end %> - 30 minutes before. + <% if opens_at %> + Sign-in opens + . + Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-l:%M %Z") %>. + + <% else %> + Sign-in opens 30 minutes before. + <% end %>

<% end %>
diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index 95bf4e8268..5de832ed5a 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -101,6 +101,24 @@ def pay_ce! expect(response.body).to include("Signed in at") end + it "shows the event dates and daily times on their own header line" do + pay_ce! + get registration_ce_path(registration.slug) + # The event runs 9:00–16:00 UTC; the public page renders in Pacific (2–9 am). + expect(response.body).to include("Jul 23, 2026 · 2 - 9 am PDT") + end + + it "shows the opening time and the event-start note before the window" do + pay_ce! + travel_to Time.zone.local(2026, 7, 23, 6, 30) + get registration_ce_path(registration.slug) + expect(response.body).to include("Sign-in opens") + # 8:30/9:00 UTC (open/start) shown in the page's Pacific zone. + expect(response.body).to include("1:30 AM PDT") + expect(response.body).to include("Event begins 30 min later, at 2:00 PDT.") + expect(response.body).not_to include("in about 2 hours") + end + it "hides the attendance section until CE is paid in full" do license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) From 0a5f75794ef7f0abc9439eec91363e5769bbbe50 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:02:01 -0400 Subject: [PATCH 16/27] Hide the Signed out chip until something is logged today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Signed out" implied a session that never happened for first-time visitors — show only the Sign in button until an entry exists today. Co-Authored-By: Claude Fable 5 --- app/views/events/callouts/ce.html.erb | 6 +++++- spec/requests/events/ce_attendance_spec.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 8db047fbbc..f3de420fb3 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -262,7 +262,11 @@ class: "shrink-0 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 cursor-pointer" %> <% end %> <% elsif @event.attendance_sign_in_open? %> - Signed out + <%# "Signed out" only makes sense after a session today — first-time + visitors just get the Sign in button. %> + <% if todays_entries.any? %> + Signed out + <% end %> <% if sample_preview? %> diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index 5de832ed5a..b02900488c 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -92,6 +92,20 @@ def pay_ce! expect(response.body).to include("Sign in") end + it "omits the Signed out chip until something has been logged today" do + pay_ce! + get registration_ce_path(registration.slug) + expect(response.body).not_to include("Signed out") + end + + it "shows the Signed out chip after signing out today" do + pay_ce! + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.current - 2.hours, signed_out_at: Time.current - 1.hour) + get registration_ce_path(registration.slug) + expect(response.body).to include("Signed out") + end + it "shows a Sign out button and today's entries while signed in" do pay_ce! create(:event_attendance_time_entry, :open, event_registration: registration, From df5c2b0ae0cfcedc81148011a6ec46422014a734 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:06:24 -0400 Subject: [PATCH 17/27] CE sign-in polish: minimum 1m durations, zone label, CTA sign-out - A sub-minute in/out pair counted as 0m; round up in the attendee's favor so every completed session logs at least a minute - Label the today's-entries columns with the display zone ("Time in (PDT)") since bare clock times were ambiguous - Sign out gets the primary CTA treatment while signed in (it's the only action), and the button reads "Sign in again" once a session exists Co-Authored-By: Claude Fable 5 --- app/models/event_attendance_time_entry.rb | 5 +++-- app/views/events/callouts/ce.html.erb | 16 ++++++++++------ spec/models/event_attendance_time_entry_spec.rb | 7 +++++++ spec/requests/events/ce_attendance_spec.rb | 14 +++++++++++++- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index 7b13d6a65f..da4152bd03 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -30,10 +30,11 @@ def open? end # Whole minutes between sign-in and sign-out; nil while still open. Rounded to - # the minute like the paper sheet, which staff totalled by the minute. + # the minute like the paper sheet, which staff totalled by the minute — except + # a sub-minute pair counts as 1 (rounded up in the attendee's favor), never 0. def duration_minutes return nil unless signed_out_at && signed_in_at - ((signed_out_at - signed_in_at) / 60).round + [ ((signed_out_at - signed_in_at) / 60).round, 1 ].max end # The event day (a Date, in the app zone) this entry's sign-in falls on — how diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index f3de420fb3..e99230b1a2 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -254,12 +254,14 @@ Signed in at <%= open_entry.decorate.signed_in_label %> + <%# Signing out is the one action left while signed in, so it gets the + primary CTA treatment, same as Sign in. %> <% if sample_preview? %> + class="shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white opacity-60 cursor-not-allowed">Sign out <% else %> <%= button_to "Sign out", registration_ce_sign_out_path(@event_registration.slug), data: { turbo: false }, - class: "shrink-0 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 cursor-pointer" %> + class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> <% end %> <% elsif @event.attendance_sign_in_open? %> <%# "Signed out" only makes sense after a session today — first-time @@ -267,11 +269,12 @@ <% if todays_entries.any? %> Signed out <% end %> + <% sign_in_label = todays_entries.any? ? "Sign in again" : "Sign in" %> <% if sample_preview? %> + class="shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white opacity-60 cursor-not-allowed"><%= sign_in_label %> <% else %> - <%= button_to "Sign in", registration_ce_sign_in_path(@event_registration.slug), data: { turbo: false }, + <%= button_to sign_in_label, registration_ce_sign_in_path(@event_registration.slug), data: { turbo: false }, class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> <% end %> <% else %> @@ -296,9 +299,10 @@ <% if todays_entries.any? %>
+ <% tz_abbr = Time.zone.now.strftime("%Z") %>
-
Time in
-
Time out
+
Time in (<%= tz_abbr %>)
+
Time out (<%= tz_abbr %>)
Duration
<% todays_entries.each do |entry| %> diff --git a/spec/models/event_attendance_time_entry_spec.rb b/spec/models/event_attendance_time_entry_spec.rb index bc136d5440..542a64e34b 100644 --- a/spec/models/event_attendance_time_entry_spec.rb +++ b/spec/models/event_attendance_time_entry_spec.rb @@ -41,6 +41,13 @@ expect(entry.duration_minutes).to eq(2) end + it "counts a sub-minute pair as a full minute (rounds up for the attendee)" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0, 0), + signed_out_at: Time.zone.local(2026, 7, 23, 9, 0, 20)) + expect(entry.duration_minutes).to eq(1) + end + it "is nil while open" do expect(build(:event_attendance_time_entry, :open).duration_minutes).to be_nil end diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index b02900488c..ffa89c7a16 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -98,12 +98,21 @@ def pay_ce! expect(response.body).not_to include("Signed out") end - it "shows the Signed out chip after signing out today" do + it "shows the Signed out chip and a Sign in again button after signing out today" do pay_ce! create(:event_attendance_time_entry, event_registration: registration, signed_in_at: Time.current - 2.hours, signed_out_at: Time.current - 1.hour) get registration_ce_path(registration.slug) expect(response.body).to include("Signed out") + expect(response.body).to include("Sign in again") + end + + it "styles Sign out as the primary CTA while signed in" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.current - 30.minutes) + get registration_ce_path(registration.slug) + expect(response.body).to match(/]*bg-teal-600[^>]*>Sign out Date: Tue, 4 Aug 2026 09:12:07 -0400 Subject: [PATCH 18/27] Compact the CE credit + license sections to single rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacked label/value rows made the card tall and left the status chip stranded mid-card — status now sits on the heading row, and the credit stats (hours at the same scale as cost) and license fields each share one wrapping row. Co-Authored-By: Claude Fable 5 --- app/views/events/callouts/ce.html.erb | 81 ++++++++++++++------------- spec/requests/events/callouts_spec.rb | 9 +-- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index e99230b1a2..b430cf02f4 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -42,42 +42,47 @@ <% end %> <%= turbo_frame_tag "license_section" do %>
-

Your CE credit

+ <%# Heading row carries the status chip; the stats sit together on one + wrapping row beneath it, all at the same scale. %> +
+

Your CE credit

+
+ Status + <%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %> +
+
-
+
Hours
-
<%= plain_number(ce_registration&.hours) || "—" %>
+
<%= plain_number(ce_registration&.hours) || "—" %>
-
-
Status
-
<%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
+
+
Cost
+
<%= dollars_from_cents(ce_registration&.cost_cents) %>
-
- -

- Cost - <%= dollars_from_cents(ce_registration&.cost_cents) %> -

- - <% if ce_registration&.discounted? %> -

- Discount - −<%= dollars_from_cents(ce_registration.discount_sum) %> -

- <% end %> -

- Amount paid - <%= dollars_from_cents(ce_registration&.payments_sum.to_i) %> - <% if ce_registration&.paid_in_full? || simulate_ce_paid %> - <%= render "shared/badge", - label: "Paid in full", - classes: "bg-green-50 text-green-700 border-green-200", - icon: "fa-solid fa-circle-check text-[0.6rem]" %> + <% if ce_registration&.discounted? %> +

+
Discount
+
−<%= dollars_from_cents(ce_registration.discount_sum) %>
+
<% end %> -

+ +
+
Amount paid
+
+ <%= dollars_from_cents(ce_registration&.payments_sum.to_i) %> + <% if ce_registration&.paid_in_full? || simulate_ce_paid %> + <%= render "shared/badge", + label: "Paid in full", + classes: "bg-green-50 text-green-700 border-green-200", + icon: "fa-solid fa-circle-check text-[0.6rem]" %> + <% end %> +
+
+
<% if ce_registration && ce_registration.remaining_cost.positive? %> <% if (due_text = @event.decorate.ce_payment_due_deadline_display) %> @@ -130,26 +135,26 @@
<% if license_locked || (license_on_file && !editing_license) %> - <%# Each license field on its own row, labels in a fixed column so the values line up. %> -
+ <%# License fields on one wrapping row, matching the CE credit stats above. %> +
-
License type
-
<%= license_kind.presence || "—" %>
+
License type
+
<%= license_kind.presence || "—" %>
-
License number
-
<%= license_number %>
+
License number
+
<%= license_number %>
-
Issuing state
-
<%= license_issuing_state.presence || "—" %>
+
Issuing state
+
<%= license_issuing_state.presence || "—" %>
-
Expires
-
<%= license_expires_on&.to_fs(:long) || "—" %>
+
Expires
+
<%= license_expires_on&.to_fs(:long) || "—" %>
<% if license_locked %> diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 01403b7658..a4d1202809 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -117,12 +117,13 @@ describe "callout page header" do let(:event) { create(:event, title: "Windows workshop", start_date: Date.new(2020, 1, 12), end_date: Date.new(2099, 12, 12)) } - it "shows the event title and short date range under the callout title" do + it "shows the event title with the date range and daily times on the line below" do create(:registration_ticket_callout, event:, builtin_key: "staff", hidden: false) get registration_staff_path(registration.slug) - # title · " - " — the short_date_range format - # (no weekday, with year); the exact day depends on the request time zone. - expect(response.body).to match(/Windows workshop · \w{3} \d{1,2}, 2020 - \w{3} \d{1,2}, 2099/) + expect(response.body).to include("Windows workshop") + # " - · " — the short_date_range format + # (no weekday, with year); the exact day/time depend on the request time zone. + expect(response.body).to match(/\w{3} \d{1,2}, 2020 - \w{3} \d{1,2}, 2099 · .+m [A-Z]{3}/) end end From 5ed7dfe51a0b995aa88d3fc7a2319dbc8116489c Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:15:47 -0400 Subject: [PATCH 19/27] Admin chip on the sign-in section linking to the attendance report Staff viewing a registrant's CE page had no direct path to the event-wide report; reuse the admin_edit_link chip (gated on the attendance? policy) beside the Training sign-in heading. Co-Authored-By: Claude Fable 5 --- app/views/events/callouts/ce.html.erb | 9 ++++++++- spec/requests/events/ce_attendance_spec.rb | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index b430cf02f4..5b8bd76414 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -251,7 +251,14 @@ <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %>
-

Training sign-in

+
+

Training sign-in

+ <%# Admin-only jump to the event's attendance report, mirroring the + "Edit CE registration" chip up top. %> + <% if allowed_to?(:attendance?, @event) && !sample_preview? %> + <%= render "events/callouts/admin_edit_link", path: attendance_event_path(@event, ce: "true"), label: "Attendance report" %> + <% end %> +
<% if signed_in %> diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index ffa89c7a16..515088cd7b 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -145,6 +145,19 @@ def pay_ce! expect(response.body).not_to include("in about 2 hours") end + it "shows staff an admin chip linking to the event's attendance report" do + pay_ce! + sign_in create(:user, :admin) + get registration_ce_path(registration.slug) + expect(response.body).to include(attendance_event_path(event, ce: "true")) + end + + it "hides the attendance report chip from registrants" do + pay_ce! + get registration_ce_path(registration.slug) + expect(response.body).not_to include(attendance_event_path(event, ce: "true")) + end + it "hides the attendance section until CE is paid in full" do license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) From 6cb64696eaea538bb14ff663311da3a116388ee0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:26:14 -0400 Subject: [PATCH 20/27] Report grouping toggle + generic sign-in report links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session detail now defaults to grouped-by-person (one card per registrant with a row per day), with a registrants-style pill toggle to the by-day tables — staff usually chase one person's hours, not a day - Non-CE events get the report too: participation rows and the registrants bulk-actions menu link a generic "Sign-in report" where the CE-scoped link doesn't apply Co-Authored-By: Claude Fable 5 --- app/views/events/_bulk_actions_menu.html.erb | 2 + app/views/events/attendance.html.erb | 62 +++++++++++++++++++- app/views/events/participation.html.erb | 3 + spec/requests/events/attendance_spec.rb | 16 ++++- spec/requests/events_spec.rb | 14 ++++- 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/app/views/events/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 7687d7eb63..5f7e5efa55 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -15,6 +15,8 @@ <%= link_to "Onboarding tracker", onboarding_event_path(@event), class: item_class %> <% if @event.ce_eligible? %> <%= link_to "CE sign-in report", attendance_event_path(@event, ce: "true", return_to: "registrants"), class: item_class %> + <% else %> + <%= link_to "Sign-in report", attendance_event_path(@event, return_to: "registrants"), class: item_class %> <% end %> <%= link_to "Send bulk emails", preview_reminder_event_path(@event), class: item_class %> <%= link_to "Bulk payments", bulk_payments_event_path(@event), class: item_class %> diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index c215bf92b5..050c68298d 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -115,7 +115,66 @@
-

By day

+ <%# Session detail grouped by person (default) or by day, toggled like the + registrants page's active/inactive filter — a full-page reload carrying + the ce/return_to params. %> + <% group_by_day = params[:group] == "day" %> + <% toggle_params = { ce: params[:ce].presence, return_to: params[:return_to].presence }.compact %> +
+

<%= group_by_day ? "Sessions by day" : "Sessions by person" %>

+ +
+ + <% unless group_by_day %> + <% @report.registrations.each do |reg| %> +
+
+

+ <%= reg.registrant.full_name %> + <% if @report.open?(reg) %> + signed in + <% end %> +

+ Total <%= attendance_duration_label(@report.total_minutes(reg)) %> +
+ +
+
+
Day
+
Sessions
+
Day total
+
+
+ <% @report.dates.each_with_index do |date, index| %> + <% entries = @report.entries_for(reg, date) %> +
+
Day <%= index + 1 %> · <%= date.strftime("%a, %b %-d") %>
+
+ <% if entries.any? %> + <% entries.each do |entry| %> + + <%= entry.signed_in_label %>–<%= entry.signed_out_label %> · <%= entry.duration_label %> + + <% end %> + <% else %> + Not signed in + <% end %> +
+
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
+
+ <% end %> +
+
+
+ <% end %> + <% else %> <% @report.dates.each_with_index do |date, index| %> <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %>
@@ -176,6 +235,7 @@
<% end %> + <% end %>

Registrants sign in and out from their private CE page; times here are in Pacific. diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index 1089b48dd7..e0bd4222dd 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -160,6 +160,9 @@ <% if row.event.ce_eligible? %> <%= link_to "CE sign-in report →", attendance_event_path(row.event, ce: "true", return_to: "participation"), class: "text-xs font-medium text-teal-700 hover:underline" %> + <% else %> + <%= link_to "Sign-in report →", attendance_event_path(row.event, return_to: "participation"), + class: "text-xs font-medium text-teal-700 hover:underline" %> <% end %>

diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index 6e27e5edac..a099f0727c 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -46,6 +46,20 @@ def log_ce_time! expect(response.body).to include(registration_ce_path(registration.slug)) # name link end + it "groups sessions by person by default" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("Sessions by person") + expect(response.body).not_to include("Sessions by day") + end + + it "groups sessions by day when toggled" do + log_ce_time! + get attendance_event_path(event, ce: "true", group: "day") + expect(response.body).to include("Sessions by day") + expect(response.body).to include("Day 1 ·") + end + it "returns to the registrants page when opened from there" do get attendance_event_path(event, ce: "true", return_to: "registrants") expect(response.body).to include("← Registrants") @@ -56,7 +70,7 @@ def log_ce_time! # (requests otherwise display in the admin's zone, Pacific by default). sign_in create(:user, :admin, time_zone: "UTC") log_ce_time! - get attendance_event_path(event, ce: "true") + get attendance_event_path(event, ce: "true", group: "day") expect(response.body).to include("#{event.decorate.date_range} · 9 am - 4 pm UTC") expect(response.body).to include("Day 1 · #{Date.new(2026, 7, 23).strftime("%A, %b %-d")} · 9 am - 4 pm UTC") end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 9d38e067e8..637f4f2250 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -267,6 +267,16 @@ def add_ce_registrant(target_event) expect(response.body).to include("2026", "2025") end + it "links each event to its sign-in report, CE-scoped only when CE is enabled" do + training_2026.update!(ce_hours_offered: 6) + sign_in admin + get participation_events_path + expect(response.body).to include("CE sign-in report") + expect(response.body).to include(attendance_event_path(training_2026)) + expect(response.body).to include("Sign-in report →") + expect(response.body).to include(attendance_event_path(webinar_2025)) + end + # The Event dropdown lists every event, so the report rows are identified by # their per-event dashboard link rather than the title. it "narrows to facilitator trainings by event type" do @@ -1003,11 +1013,13 @@ def add_ce_registrant(target_event) expect(response.body).to include(attendance_event_path(event)) end - it "omits the link when the event offers no CE" do + it "links the generic sign-in report when the event offers no CE" do event.update!(ce_hours_offered: 0) get registrants_event_path(event) expect(response.body).not_to include("CE sign-in report") + expect(response.body).to include("Sign-in report") + expect(response.body).to include(attendance_event_path(event)) end end From 1a44af305dbefd032d10445fcf31f813fb343490 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 09:32:38 -0400 Subject: [PATCH 21/27] Make report session rows clickable to the right edit page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows in both report groupings now open the CE edit page on the CE report and the registration edit page on the generic one — with return_to wiring (eyebrow + after-save redirect) added to the registration edit page so the round trip lands back on the report. Co-Authored-By: Claude Fable 5 --- .../event_registrations_controller.rb | 1 + app/views/event_registrations/edit.html.erb | 4 ++++ app/views/events/attendance.html.erb | 21 +++++++++++++------ spec/requests/event_registrations_spec.rb | 14 +++++++++++++ spec/requests/events/attendance_spec.rb | 17 +++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 20f4cab4ec..dca2d3d8f6 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -104,6 +104,7 @@ def update when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other + when "attendance" then redirect_to attendance_event_path(@event_registration.event), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the # roster) rather than dropping them on the public registration show. diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 93a31ae28e..dcff784f9e 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -18,6 +18,10 @@ <%= link_to onboarding_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Onboarding <% end %> + <% elsif params[:return_to] == "attendance" %> + <%= link_to attendance_event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Sign-in report + <% end %> <% else %> <%= link_to registrants_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index 050c68298d..d3c1a1f2a5 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -132,6 +132,12 @@ <% unless group_by_day %> <% @report.registrations.each do |reg| %> + <% ce_reg = @report.ce_registration_for(reg) %> + <%# Whole row → the CE edit page on the CE report, the registration edit + page otherwise (kept in sync with those pages' return_to handling). %> + <% row_path = @report.ce_only? && ce_reg ? + edit_continuing_education_registration_path(ce_reg, return_to: "attendance") : + edit_event_registration_path(reg, return_to: "attendance") %>

@@ -153,8 +159,10 @@
<% @report.dates.each_with_index do |date, index| %> <% entries = @report.entries_for(reg, date) %> -
+ <%= link_to "", row_path, class: "absolute inset-0", + "aria-label": "Edit #{@report.ce_only? ? "CE registration" : "registration"} for #{reg.registrant.full_name}" %>
Day <%= index + 1 %> · <%= date.strftime("%a, %b %-d") %>
<% if entries.any? %> @@ -196,12 +204,13 @@ <% @report.registrations.each do |reg| %> <% entries = @report.entries_for(reg, date) %> <% ce_reg = @report.ce_registration_for(reg) %> -
" + <% row_path = @report.ce_only? && ce_reg ? + edit_continuing_education_registration_path(ce_reg, return_to: "attendance") : + edit_event_registration_path(reg, return_to: "attendance") %> +
"> - <% if @report.ce_only? && ce_reg %> - <%= link_to "", edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), - class: "absolute inset-0", "aria-label": "Edit CE registration for #{reg.registrant.full_name}" %> - <% end %> + <%= link_to "", row_path, class: "absolute inset-0", + "aria-label": "Edit #{@report.ce_only? && ce_reg ? "CE registration" : "registration"} for #{reg.registrant.full_name}" %>
<% if @report.ce_only? && ce_reg %> <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "ce_registration"), diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index a244bd7d60..a17e3369ed 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -442,6 +442,20 @@ def toggle_day(field, value) expect(existing_registration.reload.event_id).to eq(new_event.id) end + it "returns to the attendance report after saving when opened from it" do + patch event_registration_path(existing_registration, return_to: "attendance"), + params: { event_registration: { expected_payment_method: "Check" } } + + expect(response).to redirect_to(attendance_event_path(existing_registration.event)) + end + + it "shows a sign-in report eyebrow when opened from the attendance report" do + get edit_event_registration_path(existing_registration, return_to: "attendance") + + expect(response.body).to include("Sign-in report") + expect(response.body).to include(attendance_event_path(existing_registration.event)) + end + it "sets the shout-out flag and stores the shout-out text on the registrant" do patch event_registration_path(existing_registration), params: { event_registration: { diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index a099f0727c..dc9846df14 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -60,6 +60,23 @@ def log_ce_time! expect(response.body).to include("Day 1 ·") end + it "links session rows to the registration edit page on the generic report" do + log_ce_time! + get attendance_event_path(event) + expect(response.body).to include("#{edit_event_registration_path(registration)}?return_to=attendance") + + get attendance_event_path(event, group: "day") + expect(response.body).to include("#{edit_event_registration_path(registration)}?return_to=attendance") + end + + it "links session rows to the CE edit page on the CE report" do + log_ce_time! + ce = registration.continuing_education_registrations.first + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{edit_continuing_education_registration_path(ce)}?return_to=attendance") + expect(response.body).not_to include("#{edit_event_registration_path(registration)}?return_to=attendance") + end + it "returns to the registrants page when opened from there" do get attendance_event_path(event, ce: "true", return_to: "registrants") expect(response.body).to include("← Registrants") From b7ff8290f781f0a758122adda84f0d474ee3e522 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 10:08:34 -0400 Subject: [PATCH 22/27] Keep a forgotten sign-out from swallowing the next training day Forgetting to sign out is the failure mode this feature plans for, but an open entry had no date, so it carried into the next day of a multi-day training: sign-in was refused as "already signed in", and the only way out banked a ~24h session against the previous day (or tripped the daily limit and stranded the registrant entirely). Open entries are now scoped to the day being asked about, so each day starts fresh and the stale row stays flagged on the attendance report for staff to close. Two smaller fixes from the same review: - Drop the sign-in section once the last day's window has passed, instead of telling a registrant collecting their certificate that "Sign-in opens 30 minutes before" a training that's already over. - Render the staff time-entry rows from the in-memory association, so a save rejected for overlapping or backwards times re-renders with the admin's typed values instead of silently reverting them. --- app/models/event_registration.rb | 20 ++++---- .../_attendance_entries.html.erb | 12 +++-- app/views/events/callouts/ce.html.erb | 36 +++++++------- spec/models/event_registration_spec.rb | 16 ++++++- ...continuing_education_registrations_spec.rb | 12 +++++ spec/requests/events/ce_attendance_spec.rb | 48 +++++++++++++++++++ 6 files changed, 113 insertions(+), 31 deletions(-) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index eacb678797..e54c9dba8e 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -487,14 +487,18 @@ def cost_cents event.cost_cents end - # The registrant's currently-open attendance entry (signed in, not yet out), or - # nil when they're not signed in. Drives which sign-in/out button the CE callout - # shows. Uses the most recent open entry if more than one somehow exists. - def open_attendance_entry - event_attendance_time_entries.open.chronological.last - end - - # Whether the registrant is currently signed in. + # The registrant's currently-open attendance entry (signed in, not yet out) for + # one day, or nil when they're not signed in that day. Drives which sign-in/out + # button the CE callout shows. Deliberately day-scoped: an entry left open when + # someone forgets to sign out must not carry into the next training day, where it + # would block the new day's sign-in and, once closed, bank a 24-hour session. The + # stale row stays flagged on the attendance report for staff to correct. + # Uses the most recent open entry if more than one somehow exists. + def open_attendance_entry(date = Time.zone.today) + attendance_entries_on(date).select(&:open?).last + end + + # Whether the registrant is currently signed in (today). def signed_in? open_attendance_entry.present? end diff --git a/app/views/continuing_education_registrations/_attendance_entries.html.erb b/app/views/continuing_education_registrations/_attendance_entries.html.erb index edb3d0f001..152bc107b5 100644 --- a/app/views/continuing_education_registrations/_attendance_entries.html.erb +++ b/app/views/continuing_education_registrations/_attendance_entries.html.erb @@ -7,7 +7,10 @@ event's local (Pacific) zone, matching the callout and report. locals: registration (EventRegistration). %> -<% entries = registration.event_attendance_time_entries.chronological.to_a %> +<%# The in-memory rows, not a fresh query: when a save is rejected (overlapping or + backwards times) the controller has already applied the submitted values to this + association, and re-reading the database here would throw the admin's edits away. %> +<% entries = registration.event_attendance_time_entries.to_a.sort_by { |entry| entry.signed_in_at || Time.zone.at(0) } %> <% dt = ->(time) { time&.in_time_zone(Time.zone)&.strftime("%Y-%m-%dT%H:%M") } %> <% input_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> @@ -29,7 +32,9 @@ <% entries.each_with_index do |entry, i| %>
- + <% if entry.persisted? %> + + <% end %> <%= entry.decorate.duration_label %>
<% end %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 5b8bd76414..84263a4099 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -241,15 +241,20 @@ Training sign-in/out — the in-portal replacement for the paper CE hour sign-in sheet, shown only once CE is paid in full. One button at a time: Sign in when signed out (only inside the day's window), Sign out while signed - in (always, so a forgotten sign-out can still be closed). Many entries per day - is expected (breaks, lunch). Staff correct times on the admin CE edit page. + in today (always, so a forgotten sign-out can still be closed that day). Many + entries per day is expected (breaks, lunch). An entry left open overnight + doesn't carry over — the next day starts at Sign in, and the stale row is + staff's to close on the admin CE edit page, alongside any other correction. %> - <% if ce_registration&.paid_in_full? %> - <% signed_in = @event_registration.signed_in? %> - <% open_entry = @event_registration.open_attendance_entry %> - <% todays_entries = @event_registration.attendance_entries_on(Time.zone.today) %> - <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %> - + <% attendance_offered = ce_registration&.paid_in_full? %> + <% open_entry = attendance_offered ? @event_registration.open_attendance_entry : nil %> + <% signed_in = open_entry.present? %> + <% todays_entries = attendance_offered ? @event_registration.attendance_entries_on(Time.zone.today) : [] %> + <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %> + <% opens_at = @event.next_attendance_sign_in_opens_at %> + <%# Once the last day's window has passed there's no sign-in left to offer or + announce, so the section drops rather than nagging about a training that's over. %> + <% if attendance_offered && (signed_in || @event.attendance_sign_in_open? || opens_at || todays_entries.any?) %>

Training sign-in

@@ -289,22 +294,17 @@ <%= button_to sign_in_label, registration_ce_sign_in_path(@event_registration.slug), data: { turbo: false }, class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> <% end %> - <% else %> + <% elsif opens_at %> <%# Sign-in window not open yet: standard gating notice (matching the videoconference pending-note style) naming the concrete opening time, with the event's own start beneath it. Shown in the event's zone (Pacific), labeled — no JS to detect the viewer's own zone. %> - <% opens_at = @event.next_attendance_sign_in_opens_at %>

- <% if opens_at %> - Sign-in opens - . - Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-l:%M %Z") %>. - - <% else %> - Sign-in opens 30 minutes before. - <% end %> + Sign-in opens + . + Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-l:%M %Z") %>. +

<% end %>
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index bade2cecec..17a513d18c 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1164,8 +1164,9 @@ def registration_for(person) let(:registration) { create(:event_registration) } describe "#signed_in? / #open_attendance_entry" do - it "is signed in while an entry has no sign-out" do - entry = create(:event_attendance_time_entry, :open, event_registration: registration) + it "is signed in while today's entry has no sign-out" do + entry = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.now.change(hour: 9)) expect(registration.signed_in?).to be(true) expect(registration.open_attendance_entry).to eq(entry) end @@ -1175,6 +1176,17 @@ def registration_for(person) expect(registration.signed_in?).to be(false) expect(registration.open_attendance_entry).to be_nil end + + # A forgotten sign-out must not follow the registrant into the next training + # day, where it would block the new day's sign-in and, once closed, bank a + # ~24-hour session. Staff close it from the attendance report instead. + it "ignores an entry left open on an earlier day" do + stale = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: 1.day.ago.change(hour: 9)) + expect(registration.signed_in?).to be(false) + expect(registration.open_attendance_entry).to be_nil + expect(registration.open_attendance_entry(1.day.ago.to_date)).to eq(stale) + end end describe "#attendance_entries_on" do diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 72fab9e9d6..9db3896670 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -293,6 +293,18 @@ expect(flash[:alert]).to match(/after the sign-in/) expect(registration.event_attendance_time_entries).to be_empty end + + # The rejected save re-renders the form, so the admin's typed times have to + # survive it — otherwise their correction is thrown away with only the flash + # to explain, and they have to retype it from memory. + it "keeps the submitted times on screen when the save is rejected" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T10:00", signed_out_at: "2026-07-23T09:00" } } } } + + expect(response.body).to include('value="2026-07-23T10:00"') + expect(response.body).to include('value="2026-07-23T09:00"') + end end end diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index 515088cd7b..0021ffb2b5 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -84,6 +84,46 @@ def pay_ce! end end + # A sign-out someone forgot on day one must not carry into day two: it would block + # the new day's sign-in, and closing it then would bank a ~24-hour session against + # day one. It stays open for staff to correct on the attendance report. + describe "an entry left open on an earlier day" do + let(:event) do + create(:event, + ce_hours_offered: 6, ce_hours_cost_cents: 15_000, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 24, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let!(:stale) do + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + end + + before do + pay_ce! + travel_to Time.zone.local(2026, 7, 24, 10, 0) + end + + it "starts day two signed out, offering Sign in rather than Sign out" do + get registration_ce_path(registration.slug) + expect(response.body).to include(registration_ce_sign_in_path(registration.slug)) + expect(response.body).not_to include(registration_ce_sign_out_path(registration.slug)) + end + + it "lets the registrant sign in for the new day" do + expect { + post registration_ce_sign_in_path(registration.slug) + }.to change { registration.event_attendance_time_entries.count }.by(1) + end + + it "leaves the stale entry open rather than closing it with today's time" do + post registration_ce_sign_out_path(registration.slug) + expect(stale.reload.signed_out_at).to be_nil + expect(flash[:alert]).to be_present + end + end + describe "GET /registration/:slug/ce (attendance section)" do it "shows a Sign in button once CE is paid and the window is open" do pay_ce! @@ -158,6 +198,14 @@ def pay_ce! expect(response.body).not_to include(attendance_event_path(event, ce: "true")) end + it "drops the whole section once the training is over" do + pay_ce! + travel_to Time.zone.local(2026, 7, 30, 10, 0) + get registration_ce_path(registration.slug) + expect(response.body).not_to include("Training sign-in") + expect(response.body).not_to include("Sign-in opens") + end + it "hides the attendance section until CE is paid in full" do license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license) From 9cd182389b45af94975f6b9bddd9af9b961fc457 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 10:45:45 -0400 Subject: [PATCH 23/27] Let registrants close a day they forgot to sign out of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the attendance work: A forgotten sign-out previously sat open until staff noticed it on the report, which also under-counted the registrant's hours against the CE certificate gate. It now gets its own catch-up button on the callout, stamped with that day's scheduled end rather than "now" — the correction staff made on the paper sheet — kept separate from today's sign-in/out so the two days can't be confused. The report's "signed in" chip was computed across every entry on the registration but rendered inside a per-day row, so one forgotten sign-out lit up every later day too — the opposite of what staff scan the report for. Scope it to the day. Attendance-entry validation messages reached the admin through the parent registration's nested attributes, which pasted the humanized association name in front of sentences written to display verbatim ("Event attendance time entries This sign-in overlaps…"). The report's registrant-name links led to a callout whose eyebrow returned to the CE edit page, leaving no way back to the report. Co-Authored-By: Claude --- ...uing_education_registrations_controller.rb | 15 +++- app/controllers/events/callouts_controller.rb | 37 ++++++++-- .../event_attendance_time_entry_decorator.rb | 10 +-- app/helpers/event_attendance_helper.rb | 7 ++ app/models/event_attendance_time_entry.rb | 5 +- app/models/event_registration.rb | 25 ++++++- app/views/events/attendance.html.erb | 12 ++-- app/views/events/callouts/ce.html.erb | 70 ++++++++++++++----- spec/models/event_registration_spec.rb | 42 +++++++++++ ...continuing_education_registrations_spec.rb | 14 +++- spec/requests/events/attendance_spec.rb | 33 +++++++++ spec/requests/events/ce_attendance_spec.rb | 49 +++++++++++-- 12 files changed, 268 insertions(+), 51 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 54a3309ab4..be0d2cee3d 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -23,7 +23,7 @@ def create end redirect_to edit_event_registration_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other rescue ActiveRecord::RecordInvalid => e - flash.now[:alert] = e.record.errors.full_messages.to_sentence + flash.now[:alert] = error_sentence(e.record) render :new, status: :unprocessable_content end @@ -41,7 +41,7 @@ def update end redirect_to after_ce_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other rescue ActiveRecord::RecordInvalid => e - flash.now[:alert] = e.record.errors.full_messages.to_sentence + flash.now[:alert] = error_sentence(e.record) render :edit, status: :unprocessable_content end @@ -79,6 +79,17 @@ def after_ce_path(registration) end end + # A failed save's errors as one sentence. Attendance-entry failures arrive on the + # parent registration keyed "event_attendance_time_entries.base", whose full message + # pastes the humanized association name onto a message already written as a whole + # sentence — show those verbatim, and keep full messages for the CE record's own + # attributes ("Hours can't be blank"). + def error_sentence(record) + record.errors.map { |error| + error.attribute.to_s.include?(".") ? error.message : error.full_message + }.to_sentence + end + def set_ce_registration @ce_registration = ContinuingEducationRegistration.find(params[:id]) end diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 6ee9fdf522..660f8de363 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -162,18 +162,19 @@ def sign_in_ce alert: e.record.errors.full_messages.to_sentence end - # Close the registrant's open attendance entry. Not windowed — a forgotten - # sign-out can always be recorded (staff can correct times later on the report). + # Close an open attendance entry. Not windowed — a forgotten sign-out can always + # be recorded. Two cases: today's entry (stamped now) and the catch-up button for + # a day the registrant left open (stamped that day's scheduled end) — see + # #sign_out_target. def sign_out_ce return redirect_to(registration_ce_path(@event_registration.slug)) if sample_preview? - entry = @event_registration.open_attendance_entry + entry, signed_out_at = sign_out_target unless entry return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), alert: "You're not signed in." end - entry.update!(signed_out_at: Time.current) - redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), - notice: "Signed out at #{local_time(entry.signed_out_at)}." + entry.update!(signed_out_at: signed_out_at) + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: sign_out_notice(entry) rescue ActiveRecord::RecordInvalid => e redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), alert: e.record.errors.full_messages.to_sentence @@ -239,7 +240,29 @@ def attendance_enabled? # A datetime rendered in the app zone as "9:02 AM", for sign-in/out flash notices. def local_time(time) - time.in_time_zone(Time.zone).strftime("%-l:%M %p") + helpers.attendance_clock_time(time) + end + + # Which open entry this sign-out closes, and the time to stamp it with. The + # catch-up button names an earlier day's entry explicitly (?entry_id) so it can't + # be confused with today's — it lands on that day's scheduled end rather than now, + # which would bank every hour since. Anything else closes today's entry at now. + def sign_out_target + return [ @event_registration.open_attendance_entry, Time.current ] if params[:entry_id].blank? + + forgotten = @event_registration.forgotten_sign_out_entry + return [] unless forgotten && forgotten.id.to_s == params[:entry_id].to_s + + [ forgotten, @event_registration.forgotten_sign_out_at(forgotten) ] + end + + # Name the day when the sign-out isn't for today, so a catch-up close reads as + # what it is rather than looking like a stray time. + def sign_out_notice(entry) + time = local_time(entry.signed_out_at) + return "Signed out at #{time}." if entry.attendance_date == Time.zone.today + + "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." end # Whether the event's built-in callout for this key is materialized and diff --git a/app/decorators/event_attendance_time_entry_decorator.rb b/app/decorators/event_attendance_time_entry_decorator.rb index e8f7eb5a42..174f2f26a0 100644 --- a/app/decorators/event_attendance_time_entry_decorator.rb +++ b/app/decorators/event_attendance_time_entry_decorator.rb @@ -3,12 +3,12 @@ class EventAttendanceTimeEntryDecorator < ApplicationDecorator # Clock time of the sign-in, in the app zone — e.g. "8:50 AM". def signed_in_label - format_time(signed_in_at) + h.attendance_clock_time(signed_in_at) end # Clock time of the sign-out, or an em dash while still signed in. def signed_out_label - signed_out_at ? format_time(signed_out_at) : "—" + signed_out_at ? h.attendance_clock_time(signed_out_at) : "—" end # Elapsed time as "1h 44m" (or "44m" under an hour); "In progress" while open. @@ -18,10 +18,4 @@ def duration_label h.attendance_duration_label(minutes) end - - private - - def format_time(time) - time.in_time_zone(Time.zone).strftime("%-l:%M %p") - end end diff --git a/app/helpers/event_attendance_helper.rb b/app/helpers/event_attendance_helper.rb index c79e6901de..6117b4d1a4 100644 --- a/app/helpers/event_attendance_helper.rb +++ b/app/helpers/event_attendance_helper.rb @@ -8,4 +8,11 @@ def attendance_duration_label(minutes) "#{hours}h #{mins}m" end + + # A datetime as its clock time in the app zone — "9:02 AM". Shared by the entry + # decorator, the sign-in/out flash notices, and the callout's catch-up prompt so + # every attendance time on screen reads the same. + def attendance_clock_time(time) + time.in_time_zone(Time.zone).strftime("%-l:%M %p") + end end diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index da4152bd03..161ce0e2d9 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -45,11 +45,14 @@ def attendance_date private + # On :base and phrased as a whole sentence like the other two guards: these reach + # the admin through the parent registration's nested attributes, which pastes the + # humanized association name in front of anything keyed to an attribute. def signed_out_after_signed_in return if signed_out_at.blank? || signed_in_at.blank? return if signed_out_at > signed_in_at - errors.add(:signed_out_at, "must be after the sign-in time") + errors.add(:base, "Sign-out must be after the sign-in time.") end # The day's total logged time (this entry plus its same-day siblings) can't exceed diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index e54c9dba8e..b4e025bab1 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -491,13 +491,34 @@ def cost_cents # one day, or nil when they're not signed in that day. Drives which sign-in/out # button the CE callout shows. Deliberately day-scoped: an entry left open when # someone forgets to sign out must not carry into the next training day, where it - # would block the new day's sign-in and, once closed, bank a 24-hour session. The - # stale row stays flagged on the attendance report for staff to correct. + # would block the new day's sign-in and, once closed, bank every hour since. The + # earlier day is closed separately, through #forgotten_sign_out_entry. # Uses the most recent open entry if more than one somehow exists. def open_attendance_entry(date = Time.zone.today) attendance_entries_on(date).select(&:open?).last end + # A sign-out the registrant forgot on an earlier day: the most recent entry still + # open from before `date`. Offered on today's callout as its own catch-up button, + # separate from today's sign-in/out, so the two days can't be confused. Only when + # #forgotten_sign_out_at has something sensible to stamp — otherwise it's a staff + # correction on the attendance report, not a one-click fix. + def forgotten_sign_out_entry(date = Time.zone.today) + entry = event_attendance_time_entries.chronological + .select { |candidate| candidate.open? && candidate.attendance_date && candidate.attendance_date < date } + .last + entry if entry && forgotten_sign_out_at(entry) + end + + # The time a forgotten sign-out is stamped with: the scheduled end of the training + # day it belongs to — what staff wrote on the paper sheet — never "now", which would + # bank every hour since. Nil when that end isn't after the sign-in (someone signed in + # after the day was over), leaving it for staff. + def forgotten_sign_out_at(entry) + close_at = event.daily_end_at(entry.attendance_date) + close_at if close_at > entry.signed_in_at + end + # Whether the registrant is currently signed in (today). def signed_in? open_attendance_entry.present? diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index d3c1a1f2a5..be8683b077 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -81,10 +81,10 @@ <% end %>
<% if @report.ce_only? && ce_reg %> - <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "ce_registration"), + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "attendance"), class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> <% else %> - <%= link_to reg.registrant.full_name, edit_event_registration_path(reg), + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg, return_to: "attendance"), class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> <% end %>
@@ -213,13 +213,15 @@ "aria-label": "Edit #{@report.ce_only? && ce_reg ? "CE registration" : "registration"} for #{reg.registrant.full_name}" %>
<% if @report.ce_only? && ce_reg %> - <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "ce_registration"), + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "attendance"), class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> <% else %> - <%= link_to reg.registrant.full_name, edit_event_registration_path(reg), + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg, return_to: "attendance"), class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> <% end %> - <% if @report.open?(reg) %> + <%# Day-scoped: this row is one day, so the flag has to be about + this day's entries, not any open entry on any day. %> + <% if entries.any?(&:open?) %> signed in <% end %>
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 84263a4099..483f358ab1 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -3,16 +3,23 @@ <% ce_registration = @event_registration.continuing_education_registrations.first %> <%# - Reached from the admin CE registration edit page (return_to=ce_registration): - point the eyebrow back there instead of the registrant's ticket. Gated on edit - access so a registrant who lands on this URL still gets the default ticket back. + Reached from an admin page (return_to): point the eyebrow back at that page + instead of the registrant's ticket — the CE registration edit page, or the event's + CE sign-in report, whose registrant links land here. Each gated on access to the + destination so a registrant who lands on one of these URLs still gets the default + ticket back. %> -<% callout_eyebrow = if params[:return_to] == "ce_registration" && ce_registration && allowed_to?(:edit?, ce_registration) - { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } - else - {} - end %> +<% callout_eyebrow = case params[:return_to] + when "ce_registration" + if ce_registration && allowed_to?(:edit?, ce_registration) + { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } + end + when "attendance" + if allowed_to?(:attendance?, @event) + { back_path: attendance_event_path(@event, ce: "true", anchor: "totals"), back_label: "Back to CE sign-in report" } + end + end || {} %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_label, **callout_eyebrow } do %> <%# Requesting CE flips this frame in place: the POST redirects back here and @@ -33,11 +40,13 @@ <% simulate_ce_paid = params[:admin] == "true" && ce_registration && allowed_to?(:edit?, ce_registration) %> <%# Admin-only jump to the management surface for this CE registration. Hidden - from registrants; opens in a new tab so the registrant view is kept. + from registrants; opens in a new tab so the registrant view is kept. Carries + this page's origin through so the new tab's eyebrow returns where the admin + started rather than to the registration edit default. %> <% if ce_registration && allowed_to?(:edit?, ce_registration) && !sample_preview? %>
- <%= render "events/callouts/admin_edit_link", path: edit_continuing_education_registration_path(ce_registration), label: "Edit CE registration" %> + <%= render "events/callouts/admin_edit_link", path: edit_continuing_education_registration_path(ce_registration, return_to: params[:return_to].presence), label: "Edit CE registration" %>
<% end %> <%= turbo_frame_tag "license_section" do %> @@ -239,22 +248,25 @@ <%# Training sign-in/out — the in-portal replacement for the paper CE hour - sign-in sheet, shown only once CE is paid in full. One button at a time: - Sign in when signed out (only inside the day's window), Sign out while signed - in today (always, so a forgotten sign-out can still be closed that day). Many + sign-in sheet, shown only once CE is paid in full. One button at a time for + today: Sign in when signed out (only inside the day's window), Sign out while + signed in (always, so a forgotten sign-out can still be closed that day). Many entries per day is expected (breaks, lunch). An entry left open overnight - doesn't carry over — the next day starts at Sign in, and the stale row is - staff's to close on the admin CE edit page, alongside any other correction. + doesn't carry into today's state — it gets its own catch-up button below, + stamped with that day's scheduled end rather than now. %> <% attendance_offered = ce_registration&.paid_in_full? %> <% open_entry = attendance_offered ? @event_registration.open_attendance_entry : nil %> <% signed_in = open_entry.present? %> + <% forgotten_entry = attendance_offered ? @event_registration.forgotten_sign_out_entry : nil %> + <% forgotten_at = forgotten_entry && @event_registration.forgotten_sign_out_at(forgotten_entry) %> <% todays_entries = attendance_offered ? @event_registration.attendance_entries_on(Time.zone.today) : [] %> <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %> <% opens_at = @event.next_attendance_sign_in_opens_at %> <%# Once the last day's window has passed there's no sign-in left to offer or - announce, so the section drops rather than nagging about a training that's over. %> - <% if attendance_offered && (signed_in || @event.attendance_sign_in_open? || opens_at || todays_entries.any?) %> + announce, so the section drops rather than nagging about a training that's + over — unless a day is still hanging open and waiting to be closed. %> + <% if attendance_offered && (signed_in || forgotten_entry || @event.attendance_sign_in_open? || opens_at || todays_entries.any?) %>

Training sign-in

@@ -265,6 +277,28 @@ <% end %>
+ <%# A day left open: its own prompt above today's controls, naming the day and + the time it will be recorded as, so closing it can't be mistaken for + signing out of today. %> + <% if forgotten_entry %> +
+

+ + You never signed out on <%= forgotten_entry.attendance_date.strftime("%A, %B %-d") %>, after signing in at <%= forgotten_entry.decorate.signed_in_label %>. + Signing out records <%= attendance_clock_time(forgotten_at) %>, when that day's training ended. + +

+ <% if sample_preview? %> + + <% else %> + <%= button_to "Sign out for #{forgotten_entry.attendance_date.strftime("%b %-d")}", + registration_ce_sign_out_path(@event_registration.slug, entry_id: forgotten_entry.id), data: { turbo: false }, + class: "shrink-0 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-300 cursor-pointer" %> + <% end %> +
+ <% end %> +
<% if signed_in %> @@ -303,7 +337,7 @@ Sign-in opens . - Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-l:%M %Z") %>. + Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-l:%M %p %Z") %>.

<% end %> diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 17a513d18c..f460f255fb 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1201,5 +1201,47 @@ def registration_for(person) expect(registration.attendance_entries_on(Date.new(2026, 7, 23))).to eq([ first, second ]) end end + + describe "#forgotten_sign_out_entry / #forgotten_sign_out_at" do + # A two-day training running 9:00–16:00 each day. + let(:event) do + create(:event, start_date: Time.zone.local(2026, 7, 23, 9, 0), end_date: Time.zone.local(2026, 7, 24, 16, 0)) + end + let(:registration) { create(:event_registration, event: event) } + + around { |example| travel_to(Time.zone.local(2026, 7, 24, 10, 0)) { example.run } } + + it "stamps an earlier day's forgotten sign-out with that day's scheduled end" do + stale = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 5)) + + expect(registration.forgotten_sign_out_entry).to eq(stale) + expect(registration.forgotten_sign_out_at(stale)).to eq(Time.zone.local(2026, 7, 23, 16, 0)) + end + + it "ignores today's open entry — that one is closed by the ordinary Sign out" do + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 9, 5)) + + expect(registration.forgotten_sign_out_entry).to be_nil + end + + it "ignores earlier days that were closed properly" do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0), signed_out_at: Time.zone.local(2026, 7, 23, 16, 0)) + + expect(registration.forgotten_sign_out_entry).to be_nil + end + + # Nothing sensible to stamp, so it isn't offered as a one-click close — staff + # correct it on the attendance report instead. + it "declines a sign-in recorded after that day had already ended" do + late = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 18, 0)) + + expect(registration.forgotten_sign_out_at(late)).to be_nil + expect(registration.forgotten_sign_out_entry).to be_nil + end + end end end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 9db3896670..6de5d0320a 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -281,7 +281,9 @@ }.not_to change { registration.event_attendance_time_entries.count } expect(response).to have_http_status(:unprocessable_content) - expect(flash[:alert]).to match(/overlaps/) + # Verbatim: entry messages are whole sentences, so the nested-attributes + # association prefix ("Event attendance time entries …") must not be pasted on. + expect(flash[:alert]).to eq("This sign-in overlaps another entry on Jul 23.") end it "rejects a sign-out before the sign-in with a helpful error" do @@ -290,10 +292,18 @@ time_entries: { "0" => { signed_in_at: "2026-07-23T10:00", signed_out_at: "2026-07-23T09:00" } } } } expect(response).to have_http_status(:unprocessable_content) - expect(flash[:alert]).to match(/after the sign-in/) + expect(flash[:alert]).to eq("Sign-out must be after the sign-in time.") expect(registration.event_attendance_time_entries).to be_empty end + it "still spells out the CE record's own attribute errors in full" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "", cost_dollars: "120" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to match(/\AHours /) + end + # The rejected save re-renders the form, so the admin's typed times have to # survive it — otherwise their correction is thrown away with only the flash # to explain, and they have to retype it from memory. diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb index dc9846df14..8e5dc2a5b7 100644 --- a/spec/requests/events/attendance_spec.rb +++ b/spec/requests/events/attendance_spec.rb @@ -77,6 +77,18 @@ def log_ce_time! expect(response.body).not_to include("#{edit_event_registration_path(registration)}?return_to=attendance") end + # The name link opens the registrant-facing callout; its eyebrow has to lead back + # to the report, not to the registration edit default two hops away. + it "sends the name link to a CE callout that points back at the report" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{registration_ce_path(registration.slug)}?return_to=attendance") + + get registration_ce_path(registration.slug, return_to: "attendance") + expect(response.body).to include("Back to CE sign-in report") + expect(response.body).to include(attendance_event_path(event, ce: "true", anchor: "totals")) + end + it "returns to the registrants page when opened from there" do get attendance_event_path(event, ce: "true", return_to: "registrants") expect(response.body).to include("← Registrants") @@ -92,6 +104,27 @@ def log_ce_time! expect(response.body).to include("Day 1 · #{Date.new(2026, 7, 23).strftime("%A, %b %-d")} · 9 am - 4 pm UTC") end + # The chip flags an entry with no sign-out, so on a per-day table it has to be + # about that day — otherwise one forgotten sign-out lights up every later day too, + # which is exactly what staff are scanning the report to find. + it "flags 'signed in' only on the day whose entry is still open" do + event.update!(end_date: Time.zone.local(2026, 7, 24, 16, 0)) + license = create(:professional_license, person: registration.registrant, number: "AAA111") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 9, 0), signed_out_at: Time.zone.local(2026, 7, 24, 12, 0)) + + get attendance_event_path(event, ce: "true", group: "day") + + sections = Capybara.string(response.body).all("section") + day_one = sections.find { |section| section.text.squish.start_with?("Day 1 ·") } + day_two = sections.find { |section| section.text.squish.start_with?("Day 2 ·") } + expect(day_one).to have_css("span.bg-teal-50", text: "signed in") + expect(day_two).to have_no_css("span.bg-teal-50") + end + it "warns when the event runs longer than the report's 5-day cap" do event.update!(end_date: Time.zone.local(2026, 7, 30, 16, 0)) get attendance_event_path(event) diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb index 0021ffb2b5..f340ea7f5d 100644 --- a/spec/requests/events/ce_attendance_spec.rb +++ b/spec/requests/events/ce_attendance_spec.rb @@ -85,8 +85,9 @@ def pay_ce! end # A sign-out someone forgot on day one must not carry into day two: it would block - # the new day's sign-in, and closing it then would bank a ~24-hour session against - # day one. It stays open for staff to correct on the attendance report. + # the new day's sign-in, and closing it at "now" would bank a ~24-hour session + # against day one. Day two starts fresh, and the open day gets its own catch-up + # button stamped with that day's scheduled end. describe "an entry left open on an earlier day" do let(:event) do create(:event, @@ -105,10 +106,9 @@ def pay_ce! travel_to Time.zone.local(2026, 7, 24, 10, 0) end - it "starts day two signed out, offering Sign in rather than Sign out" do + it "starts day two signed out, offering Sign in for today" do get registration_ce_path(registration.slug) expect(response.body).to include(registration_ce_sign_in_path(registration.slug)) - expect(response.body).not_to include(registration_ce_sign_out_path(registration.slug)) end it "lets the registrant sign in for the new day" do @@ -117,11 +117,48 @@ def pay_ce! }.to change { registration.event_attendance_time_entries.count }.by(1) end - it "leaves the stale entry open rather than closing it with today's time" do + it "prompts to close day one, naming the day and the time it will record" do + get registration_ce_path(registration.slug) + expect(response.body).to include("You never signed out on") + expect(response.body).to include("Thursday, July 23") + expect(response.body).to include("Sign out for Jul 23") + # The 16:00 UTC end of that training day, rendered in the page's Pacific zone. + expect(response.body).to include("Signing out records 9:00 AM, when that day's training ended.") + expect(response.body).to include(registration_ce_sign_out_path(registration.slug, entry_id: stale.id)) + end + + it "closes day one at that day's scheduled end, not now" do + post registration_ce_sign_out_path(registration.slug, params: { entry_id: stale.id }) + + expect(stale.reload.signed_out_at).to eq(Time.zone.local(2026, 7, 23, 16, 0)) + expect(flash[:notice]).to include("Signed out for Thu, Jul 23") + end + + it "leaves the stale entry alone when today's Sign out is used instead" do post registration_ce_sign_out_path(registration.slug) expect(stale.reload.signed_out_at).to be_nil expect(flash[:alert]).to be_present end + + it "ignores an entry_id that isn't the registrant's open earlier day" do + other = create(:event_attendance_time_entry, :open, event_registration: create(:event_registration), + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + + post registration_ce_sign_out_path(registration.slug, params: { entry_id: other.id }) + + expect(other.reload.signed_out_at).to be_nil + expect(stale.reload.signed_out_at).to be_nil + expect(flash[:alert]).to be_present + end + + # Nothing sensible to stamp when the sign-in is after the day was already over, + # so the one-click close isn't offered and staff correct it on the report. + it "doesn't offer the catch-up close for a sign-in after that day ended" do + stale.update_columns(signed_in_at: Time.zone.local(2026, 7, 23, 20, 0)) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include("You never signed out on") + end end describe "GET /registration/:slug/ce (attendance section)" do @@ -181,7 +218,7 @@ def pay_ce! expect(response.body).to include("Sign-in opens") # 8:30/9:00 UTC (open/start) shown in the page's Pacific zone. expect(response.body).to include("1:30 AM PDT") - expect(response.body).to include("Event begins 30 min later, at 2:00 PDT.") + expect(response.body).to include("Event begins 30 min later, at 2:00 AM PDT.") expect(response.body).not_to include("in about 2 hours") end From 662f7dcdd08a30e558ad8be23a36516204112f50 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 11:07:58 -0400 Subject: [PATCH 24/27] Correct a day's sign-in times on the report instead of clicking out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing one missed sign-in meant leaving the attendance report for the CE edit page, correcting a datetime, and navigating back — for a sheet whose whole point is scanning a training day at a glance. Each day's sessions cell now opens in place. Times are clock times, not datetimes: the day is the section the editor sits in, so a correction is two fields rather than two dates to retype and get wrong. A blank trailing row adds a session (including both halves of a day nobody signed in on), Remove drops one, and an empty sign-out leaves the session open. Registrants still stamp their own times from the CE callout — this is the correction surface, not a replacement for it. Server-rendered via an `edit` param rather than a Stimulus toggle, so a rejected save can hand the submitted times back through the flash and reopen the cell with them; the CE edit page keeps that property and the report shouldn't lose it. The whole-row link is suppressed while a cell is being edited so a stray click can't navigate away mid-correction. The nested-attributes write and audit stamping now live in EventAttendanceEntriesUpdate, shared with the CE edit form, and the verbatim-message error formatting moves to ApplicationController since two controllers now surface nested-association failures. Co-Authored-By: Claude --- AGENTS.md | 1 + app/controllers/application_controller.rb | 10 ++ ...uing_education_registrations_controller.rb | 35 +----- .../event_registrations_controller.rb | 69 ++++++++++- app/helpers/event_attendance_helper.rb | 23 ++++ app/policies/event_registration_policy.rb | 3 + .../event_attendance_entries_update.rb | 53 +++++++++ .../events/_attendance_day_form.html.erb | 39 +++++++ .../events/_attendance_sessions.html.erb | 37 ++++++ app/views/events/attendance.html.erb | 56 +++++---- config/routes.rb | 1 + spec/requests/events/attendance_spec.rb | 110 ++++++++++++++++++ 12 files changed, 374 insertions(+), 63 deletions(-) create mode 100644 app/services/event_attendance_entries_update.rb create mode 100644 app/views/events/_attendance_day_form.html.erb create mode 100644 app/views/events/_attendance_sessions.html.erb diff --git a/AGENTS.md b/AGENTS.md index 9ab1d0c73e..66ad9df0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,7 @@ action, or `authorize! :workshop, to: :summary?`). - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards - `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card - `EventAttendanceReport` — Per-event attendance sign-in/out report from `EventAttendanceTimeEntry`, grouped by day then registrant with per-day and grand-total minutes; `ce_only:` scopes to CE registrants and surfaces license number + awarded hours. The in-portal CE hour sign-in sheet, linked from the participation report (`?ce=true`) at `attendance_event_path` +- `EventAttendanceEntriesUpdate` — Applies a batch of submitted sign-in/out rows to one `EventRegistration`'s `event_attendance_time_entries` (add/correct/remove through nested attributes) and stamps `created_by`/`updated_by` with the editing admin. Shared by the CE edit form (`ContinuingEducationRegistrationsController#update`, datetime rows across every day) and the attendance report's inline per-day editor (`EventRegistrationsController#update_attendance`, clock times against one day) - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ac70f759af..b95c47d761 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -35,6 +35,16 @@ def csv_dollars(cents) cents.positive? ? helpers.dollars_from_cents(cents) : "" end + # A failed save's errors as one flash-ready sentence. Errors on a nested association + # arrive keyed ".", and their full message pastes the + # humanized association name in front — fine for "Hours can't be blank", wrong for a + # child validation written as a whole sentence, so those show verbatim. + def error_sentence(record) + record.errors.map { |error| + error.attribute.to_s.include?(".") ? error.message : error.full_message + }.to_sentence + end + def after_sign_out_path_for(resource_or_scope) if params[:reset_password].present? # needed for custom "log out and reset it" flow new_user_password_path diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index be0d2cee3d..02e0e12ce0 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -79,17 +79,6 @@ def after_ce_path(registration) end end - # A failed save's errors as one sentence. Attendance-entry failures arrive on the - # parent registration keyed "event_attendance_time_entries.base", whose full message - # pastes the humanized association name onto a message already written as a whole - # sentence — show those verbatim, and keep full messages for the CE record's own - # attributes ("Hours can't be blank"). - def error_sentence(record) - record.errors.map { |error| - error.attribute.to_s.include?(".") ? error.message : error.full_message - }.to_sentence - end - def set_ce_registration @ce_registration = ContinuingEducationRegistration.find(params[:id]) end @@ -121,28 +110,10 @@ def apply_ce_params(ce_registration) end # Staff corrections to the registrant's attendance times, submitted alongside the - # CE form under continuing_education_registration[time_entries]. Mapped onto the - # registration's nested-attributes setter (create/update/destroy), then attributed - # to current_user — these are the only attributed entries (self-service is not). + # CE form under continuing_education_registration[time_entries] as full datetimes + # (this form spans every day, unlike the report's per-day editor). def apply_time_entries(registration) - rows = time_entries_attributes - return if rows.blank? - - # Drop rows pointing at an entry that's no longer on this registration — a stale - # form or double-submit (it was already removed). Left in, nested attributes raise - # RecordNotFound and blow up the save. - existing_ids = registration.event_attendance_time_entries.pluck(:id).map(&:to_s) - rows = rows.reject { |row| row["id"].present? && existing_ids.exclude?(row["id"].to_s) } - return if rows.blank? - - registration.assign_attributes(event_attendance_time_entries_attributes: rows) - registration.event_attendance_time_entries.each do |entry| - next if entry.marked_for_destruction? - - entry.created_by ||= current_user if entry.new_record? - entry.updated_by = current_user if entry.new_record? || entry.changed? - end - registration.save! + EventAttendanceEntriesUpdate.new(registration, time_entries_attributes, editor: current_user).save! end def time_entries_attributes diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index dca2d3d8f6..6b024deeab 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -2,7 +2,7 @@ class EventRegistrationsController < ApplicationController require "csv" # show redirects to slug URL; kept for backwards compatibility - before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :update_attendance ] def index authorize! @@ -156,6 +156,28 @@ def update_onboarding end end + # Inline correction of one registrant's sign-in/out times for one training day, from + # the event's attendance report. Rows carry clock times only — the day comes from the + # report section the editor was opened in — plus a blank row to add a session and a + # Remove box to drop one. Registrants still stamp their own times from the CE callout; + # this is where staff fix a missed sign-in or a forgotten sign-out without leaving the + # report. + def update_attendance + authorize! @event_registration, to: :update_attendance? + date = attendance_date_param + return head :unprocessable_content unless date + + rows = attendance_rows(date) + EventAttendanceEntriesUpdate.new(@event_registration, rows, editor: current_user).save! + redirect_to attendance_report_path(date), notice: "Attendance times updated.", status: :see_other + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = error_sentence(e.record) + # Hand the submitted times back so a rejected save doesn't cost the admin what + # they typed; the editor reopens on this cell prefilled with them. + flash[:attendance_rows] = submitted_attendance_rows + redirect_to attendance_report_path(date, reopen: true), status: :see_other + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? @@ -320,6 +342,51 @@ def set_event_registration @event_registration = EventRegistration.includes({ registrant: [ :user, { affiliations: :organization } ] }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end + # The training day the inline editor was opened on. Nil for anything unparseable — + # the date comes from the report's own sections, so a bad one is a broken request. + def attendance_date_param + Date.iso8601(params[:date].to_s) + rescue ArgumentError + nil + end + + # The submitted sessions as attendance-entry attributes. Clock times ("08:50") are + # combined with the editor's day, since a session belongs to the day it's listed + # under; the CE edit page stays the place to enter a pair that crosses midnight. + def attendance_rows(date) + submitted_attendance_rows.map do |row| + { "id" => row["id"], + "signed_in_at" => attendance_time(date, row["in"]), + "signed_out_at" => attendance_time(date, row["out"]), + "_destroy" => row["_destroy"] } + end + end + + def submitted_attendance_rows + params.fetch(:attendance, {}) + .permit(entries: [ :id, :in, :out, :_destroy ]) + .fetch(:entries, {}) + .values + .map { |row| row.to_h.stringify_keys } + end + + # Blank stays blank: an empty sign-in marks an untouched row (dropped by the + # association's reject_if), an empty sign-out leaves the session open. + def attendance_time(date, clock) + return nil if clock.blank? + + Time.zone.parse("#{date.iso8601} #{clock}") + end + + # Back to the report in read mode, scrolled to the day cell that was edited, keeping + # whichever view the admin had open. `reopen:` puts that cell back into edit mode. + def attendance_report_path(date, reopen: false) + cell = helpers.attendance_cell_id(@event_registration, date) + attendance_event_path(@event_registration.event, + ce: params[:ce].presence, group: params[:group].presence, return_to: params[:return_to].presence, + edit: (cell if reopen), anchor: cell) + end + # Creates the audited completion row for a checklist step (recording who/when), # or removes it — so an unchecked step leaves no trace. def toggle_checklist_step(step, completed) diff --git a/app/helpers/event_attendance_helper.rb b/app/helpers/event_attendance_helper.rb index 6117b4d1a4..3b0303ba38 100644 --- a/app/helpers/event_attendance_helper.rb +++ b/app/helpers/event_attendance_helper.rb @@ -15,4 +15,27 @@ def attendance_duration_label(minutes) def attendance_clock_time(time) time.in_time_zone(Time.zone).strftime("%-l:%M %p") end + + # Identifies one registrant's sessions on one training day — the report's editable + # unit. Doubles as the cell's DOM id, the `edit` param that opens it, and the anchor + # the page returns to after a save. + def attendance_cell_id(registration, date) + "attendance-#{registration.id}-#{date.iso8601}" + end + + # The rows the inline day editor renders: one per logged session plus a trailing + # blank to add another (fill it and save, same as the CE edit page's table). After a + # rejected save the submitted values come back through the flash, so a validation + # error doesn't cost the admin what they typed. + def attendance_editor_rows(entries) + rows = flash[:attendance_rows].presence || entries.map { |entry| + { "id" => entry.id.to_s, "in" => attendance_input_time(entry.signed_in_at), "out" => attendance_input_time(entry.signed_out_at) } + } + rows.reject { |row| row.values_at("id", "in", "out").all?(&:blank?) } + [ {} ] + end + + # A datetime as the "HH:MM" an expects, in the app zone. + def attendance_input_time(time) + time&.in_time_zone(Time.zone)&.strftime("%H:%M") + end end diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index c7ba2458b9..1d43d2e4bb 100644 --- a/app/policies/event_registration_policy.rb +++ b/app/policies/event_registration_policy.rb @@ -18,6 +18,9 @@ def unlink_organization? = admin? # Editing the onboarding matrix is an admin management action; event owners # (the event's creator) manage their own events' onboarding too. def update_onboarding? = admin? || event_owner? + # Correcting attendance times inline on the event's sign-in report — same reach as + # reading that report (EventPolicy#attendance?). + def update_attendance? = admin? || event_owner? relation_scope do |relation| diff --git a/app/services/event_attendance_entries_update.rb b/app/services/event_attendance_entries_update.rb new file mode 100644 index 0000000000..959642cc63 --- /dev/null +++ b/app/services/event_attendance_entries_update.rb @@ -0,0 +1,53 @@ +# Applies a batch of submitted sign-in/out rows to one registration's attendance +# entries — add, correct, remove — through the registration's nested-attributes +# setter, then attributes the change to the editing admin. Shared by the CE edit +# form (datetime rows spanning every day) and the attendance report's per-day inline +# editor (clock times against one known day): different row shapes on the way in, the +# same write on the way out. +# +# Rows are hashes (or permitted params) of "id", "signed_in_at", "signed_out_at" and +# "_destroy"; a row with no sign-in is an untouched blank and is dropped by the +# association's reject_if. +class EventAttendanceEntriesUpdate + def initialize(registration, rows, editor:) + @registration = registration + @rows = rows + @editor = editor + end + + # Saves the batch, raising ActiveRecord::RecordInvalid so callers can roll back and + # report. A no-op when there's nothing left to apply. + def save! + applicable = applicable_rows + return if applicable.blank? + + registration.assign_attributes(event_attendance_time_entries_attributes: applicable) + attribute_to_editor + registration.save! + end + + private + + attr_reader :registration, :rows, :editor + + # Drop rows pointing at an entry that's no longer on this registration — a stale + # form or double-submit (it was already removed). Left in, nested attributes raise + # RecordNotFound and blow up the save. + def applicable_rows + return [] if rows.blank? + + existing_ids = registration.event_attendance_time_entries.pluck(:id).map(&:to_s) + rows.reject { |row| row["id"].present? && existing_ids.exclude?(row["id"].to_s) } + end + + # Staff edits are the only attributed entries — registrant self-service sign-ins on + # the public callout leave created_by nil. + def attribute_to_editor + registration.event_attendance_time_entries.each do |entry| + next if entry.marked_for_destruction? + + entry.created_by ||= editor if entry.new_record? + entry.updated_by = editor if entry.new_record? || entry.changed? + end + end +end diff --git a/app/views/events/_attendance_day_form.html.erb b/app/views/events/_attendance_day_form.html.erb new file mode 100644 index 0000000000..720d309a5a --- /dev/null +++ b/app/views/events/_attendance_day_form.html.erb @@ -0,0 +1,39 @@ +<%# + Inline editor for one registrant's sessions on one training day. Times are clock + times, not datetimes — the day is the section this sits in, so a correction is two + fields rather than two dates the admin has to retype and can get wrong. The trailing + blank row adds a session (fill it and save; a fresh blank comes back), and Remove + drops one, matching the CE edit page's table. + locals: registration, date, entries (decorated), state (page params), cell. +%> +<% rows = attendance_editor_rows(entries) %> +<% input_class = "w-28 rounded-lg border border-gray-300 px-2 py-1 text-xs text-gray-900 shadow-sm tabular-nums focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + +<%= form_with url: update_attendance_event_registration_path(registration, date: date.iso8601, **state), + method: :patch, data: { turbo: false }, class: "space-y-1.5" do %> + <% rows.each_with_index do |row, index| %> +
+ <% if row["id"].present? %> + <%= hidden_field_tag "attendance[entries][#{index}][id]", row["id"], id: nil %> + <% end %> + <%= text_field_tag "attendance[entries][#{index}][in]", row["in"], type: "time", id: nil, + class: input_class, "aria-label": "Time in" %> + + <%= text_field_tag "attendance[entries][#{index}][out]", row["out"], type: "time", id: nil, + class: input_class, "aria-label": "Time out" %> + <% if row["id"].present? %> + + <% end %> +
+ <% end %> + +
+ + <%= link_to "Cancel", attendance_event_path(@event, **state, anchor: cell), class: "text-xs text-gray-500 hover:text-gray-700" %> + Leave the sign-out blank to leave a session open. +
+<% end %> diff --git a/app/views/events/_attendance_sessions.html.erb b/app/views/events/_attendance_sessions.html.erb new file mode 100644 index 0000000000..1baf80cd85 --- /dev/null +++ b/app/views/events/_attendance_sessions.html.erb @@ -0,0 +1,37 @@ +<%# + One registrant's sign-in/out sessions on one training day — the report's editable + unit, shared by the by-person and by-day groupings (same cell, different axis). + Read mode shows a chip per session; Edit swaps the cell for a form so staff can + correct a time, add the pair for a day nobody signed in on, or drop a stray row, + without leaving the report. Registrants still stamp their own times from the CE + callout — this is the correction surface, not a replacement for it. + locals: registration, date, entries (decorated), editing (Boolean), state (page params). +%> +<% cell = attendance_cell_id(registration, date) %> +<%# z-10 lifts the cell above the row's whole-row link so the controls are clickable. %> +
+ <% if editing %> + <%= render "events/attendance_day_form", registration: registration, date: date, + entries: entries, state: state, cell: cell %> + <% else %> +
+ <% if entries.any? %> + <% entries.each do |entry| %> + + <%= entry.signed_in_label %>–<%= entry.signed_out_label %> · <%= entry.duration_label %> + + <% end %> + <% else %> + Not signed in + <% end %> + <%# Muted until the row is hovered, rather than hidden: 30 people × 5 days of Edit + links shouldn't drown out the times, but a hover-only control is unreachable + on a touch screen. %> + <%= link_to attendance_event_path(@event, **state, edit: cell, anchor: cell), + class: "ml-1 rounded px-1.5 py-0.5 text-xs font-medium text-teal-700 opacity-50 transition-opacity hover:bg-teal-50 hover:underline group-hover:opacity-100 focus:opacity-100", + "aria-label": "Edit #{registration.registrant.full_name}'s times for #{date.strftime("%b %-d")}" do %> + Edit + <% end %> +
+ <% end %> +
diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb index be8683b077..b457d6ac4a 100644 --- a/app/views/events/attendance.html.erb +++ b/app/views/events/attendance.html.erb @@ -120,6 +120,9 @@ the ce/return_to params. %> <% group_by_day = params[:group] == "day" %> <% toggle_params = { ce: params[:ce].presence, return_to: params[:return_to].presence }.compact %> + <%# The page state every session-cell link has to carry so editing, saving and + cancelling all land back on the view the admin was looking at. %> + <% cell_state = toggle_params.merge(group: params[:group].presence).compact %>

<%= group_by_day ? "Sessions by day" : "Sessions by person" %>