From d774568a13ba8645aff47bf31084c0587f49af08 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:05:29 -0400 Subject: [PATCH] Add Event abbreviation and surface it in compact/event contexts Adds an admin-set short code (e.g. "TOS205") to events and uses it wherever a full event title is too long or repeated across many events, falling back to the title when no abbreviation is set. - Event#abbreviation column + event-form field + policy param. - EventDecorator#compact_label => abbreviation or title; used on the revenue report's per-event rows and the dashboard/background header links (full title kept as a tooltip). - Org profile "Program status by event" chips: classify the org as of each event's start date and label the chip with the event's compact_label. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/event_decorator.rb | 7 +++++ app/decorators/organization_decorator.rb | 15 ++++++++++ app/policies/event_policy.rb | 1 + app/views/events/_form.html.erb | 8 ++++- app/views/events/background.html.erb | 2 +- app/views/events/dashboard.html.erb | 2 +- app/views/events/revenue.html.erb | 2 +- .../organizations/sections/_events.html.erb | 18 +++++++++++ ...260715041414_add_abbreviation_to_events.rb | 9 ++++++ db/schema.rb | 1 + spec/decorators/event_decorator_spec.rb | 12 ++++++++ .../decorators/organization_decorator_spec.rb | 30 +++++++++++++++++++ spec/requests/events_spec.rb | 8 +++++ .../organizations_events_section_spec.rb | 22 ++++++++++++-- 14 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20260715041414_add_abbreviation_to_events.rb diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index 15896f15df..442ca544fe 100644 --- a/app/decorators/event_decorator.rb +++ b/app/decorators/event_decorator.rb @@ -62,6 +62,13 @@ def detail(length: nil) length ? description&.truncate(length) : description end + # Compact event label for tight/tabular or multi-event contexts: the admin-set + # abbreviation (e.g. "TOS205") when present, otherwise the full title. Pair it + # with the full title as a tooltip so the abbreviation is never ambiguous. + def compact_label + object.abbreviation.presence || object.title + end + # `show_videoconference_details` controls whether the join link/ID/passcode are # carried into the calendar entry. Callers with a registration pass that # registrant's gate (date + paid/intends); the default falls back to the diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 97a0c134e6..34d02d9671 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -88,6 +88,21 @@ def facilitation_end_date facilitator_affiliations.maximum(:end_date) end + # In-memory program status (:new / :ongoing / :reinstated) for this org as it + # stood on a given date — the same New/Ongoing/Reinstate classification used in + # event context (Organization#facilitator_status_on), computed from the + # already-loaded affiliations so a profile can classify many events without an + # N+1. No facilitator affiliation starting before the date => :new; an earlier + # one still active on the date => :ongoing; all earlier ones ended => :reinstated. + def facilitator_status_as_of(date) + reference = date&.to_date || Date.current + earlier = affiliations.select { |affiliation| affiliation.facilitator? && affiliation.start_date.present? && affiliation.start_date.to_date < reference } + return :new if earlier.empty? + + active = earlier.any? { |affiliation| affiliation.end_date.nil? || affiliation.end_date.to_date >= reference } + active ? :ongoing : :reinstated + end + def badges earliest = affiliations.minimum(:start_date) || start_date years = earliest ? (Time.zone.now.year - earliest.year) : nil diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index a081a54f73..a1a5496ef6 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -116,6 +116,7 @@ def google_analytics? :created_by_id, :location_id, :title, + :abbreviation, :pre_title, :videoconference_url, :videoconference_label, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 6b526d84a1..400c98a60f 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -23,7 +23,13 @@ "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> - + <%= f.input :abbreviation, + label: "Abbreviation", + hint: "Short code shown on organization profiles (e.g. TOS205)", + input_html: { + class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", + placeholder: "e.g. TOS205" + } %> <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %> diff --git a/app/views/events/background.html.erb b/app/views/events/background.html.erb index 896cae5fdf..9474d85647 100644 --- a/app/views/events/background.html.erb +++ b/app/views/events/background.html.erb @@ -34,7 +34,7 @@ <%# Title block %>
- <%= link_to @event.title, edit_event_path(@event), class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %> + <%= link_to @event.decorate.compact_label, edit_event_path(@event), title: @event.title, class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %> <% if @event.start_date.present? %>

<%= @event.date_range %>

<% end %> diff --git a/app/views/events/dashboard.html.erb b/app/views/events/dashboard.html.erb index bbafbc2861..1746b86b5d 100644 --- a/app/views/events/dashboard.html.erb +++ b/app/views/events/dashboard.html.erb @@ -8,7 +8,7 @@ <%# Centered title block %>
- <%= link_to @event.title, edit_event_path(@event), class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %> + <%= link_to @event.decorate.compact_label, edit_event_path(@event), title: @event.title, class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %> <% if @event.start_date.present? %>

<%= @event.date_range %> diff --git a/app/views/events/revenue.html.erb b/app/views/events/revenue.html.erb index 9c5d6626f6..b7366887ed 100644 --- a/app/views/events/revenue.html.erb +++ b/app/views/events/revenue.html.erb @@ -112,7 +112,7 @@

