Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c602a26
Add EventAttendanceTimeEntry model + per-day sign-in window
maebeale Aug 4, 2026
02b6397
Add CE sign-in/out to the callout + live card reminder
maebeale Aug 4, 2026
dc7224f
Let staff edit attendance times on the CE edit form
maebeale Aug 4, 2026
93545dc
Add per-event attendance report, linked from participation
maebeale Aug 4, 2026
743e9e7
Cover EventPolicy#attendance? in the policy spec
maebeale Aug 4, 2026
688eb98
Gate CE certificate on logged time approximating awarded hours
maebeale Aug 4, 2026
693f3ff
Link the attendance report from registrants + add per-CE edit
maebeale Aug 4, 2026
d6c277c
Address review: report round-trip, 5-day cap warning, index + dead code
maebeale Aug 4, 2026
2f25800
Harden CE attendance edits and clarify the sign-in gating
maebeale Aug 4, 2026
2f28c54
Lead the attendance report with totals, teal-set-apart
maebeale Aug 4, 2026
e4ecd9f
Guard attendance entries against >24h/day and same-day overlaps
maebeale Aug 4, 2026
bcc59e8
Attendance report: per-day logged columns, consistent totals, row/nam…
maebeale Aug 4, 2026
2654054
Stack the Totals header labels for readability
maebeale Aug 4, 2026
82195cd
Show daily training hours on the attendance report
maebeale Aug 4, 2026
7cdf6dc
Callout header date line + concrete sign-in/event times
maebeale Aug 4, 2026
8d1dcd0
Hide the Signed out chip until something is logged today
maebeale Aug 4, 2026
3d6faf6
CE sign-in polish: minimum 1m durations, zone label, CTA sign-out
maebeale Aug 4, 2026
e7b9619
Compact the CE credit + license sections to single rows
maebeale Aug 4, 2026
7edb3cf
Admin chip on the sign-in section linking to the attendance report
maebeale Aug 4, 2026
e194577
Report grouping toggle + generic sign-in report links
maebeale Aug 4, 2026
de2b88c
Make report session rows clickable to the right edit page
maebeale Aug 4, 2026
7012ecf
Keep a forgotten sign-out from swallowing the next training day
maebeale Aug 4, 2026
615c033
Let registrants close a day they forgot to sign out of
maebeale Aug 4, 2026
b7dfb14
Correct a day's sign-in times on the report instead of clicking out
maebeale Aug 4, 2026
047b768
Pin the report name links' return path with a regression spec
maebeale Aug 7, 2026
fe77db8
Match the two sign-in-notice times' hour format
maebeale Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -199,6 +200,8 @@ 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`
- `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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<association>.<attribute>", 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
Expand Down
38 changes: 32 additions & 6 deletions app/controllers/continuing_education_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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] = error_sentence(e.record)
render :new, status: :unprocessable_content
end

Expand All @@ -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
redirect_to after_ce_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other
rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = error_sentence(e.record)
render :edit, status: :unprocessable_content
end

Expand All @@ -54,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
Expand All @@ -67,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
Expand Down Expand Up @@ -96,4 +108,18 @@ 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] as full datetimes
# (this form spans every day, unlike the report's per-day editor).
def apply_time_entries(registration)
EventAttendanceEntriesUpdate.new(registration, time_entries_attributes, editor: current_user).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
70 changes: 69 additions & 1 deletion app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -155,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?
Expand Down Expand Up @@ -319,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)
Expand Down
Loading