Skip to content

Events IA refactor: subnav, cross-event reports, self-filtering breakdowns - #2113

Merged
maebeale merged 39 commits into
mainfrom
maebeale/helsinki
Aug 10, 2026
Merged

Events IA refactor: subnav, cross-event reports, self-filtering breakdowns#2113
maebeale merged 39 commits into
mainfrom
maebeale/helsinki

Conversation

@maebeale

@maebeale maebeale commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

🤖 suggested review level: 5 Inspect 🔬 multi-phase refactor: event stat services, model scopes, routes, policies, subnav, per-event Roster + cross-event report pages

Phased refactor of the event admin area — consolidating duplicated stats, clarifying naming, and restructuring the per-event nav. Each phase is a self-contained commit.

Subnav (per event)

Dashboard | Manage | Roster | Reports | Scholarships | Edit event

  • Manage = the old Registrants tab (label + heading; route/action stay registrants).
  • Roster = per-event page showing the event's active registrants as a roster table + demographic charts (shared partials fed by EventDashboard; drill-ins → Manage). Owner-visible.
  • Reports = the shared cross-event reports hub, pre-filtered to this event via event_id. Owner-visible when scoped to their event.
  • Staff has no tab — it's the public "Meet the staff" roster, not a management surface, and is reached from the event's own page. Page, routes and policy unchanged.