- <%= row.event.title %> + <%= row.event.compact_label %>
<%= row.event.date_range %>
diff --git a/app/views/organizations/sections/_events.html.erb b/app/views/organizations/sections/_events.html.erb index b3949771c8..03a1b83403 100644 --- a/app/views/organizations/sections/_events.html.erb +++ b/app/views/organizations/sections/_events.html.erb @@ -1,5 +1,23 @@ <%= turbo_frame_tag "organization_events_section" do %> <% if events.any? %> + <% if allowed_to?(:manage?, Organization) %> + <% decorated_org = organization.decorate %> +
+

Program status by event

+
+ <% events.each do |event| %> + <% status = decorated_org.facilitator_status_as_of(event.start_date) %> + <%= link_to event_path(event), + title: event.title, + data: { turbo_frame: "_top" }, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> + <%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> + <% end %> + <% end %> +
+
+ <% end %> +
<% events.each do |event| %> <% decorated = event.decorate %> diff --git a/db/migrate/20260715041414_add_abbreviation_to_events.rb b/db/migrate/20260715041414_add_abbreviation_to_events.rb new file mode 100644 index 0000000000..b59d52aa7e --- /dev/null +++ b/db/migrate/20260715041414_add_abbreviation_to_events.rb @@ -0,0 +1,9 @@ +class AddAbbreviationToEvents < ActiveRecord::Migration[8.1] + def up + add_column :events, :abbreviation, :string unless column_exists?(:events, :abbreviation) + end + + def down + remove_column :events, :abbreviation, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 39ed76ab04..f2a6ddb14b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -522,6 +522,7 @@ end create_table "events", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "abbreviation" t.boolean "autoshow_cost", default: true, null: false t.boolean "autoshow_date", default: true, null: false t.boolean "autoshow_location", default: true, null: false diff --git a/spec/decorators/event_decorator_spec.rb b/spec/decorators/event_decorator_spec.rb index 8d248cfedf..a71071d298 100644 --- a/spec/decorators/event_decorator_spec.rb +++ b/spec/decorators/event_decorator_spec.rb @@ -23,6 +23,18 @@ end end + describe "#compact_label" do + it "returns the abbreviation when present" do + event = build(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205").decorate + expect(event.compact_label).to eq("TOS205") + end + + it "falls back to the full title when abbreviation is blank" do + event = build(:event, title: "Trauma-Informed Onsite", abbreviation: "").decorate + expect(event.compact_label).to eq("Trauma-Informed Onsite") + end + end + describe "#videoconference_room" do it "pulls the Zoom meeting ID from the join URL and groups the digits" do event = build(:event, videoconference_url: "https://awbw-org.zoom.us/j/88285411273").decorate diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 638447c9bf..69502ce06d 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -42,6 +42,36 @@ end end + describe "#facilitator_status_as_of" do + let(:organization) { create(:organization) } + let(:person) { create(:person) } + let(:reference) { Date.new(2026, 6, 1) } + + it "is :new when there are no facilitator affiliations starting before the date" do + expect(organization.decorate.facilitator_status_as_of(reference)).to eq(:new) + end + + it "ignores facilitator affiliations that start on or after the date" do + create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference) + expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:new) + end + + it "is :ongoing when an earlier facilitator affiliation is still active on the date" do + create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference - 1.year, end_date: nil) + expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:ongoing) + end + + it "is :reinstated when earlier facilitator affiliations all ended before the date" do + create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference - 2.years, end_date: reference - 1.year) + expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:reinstated) + end + + it "ignores non-facilitator affiliations" do + create(:affiliation, organization: organization, person: person, title: "Volunteer", start_date: reference - 1.year, end_date: nil) + expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:new) + end + end + describe "#agency_type_option" do it "returns a recognized type unchanged" do organization = create(:organization, agency_type: "For-profit") diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index bf08395bd9..187f746484 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -121,6 +121,14 @@ def offer_ce!(target_event) expect(response.body).not_to include("Free open house") end + it "labels an event by its abbreviation when set, keeping the full title as a tooltip" do + paid_training.update!(abbreviation: "TAC261") + sign_in admin + get revenue_events_path + expect(response.body).to include("TAC261") + expect(response.body).to include('title="TAC 261"') + end + it "returns to the originating event's dashboard when arrived from it" do sign_in admin get revenue_events_path(return_to: "dashboard", event_id: paid_training.id) diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index 68277bece3..35e5af8a63 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -47,13 +47,31 @@ def register(event:, status: "registered") end it "lists each event once even when several members attended it" do - event = create(:event, title: "Shared Event") + event = create(:event, title: "Shared Event", abbreviation: "SE1") register(event: event) register(event: event) get_events_section - expect(response.body.scan("Shared Event").size).to eq(1) + # One event card, and one deduped program-status chip (keyed by its + # abbreviation) — not one per registration. The card renders the title as a + # text node (">Shared Event"); the chip references it only in a title="…" + # tooltip, so the card count keys off the leading ">". + expect(response.body.scan(/>\s*Shared Event/).size).to eq(1) + expect(response.body.scan("SE1").size).to eq(1) + end + + it "shows an admin program-status chip labeled with the event abbreviation" do + event = create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", start_date: 2.days.from_now) + person = create(:person) + create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: 1.year.ago, end_date: nil) + registration = create(:event_registration, registrant: person, event: event, status: "registered") + registration.event_registration_organizations.create!(organization: organization) + + get_events_section + + expect(response.body).to include("TOS205") + expect(response.body).to include("Ongoing") end it "renders the section heading and lazy frame on the profile page" do