What changed (by phase / commit)

  1. Terminology — "Live" vs "On-demand"; Event.live/.on_demand.

  2. Canonical scopes/constantsEventRegistration.attended_facilitator_trainings (superseded in phase 9), Scholarship.externally_funded/.org_subsidized + Grant.self_funded_ids, Affiliation::STATUSES/#status_on/.with_status (SQL↔Ruby agreement spec), Address::US_STATE_ABBREVIATIONS, EventRegistration.status_counts_by_event. One intentional fix: the Manage "funder" filter now treats an AWBW self-funded grant as unfunded, matching the dashboard.

  3. EventScholarshipFigures — batched loader replacing the one-EventDashboard-per-event N+1 in the scholarship report.

  4. Cross-event rename + owner accessstatisticsreports, training_attendeesattendees (frames + files, no redirects). The report suite (revenue, participation, scholarships, hub, attendees) is visible to event owners as well as admins: authorization lives in the rows via an EventPolicy :reportable relation scope (admin → all events, owner → their own), so no filter combination — including an event_id[] array — can widen a report past what the viewer may see. An explicit event_id is still authorized against that event so an unowned one fails loudly.

  5. Subnav + Background removal + Roster page — subnav restructured; Background page retired; Roster reborn as a per-event page (active registrants, shared partials); dead Onboarding/Bulk-payments highlights fixed.

  6. Follow-ups — fixed a 500 on the attendees charts when a city has scholarship recipients (scholarship-cities sub-table used a nil-event link); widened the reports-hub Event dropdown.

  7. Org program-status fixes — preload affiliations in person_program_status_ids so facilitator_status_on doesn't fire a query per org (N+1); collapse the decorator's facilitator_status_as_of onto the model's facilitator_status_on (single source of truth), removing the drift-prone twin.

  8. Ahoy tracking + rename — extend event-page view tracking to preview, sample_ticket, and the reminder confirm/send steps (send logs inline with a recipient count; confirm skips the empty-recipient bounce). Rename the report suite to "Event reports" in the UI — admin-home card + report page heading now match.

  9. Breakdowns filter their own page — the breakdown cards are a drill-in index, not a report: every row exists to reach the people behind it, and on the roster they left the page for Manage, walking past the table that already held the answer. Now one rule across all three people-pages: a row links to the page you're on, plus a filter. Single-event pages carry registrant_ids; the cross-event index keeps named filters, since a dash-joined id list there would outgrow the URL. Only the list narrows — stat bar and charts stay whole, with an "Applied … Showing 12 of 39 ✕" chip. This is a net deletion: roster_return_path, the return_to=roster eyebrow and _breakdown_card's "/registrants" string sniff all became dead. AttendeesBreakdowns gained *_registrant_ids_by_* maps mirroring EventDashboard's (regrouped from rows already loaded — no extra queries), so the recipients charts drill in too instead of rendering read-only.

  10. Attendees index opened past attended-trainings — it was hardcoded to attended registrations on facilitator trainings, so it could never answer what the report KPIs ask: a "No show" link landed on an empty page, because no-shows are excluded by definition. Attendance outcome and event type became filters defaulting to today's valuesattended stays a scope, as the default rather than the base, since you can't filter into rows a scope already removed. All five filters chain existing EventRegistration scopes (no new SQL), and the defaults render pre-selected and in the subtitle so they're visible rather than hidden. With that, all 13 report drill-ins moved from event_registrations_path to the people index, and the roster/breakdown columns follow the current event filter rather than a person's whole history. *_training_* naming went event-neutral throughout.

  11. Section polish — sections read "Breakdowns", not "Demographics" (the scholarship and CE pies and the program-status card aren't demographic, and on the recipients page the scholarship pie is the headline). Every section keeps its header while collapsed, with its Show/Hide control in the heading row: the page's toggle bar pins to the right of the first section's heading, and each section further down gets a short Show/Hide pill beside its own (the heading names the section, so the pill doesn't repeat it) — shout outs included. The heading + toggle row is a shared partial; panel-toggle's self-hiding CTA branch went with it. The reports hub links out to the breakdowns rather than hosting them; ?charts=1 opens the panel on arrival so the link doesn't land on a collapsed section.

  12. Review pass — four fixes found reviewing the branch end to end. A stale training_attendees_events_path in the registration form's Cancel path (500 on any pre-rename return_to) plus the two origins it was missing; the attendees index's person-name link had no return_to, so saving dumped you on that event's Manage list; the reports hub's live/on_demand event types fell through the attendees index's trainings/other vocabulary and silently widened the drill-in, now one shared EVENT_TYPE_FILTER_OPTIONS across the report filter, the index and the event_type scope; and the recipients charts frame paid for the full scholarship_applicants load it never renders.

  13. Toggle bar in the heading row — the bar sat on its own row directly above the heading, so the page opened with two stacked rows of controls and the heading pill duplicated the bar's button for the same panel.

  14. Second review pass — three fixes from reviewing the branch end to end again, each with a spec that fails without it. Breakdown drill-ins dropped charts, so a row clicked from inside the charts panel came back with that panel collapsed — the charts closed under the row just clicked, which is the opposite of the phase-9 rule that a row filters the page you're on; every path built inside the panel now carries charts: 1, as the hub's "Breakdowns" link already did. The roster's sector/state drill-ins resolved every matching person in the app only to intersect the result with a roster of at most a few hundred, so they're now bounded to the roster's own ids. And the recipients drill-in and the funder grouping each rebuilt their path from scratch, so switching one cleared the other — which left phase 12's bucket-narrowing unreachable from the UI; all three links now thread the rest of the query through.

  15. Incomplete attendance: two reasons to count, one population — an incomplete attendance was being counted as if it were one thing. It isn't: the money (scholarships awarded, fees paid) stays with the event however the attendance went, while "trained" means an attendance that was completed. Narrowing the Roster's population to serve the second broke the first — a partial attendee's scholarship fell out of the roster's totals, so the page stopped reconciling with the dashboard and the Scholarships tab. So: one population per event (EventRegistration.active), and the distinction is a figure, not a filter — which is what attendance counts already are. The Roster header names how many of its registrants were partial ("14 active registrants (2 with incomplete attendance)"), each row already carries its attendance pill, and the scholarship report reports them beside the attended count — 15 (2) per training, with the totals row spelling out that they're counted in the money but not as attended. Separately, payment_status and funder narrowed the attendees index with no select, no chip and no hidden field: they shrank the list with nothing on screen to say why, no way to clear them, and the next filter change dropped them. Adding them to CHIP_PARAMS fixes all three at once (the chip row and the form's hidden fields both read that list), with labels from shared option lists on EventRegistration so the select and the chip can't word a value differently.

Deferred

  • The deeper EventDashboardRegistrantPopulation/Roster/Breakdowns decomposition (collapsing TrainingAttendees*). Follow-up.

Resolved since

  • Chartkick blank charts: confirmed fixed in a browser. The CSP nonce + csp_meta_tag were the cause.
  • Roster population: settled in phase 15incomplete_attendance stays in (its money belongs to the event); the header and the scholarship report name it separately.

Rebase notes (onto #2054, the recipients funder grouping)

Rebase notes

Rebased onto origin/main at 901851127 — one conflict, in EventDashboard. main's #2164 landed the same donorfunder rename this branch had already made, and its copy of scholarship_applicant_ids sits under private; this branch had moved that method above private because the recipients charts frame calls it from the controller. Kept the public one — taking main's would have re-privatised it and 500'd that frame (the same trap recorded in the #2054 notes above). No other conflicts; main's other commit is ai/test tooling.

Earlier — onto 47def6cfc

Rebased onto origin/main at 47def6cfc — clean, no conflicts, nothing reapplied by hand. That includes 4e737f988 ("Shared registrant filter bar"), which was the one overlapping commit: its four new registrants filters, five new EventRegistration scopes and the _registrants_search_registrant_filters swap all landed intact alongside this branch's changes to the same files. The rest are Story Share and a json CVE bump, which do not touch this area.

Earlier (onto #2130#2134)

Testing

Green on the new base: 5,539 examples, 0 failures (8 pending) across spec/requests spec/views spec/models spec/services spec/policies spec/helpers spec/decorators, RuboCop clean on 1,449 files. Each of phase 14's three commits is green on its own. The regression guard for the widened attendees population is in spec/requests/events/attendees_spec.rb — omitting every new filter must reproduce the old population exactly — and the delivery-format splits are covered there and in spec/models/event_registration_spec.rb.

Copilot AI lite review requested due to automatic review settings August 9, 2026 00:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 00:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 00:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 00:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 00:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 02:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 03:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 04:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 04:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 9, 2026 04:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

S Mae Beale and others added 18 commits August 10, 2026 11:23
Shared events/_charts_loading skeleton (pulsing breakdown-card grid) replaces the
plain "Loading charts…" line in the roster, attendees, and recipients charts
frames, so the lazy load reads as loading and doesn't jump the layout on reveal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile every button (label, aria-expanded, and CTA self-hide) with its panel's
actual visibility on connect, not just on click — so a section's top toggle and
its in-section "Show" CTA can never disagree, and the state stays correct after a
Turbo restore or a frame reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ded-trainings

The breakdown cards are a drill-in index, not a report: every row exists to reach
the people behind it. On the roster they left the page for Manage, walking past
the registrant table that already held the answer — so they now filter in place,
as the attendees index has always done. That makes one rule across all three
people-pages: a row links to the page you're on, plus a filter. Single-event
pages carry registrant_ids (tens of people); the cross-event index keeps named
filters, since a dash-joined id list there would outgrow the URL.

Filtering in place also deletes the machinery that existed only to get back:
roster_return_path, the return_to=roster eyebrow, and _breakdown_card's
"/registrants" string sniff.

The attendees index was hardcoded to attended registrations on facilitator
trainings, so it could never answer the questions the report KPIs ask — a "No
show" link landed on an empty page, because no-shows were excluded by definition.
That's why those KPIs pointed at the raw registrations table. Attendance outcome
and event type become filters defaulting to today's values, so the page is
unchanged unless asked otherwise, and all 13 report drill-ins can now land on the
people index. attended stays a scope; it's the default, not the base — you can't
filter into rows a scope already removed.

Sections read "Breakdowns" rather than "Demographics": the scholarship and CE
pies and the program-status card aren't demographic, and on the recipients page
the scholarship pie is the headline. Every section keeps its header while
collapsed and offers its own Show prompt, shout outs included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w it

A full-width "Show registrants" button stacked under a "Registrants" heading
repeated the word and pushed the content down. The heading already names the
section, so the control only needs to say Show or Hide, sitting next to it.

That also makes it a real toggle rather than a one-way prompt: it stays put and
flips its own label, so the self-hiding CTA behaviour in panel-toggle has no
callers left and goes. Extracted the heading + toggle row into a shared partial,
since roster, attendees and recipients were each building it by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Staff isn't a management surface like the tabs around it — it's the public
"Meet the staff" roster, reached from the event's own page. The page, routes and
policy are untouched; only the tab goes, so the subnav reads as the admin
workflow it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d per event

main's recipient drilldowns (#2125) read them off EventDashboard, which this
branch had already replaced with EventScholarshipFigures — the rebase merged the
two textually and left Column calling a `dashboard` member that no longer exists,
500ing the scholarships report.

Carrying the splits in the batched loader keeps the point of that replacement:
recipients and their dollars now ride along with the figures, so the expanders
cost one extra Person query in total rather than reviving one dashboard (and its
~8 queries) per training. The query-count guard moves 5 → 6 and a parity spec
holds the splits to the dashboard's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s returns

Two things the rebase surfaced.

Revenue figures are money on registrations, so sending them to the attendees
people-index landed admins on a list with no payment columns — and main's new
per-registrant breakdown rows already went to Manage, leaving one report drilling
into two different places. Both halves now agree: one helper picks that event's
Manage list when the report is scoped to a single event, and the cross-event
registrations index otherwise (it takes the same payment_status/funder filters).

main's shout-out feature and this branch's recipient-name link both arrived at
`return_to: "recipients"` from the same page, wanting different anchors — the
first branch silently won. The name link gets its own token so each returns where
it came from: the recipient's card, or the shout-outs section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontrols

Onboarding's program-status badge classifies each linked org via
Organization#facilitator_status_on, which now filters the loaded association
rather than probing with EXISTS. Without the preload that's a query per org
pulling every affiliation row — the same N+1 this branch removed on the roster,
attendees and recipients breakdowns, missed at this call site.

The recipients status switch and expand/collapse were pushed to opposite ends of
the row; grouped left they read as one set of controls for the list they act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of these is gated by admin? || owner? — manage? for the management
pages, dashboard?/edit? for the rest, and the report suite's own rules — but they
all claimed "admin-only". The marker is a policy statement, so a reviewer reading
it got the access model wrong.

No visual change: the markers aren't styled, so the page colour comes from the
bg-* utility riding alongside, which is untouched. events/new stays admin-only —
owner? can't hold for an unsaved event, so new? is admin-only in practice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ip code

#2147 renamed the column and the filter value; the rebase merged around this
branch's refactor without touching what it introduced, leaving live references to
a column that no longer exists — Grant.self_donated_ids and
EventScholarshipFigures#funder_grant_ids both still queried `donor:`, and the
funder filter's "donor" value no longer matches anything.

Also renamed self_donated_* to self_funded_*, since #2147's point was one word
for this concept and "donated" was the drift it set out to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preload added with the donor→funder rebase had nothing holding it in place:
removing it broke no test, so the N+1 could silently come back. Verified the new
guard by mutation — it fails without the preload and passes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar sat on its own row directly above the heading, so these pages opened
with two stacked rows of controls — and the heading's own Show/Hide pill
duplicated the bar's button for the very same panel. Pin the bar to the right
of the heading row instead and drop that heading's pill. Sections further down
keep theirs, where the bar is too far away to serve as their control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
following the rename, so the funder grouping calls grant.donor on a column that
no longer exists — main is red on its own recipients specs, not just here. Same
mechanical rename through the grouping, its FunderGroup member, and the specs.

Also restores two things the rebase dropped: scholarship_applicant_ids landed
below `private` where the merge placed it among the new grouping helpers, 500ing
the recipients charts frame the controller calls it from; and the recipient name
links to the registration's edit form again, now from the extracted card partial
(the ticket icon beside it goes to the registrant's own view — different page).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A breakdown row is clicked from inside the charts panel, but the paths it
built dropped `charts`, so the page came back with the panel collapsed:
the charts closed under the row that was just clicked, and stopped being
the navigation surface the drill-in design leans on. Every path built
inside the panel now carries charts: 1, matching the Reports hub's
"Breakdowns" link, which already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both branches resolved every matching person in the app — everyone tagged
with that sector, everyone with an address in that state — only to
intersect the result with a roster of at most a few hundred. Scoping the
lookup to the roster's own ids keeps the query proportional to what it
filters, and drops the redundant round trip back through people.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both controls rebuilt the path from scratch, so toggling the grouping
cleared an applied drill-in and clearing the chip cleared the grouping —
which left the bucket-narrowing that shipped with the drill-in unreachable
from the UI. Threading the rest of the query through all three links
matches the roster's chip, and keeps the charts panel open across the
toggle as a side effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The index narrows on payment_status and funder — the revenue report's KPIs
link in with them — but neither had a select, a chip, or a hidden field. So
they shrank the list with nothing on screen to say why, no way to clear
them, and the next filter change silently dropped them. Adding them to
CHIP_PARAMS fixes all three at once: the form's hidden fields and the chip
row both read that list.

Their labels come from option lists on EventRegistration, shared with the
registrants filter bar, so the select and the chip can't word a value
differently. A value the list doesn't offer gets no chip, matching the
scopes, which treat it as no filter at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Roster drew on EventRegistration.active, which includes
incomplete_attendance — so a trainee who made one day of a two-day event sat
in a roster meant to answer "who was here", and in every stat and breakdown
built from it.

EventDashboard now takes the registration scope its people-facing figures
draw from (the same shape AttendeesRoster and AttendeesBreakdowns already
take), defaulting to .active so the dashboard, recipients page and revenue
figures are untouched. The Roster passes the new .on_roster. Its header
reports the count it left out, so the narrower number reads as a deliberate
exclusion rather than a discrepancy against the dashboard.

Renamed the dashboard's private active_registrations to scoped_registrations
now that the caller chooses the scope, matching AttendeesRoster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 10, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Backs out the Roster's on_roster population. There are two reasons to count
on an event and they want opposite things: the money — scholarships awarded,
fees paid — stays with the event however the attendance went, while
"trained" means an attendance that was completed. A narrower population
serves the second by breaking the first: a partial attendee's scholarship
fell out of the roster's totals, so the page stopped reconciling with the
dashboard and the Scholarships tab.

So one population per event (EventRegistration.active) and the distinction
becomes a figure, not a filter — which is what attendance counts already
are. The Roster's header names how many of its registrants were partial, and
the scholarship report reports them beside the attended count: "15 (2)" per
training, with the totals row spelling out that they're counted in the money
but not as attended.

Nothing narrows a population now, so EventDashboard's registrations: kwarg
and EventRegistration.on_roster go with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 10, 2026 19:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@maebeale
maebeale merged commit bbce84e into main Aug 10, 2026
3 checks passed
@maebeale
maebeale deleted the maebeale/helsinki branch August 10, 2026 22:46
maebeale added a commit that referenced this pull request Aug 13, 2026
main's #2113 gave every event admin sub-page a shared subnav and moved
page_bg_class from "admin-only" to "admin-or-owner" where the policy allows
owners — this report is one of those pages, and was the only one left
without either.

Also derive the sign-in notice's "30 minutes" from ATTENDANCE_SIGN_IN_LEAD,
so widening the window can't leave the copy claiming a stale gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Aug 15, 2026
main's #2113 gave every event admin sub-page a shared subnav and moved
page_bg_class from "admin-only" to "admin-or-owner" where the policy allows
owners — this report is one of those pages, and was the only one left
without either.

Also derive the sign-in notice's "30 minutes" from ATTENDANCE_SIGN_IN_LEAD,
so widening the window can't leave the copy claiming a stale gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Aug 15, 2026
main's #2113 gave every event admin sub-page a shared subnav and moved
page_bg_class from "admin-only" to "admin-or-owner" where the policy allows
owners — this report is one of those pages, and was the only one left
without either.

Also derive the sign-in notice's "30 minutes" from ATTENDANCE_SIGN_IN_LEAD,
so widening the window can't leave the copy claiming a stale gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Aug 15, 2026
* 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) <noreply@anthropic.com>

* Add CE sign-in/out to the callout + live card reminder

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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* Add per-event attendance report, linked from participation

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) <noreply@anthropic.com>

* Cover EventPolicy#attendance? in the policy spec

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Gate CE certificate on logged time approximating awarded hours

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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* Guard attendance entries against >24h/day and same-day overlaps

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) <noreply@anthropic.com>

* Attendance report: per-day logged columns, consistent totals, row/name links

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) <noreply@anthropic.com>

* Stack the Totals header labels for readability

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) <noreply@anthropic.com>

* Show daily training hours on the attendance report

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 <noreply@anthropic.com>

* Callout header date line + concrete sign-in/event times

- 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 <noreply@anthropic.com>

* Hide the Signed out chip until something is logged today

"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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Compact the CE credit + license sections to single rows

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* Report grouping toggle + generic sign-in report links

- 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 <noreply@anthropic.com>

* Make report session rows clickable to the right edit page

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 <noreply@anthropic.com>

* 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.

* Let registrants close a day they forgot to sign out of

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 <noreply@anthropic.com>

* Correct a day's sign-in times on the report instead of clicking out

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 <noreply@anthropic.com>

* Pin the report name links' return path with a regression spec

The CE-report name links once pointed the CE page eyebrow at
return_to=ce_registration, stranding admins one hop from the report;
assert the attendance value (and the old value's absence) so the
eyebrow chain can't silently regress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Match the two sign-in-notice times' hour format

Both the sign-in-opening and event-begins times already show AM/PM; align the
event-begins hour directive (%-l → %-I) so the adjacent times format identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Show a nested record's validation error the way its own model phrases it

error_sentence stripped the association prefix by falling back to the bare
error message, which loses the subject for anything not written as a whole
sentence — a child's "Signed in at can't be blank" reached the flash as
"can't be blank". Ask the nested error for its inner error's full message
instead: sentence-style messages still read verbatim, attribute-level ones
keep their subject.

Also drops EventAttendanceEntriesUpdate's private attr_readers for its
constructor arguments, per the project convention.

* Format attendance times without going through the view context

The report decorates entries from a service, and Draper resolves `h` against
whatever view context happens to be cached — outside a request that is often a
leftover mailer one, which carries ApplicationHelper and nothing else. So
signed_in_label raised NoMethodError for attendance_clock_time depending on
which spec ran first; main's new specs reshuffled the order enough to surface
it. Move the two formats into AttendanceTimeFormatter, alongside MoneyFormatter:
the helper stays the view-side front door, the decorator calls the PORO.

* Let registrants write up their own hours, not just stamp them

The sign-in/out buttons only ever recorded "now", so anyone who arrived
before signing in, forgot to tap them, or wrote the training up afterwards
had to ask staff to fix it. Their own times are now editable day by day on
the CE callout, outside the sign-in window and after the training ends —
the buttons stay as the fast path, not the only path.

Registrant edits stay unattributed like their taps, so created_by/updated_by
still mean "a staff correction happened here".

An open entry now occupies the rest of its day for the overlap guard.
Treating it as a moment let a later session be recorded underneath it, and
the eventual sign-out then failed validation every time — leaving the
registrant signed in with no way out.

The sample-ticket preview builds an unsaved CE registration with no
payments, so the attendance section it was meant to preview was never
reachable; treat the preview as paid the way the "Paid in full" badge does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fit the attendance report into the events IA refactor

main's #2113 gave every event admin sub-page a shared subnav and moved
page_bg_class from "admin-only" to "admin-or-owner" where the policy allows
owners — this report is one of those pages, and was the only one left
without either.

Also derive the sign-in notice's "30 minutes" from ATTENDANCE_SIGN_IN_LEAD,
so widening the window can't leave the copy claiming a stale gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give each CE licence its own sign-in sheet

A registrant claiming CE against two licences is audited by two boards, and
each board is shown only its own licence number and its own awarded hours.
One sheet listing both was no use to either.

The times stay shared — one person, one room, one set of hours — so the
sheets can't disagree about a day the way two independent records could.
Signing in once fills in every sheet, which is why the controls stay
singular above them rather than repeating per licence.

Gating is now per licence on the view (a second licence still being paid
for doesn't take the first one's sheet away) and any-of on the write, which
closes a mismatch that let the section render while sign-in refused: the
view had always read .first, the controller required every CE paid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Report a sign-in line per CE licence, not per registrant

The CE report is the artifact a board audits, so it has to follow the split
the callout already makes: a registrant claiming CE against two licences was
one row reading "AAA111, BBB222" with the two boards' hours added together,
which is no use to either board.

The reported unit is now a Row — a registration plus, on the CE report, the
licence its hours are certified against — and cells are keyed by the row, so
two lines for one person don't share a DOM id and open both editors at once.

Hours awarded sum across lines, since each board awards its own. Logged
minutes deliberately don't: the lines share one set of times, so the All row
counts each person once rather than banking their hours twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Improve notice re truncated events

* Ask for a recap on every completed task, not just on request

A prose summary reads the same whether everything is clean or something
needs attention — the user has to read all of it and infer. The count
header answers that in one line, so it belongs on every message that
reports work as done, not only on "ai recap" or after a PR.

Same reason for the formatting section: long replies are for scanning
first and reading second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Group the sign-in report with the CSV export in bulk actions

Everything above it in the menu acts on the registrants; the sign-in
report and the CSV are both ways of taking the roster's record out of
the page, so they belong next to each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give the participation report a subnav, and sign-ins a cross-event page

The report, the people behind it, their breakdowns and their logged time
are four angles on one population, but only Attendees was reachable, and
only as a lone link beside the title. They now sit together as tabs on the
eyebrow's row, each carrying the report's filters so switching angle keeps
the same events in scope.

Sign-ins had nowhere to point when no single event is filtered — the
existing report is per-event — so this adds the cross-event page the other
reports already have: a totals table per event in scope, linking into that
event's own report for the editable session detail. Repeating all three
parts per event would be unreadable across a year of trainings.

CE columns follow any event in scope granting CE, not all of them: a mixed
selection must not hide the licence numbers boards audit.

Dropped "report" from the feature's name throughout — it's "CE sign-ins"
now — and moved the per-event page onto the shared report header so it
reads like the registrants page it sits beside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add subnav navigation

* comments updated

* hardening options

* Call it CE sign-ins on the CE registrations index too

main's new index links here as "CE sign-in reports", which is the name the
page no longer uses — leaving it would put "report" back in front of a page
titled "CE sign-ins".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants