From 01db4b0481a04dae202595e9326fae50f0a93e36 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:06:28 -0400 Subject: [PATCH 01/40] Remove program status from org-wide UI New/Reinstate/Ongoing can only be determined relative to a specific event, so showing it as a global org attribute was misleading. Per-event program status is surfaced on the org profile in a separate PR (event abbreviations, #1995). - Drop the admin-only "Program" column from the org index. - Drop the now-orphaned Organization.program_statuses_by_id bulk classifier. - Drop the "Program status" block from the org edit form's Affiliations section. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 1 - app/models/organization.rb | 15 ---------- app/views/organizations/_form.html.erb | 12 -------- .../organizations_results.html.erb | 11 +------ spec/models/organization_spec.rb | 27 ----------------- spec/requests/organizations_spec.rb | 29 ------------------- .../organizations/index.html.erb_spec.rb | 1 - 7 files changed, 1 insertion(+), 95 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 478165046d..465e15814e 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -25,7 +25,6 @@ def index .group(:organization_id) .distinct .count(:person_id) - @program_statuses = Organization.program_statuses_by_id(org_ids) render :organizations_results else diff --git a/app/models/organization.rb b/app/models/organization.rb index 7f316defb5..6463d6d846 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -221,21 +221,6 @@ def program_status(recipient = nil) prior.any? ? "Ongoing" : "New" end - # Bulk program status (:new / :ongoing / :reinstated) for the given org ids, - # keyed by id — the recipient-less form of #program_status, computed with - # aggregate queries so list pages avoid loading each org's affiliations. An org - # with no facilitator affiliations is :new; with facilitators but none - # currently active it is :reinstated; otherwise :ongoing. - def self.program_statuses_by_id(org_ids) - facilitator_scope = Affiliation.facilitators.where(organization_id: org_ids) - with_facilitators = facilitator_scope.distinct.pluck(:organization_id).to_set - with_active = facilitator_scope.active.distinct.pluck(:organization_id).to_set - org_ids.index_with do |id| - next :new if with_facilitators.exclude?(id) - with_active.include?(id) ? :ongoing : :reinstated - end - end - def type_name "#{name} #{ " (#{windows_type.short_name})" if windows_type}" end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index b5d3edc59f..ead501d830 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -323,18 +323,6 @@ <% if org_fac_ended %><% end %><%= org_fac_start&.strftime("%b %Y") || "—" %><%= " – #{org_fac_ended.strftime('%b %Y')}" if org_fac_ended %> -
- - -
- <%= org_decorated.program_status_badge %> - <%= f.object.program_status %> -
-
<% if allowed_to?(:manage?, Organization) %>
diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 489610ee52..eba3f4c61e 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -7,9 +7,6 @@ - <% if allowed_to?(:manage?, Organization) %> - - <% end %> @@ -23,14 +20,8 @@ <% @organizations.each do |organization| %> - <% cache [organization, @affiliated_since[organization.id], @active_people_counts[organization.id], @program_statuses&.dig(organization.id), current_user.super_user?] do %> + <% cache [organization, @affiliated_since[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> - <% if allowed_to?(:manage?, Organization) %> - - <% end %> - <% @organizations.each do |organization| %> - <% cache [organization, @affiliated_since[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> + <% cache [organization, @affiliated_since_display[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> - + <% if allowed_to?(:manage?, Organization) %> @@ -20,7 +20,7 @@ <% @organizations.each do |organization| %> - <% cache [organization, @affiliated_since_display[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> + <% cache [organization, @program_since_display[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> <% @organizations.each do |organization| %> - <% cache [organization, @program_since_display[organization.id], @active_people_counts[organization.id], current_user.super_user?] do %> + <% cache [organization, @program_since_display[organization.id], organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> - + @@ -29,10 +29,6 @@ diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 4c8e43a654..ef38aeaf10 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -66,6 +66,22 @@ end end + describe "#program_since_chip" do + it "shows the years in the org's status colour (Active → green)" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + chip = org.decorate.program_since_chip("2021") + expect(Capybara.string(chip)).to have_css("span", text: "2021") + expect(chip).to include("green") + end + + it "falls back to the status label (orange) when there are no facilitator years" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) + chip = org.decorate.program_since_chip("") + expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") + expect(chip).to include("orange") + end + end + describe ".program_status_classes" do it "maps each status to its pill classes, accepting symbols or model strings" do expect(described_class.program_status_classes(:new)).to include("indigo") From c69319472ed4d1c81ddf91b1d273dfe34049d78e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 21:54:54 -0400 Subject: [PATCH 11/40] Swap the windows-type index filter for Sector + Age group dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "Windows audience" dropdown with a Sector dropdown (sector_names_all) followed by an Age group dropdown (category_names_all over AgeRange categories) — both wired to the existing search scopes. Options are loaded in set_index_variables. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 6 +++++ .../organizations/_search_boxes.html.erb | 20 ++++++++++----- spec/models/organization_spec.rb | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index c193a5132d..612b5e6097 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -206,6 +206,12 @@ def set_form_variables def set_index_variables @organization_statuses = OrganizationStatus.all + @sector_options = Sector.published.order(:name).pluck(:name) + @age_group_options = Category.joins(:category_type) + .where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) + .published + .order(:position, :name) + .pluck(:name) end def populations_served diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index 6106561d6c..f48b3af555 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -9,12 +9,20 @@ class: search_field_class(extra: "w-64") %>
- <%= label_tag :windows_type_name, "Windows audience", class: search_label_class %> - <%= select_tag :windows_type_name, - options_for_select(WindowsType::TYPES.map { |wt| [wt, wt] }, - params[:windows_type_name]), - include_blank: "All types", - class: search_field_class(extra: "w-40 search-select-placeholder") %> + <%= label_tag :sector_names_all, "Sector", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :sector_names_all, + options_for_select((@sector_options || []).map { |name| [ name, name ] }, params[:sector_names_all]), + include_blank: "All sectors", + class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+
+ <%= label_tag :category_names_all, "Age group", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :category_names_all, + options_for_select((@age_group_options || []).map { |name| [ name, name ] }, params[:category_names_all]), + include_blank: "All age groups", + class: "w-40 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
<%= label_tag :address, "Address", class: search_label_class %> diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index a834951849..e537cef54f 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -286,6 +286,31 @@ end end + describe "search_by_params sector and age-group filters" do + let!(:age_type) { create(:category_type, name: "AgeRange", published: true) } + let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } + let!(:sector) { create(:sector, :published, name: "Housing") } + let!(:tagged) { create(:organization, name: "Tagged Org") } + let!(:untagged) { create(:organization, name: "Untagged Org") } + + before do + tagged.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + tagged.sectorable_items.create!(sector: sector, is_primary: false) + end + + it "filters by age group via category_names_all" do + results = Organization.search_by_params(category_names_all: "13-17") + expect(results).to include(tagged) + expect(results).not_to include(untagged) + end + + it "filters by sector via sector_names_all" do + results = Organization.search_by_params(sector_names_all: "Housing") + expect(results).to include(tagged) + expect(results).not_to include(untagged) + end + end + describe "age groups served" do let(:age_type) { create(:category_type, name: "AgeRange", published: true) } let!(:young) { create(:category, :published, category_type: age_type, name: "3-5") } From bbdc59a40f429cb97596a78a93ac8a138e94f7a6 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 22:01:52 -0400 Subject: [PATCH 12/40] Aggregate org sector/age-group displays + filters over affiliated people Sector and age-group displays and filters now reflect tags on the org itself and on any affiliated person (not just the org's own tags): - Index sector column now uses all_sectors (aggregate), matching the age-group column and the org profile (which already aggregate). - New Organization scopes sector_name_including_people / age_group_name_including_people back the index Sector / Age group dropdowns (params sector_name / age_group_name). - affiliated_sectors now aggregates over all affiliated people (people through affiliations) rather than only those with user accounts, so all_sectors matches the age-group aggregation and the filters. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/organization.rb | 31 ++++++++++++++----- .../organizations/_search_boxes.html.erb | 12 +++---- .../organizations_results.html.erb | 2 +- spec/models/organization_spec.rb | 24 ++++++++------ 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index a448d964d8..2ce278ff29 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -120,6 +120,27 @@ def self.awbw end } + # Index filters that match a sector / age group tagged directly on the org OR on + # any affiliated person — mirroring the aggregate the index/profile columns show. + scope :sector_name_including_people, ->(name) { + next all if name.blank? + term = name.to_s.downcase + direct = joins(:sectors).where("LOWER(sectors.name) = ?", term).select(:id) + via_people = joins(people: :sectors).where("LOWER(sectors.name) = ?", term).select(Arel.sql("organizations.id")) + where(id: direct).or(where(id: via_people)) + } + + scope :age_group_name_including_people, ->(name) { + next all if name.blank? + age_category_ids = Category.joins(:category_type) + .where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) + .where("LOWER(categories.name) = ?", name.to_s.downcase) + .select(:id) + direct = joins(:categories).where(categories: { id: age_category_ids }).select(:id) + via_people = joins(people: :categories).where(categories: { id: age_category_ids }).select(Arel.sql("organizations.id")) + where(id: direct).or(where(id: via_people)) + } + scope :organization_ids, ->(organization_ids) { where(id: organization_ids.to_s.split("-").map(&:to_i)) } scope :project_ids, ->(project_ids) { where(id: project_ids.to_s.split("-").map(&:to_i)) } scope :published, -> { active } @@ -127,8 +148,8 @@ def self.awbw def self.search_by_params(params) organizations = is_a?(ActiveRecord::Relation) ? self : all organizations = organizations.search(params[:query]) if params[:query].present? - organizations = organizations.sector_names_all(params[:sector_names_all]) if params[:sector_names_all].present? - organizations = organizations.category_names_all(params[:category_names_all]) if params[:category_names_all].present? + organizations = organizations.sector_name_including_people(params[:sector_name]) if params[:sector_name].present? + organizations = organizations.age_group_name_including_people(params[:age_group_name]) if params[:age_group_name].present? organizations = organizations.address(params[:address]) if params[:address].present? organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? @@ -267,11 +288,7 @@ def direct_sectors end def affiliated_sectors - users - .includes(person: :sectors) - .map(&:person) - .compact - .flat_map(&:sectors) + people.includes(:sectors).flat_map(&:sectors) end def all_sectors diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index f48b3af555..936efc6936 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -9,17 +9,17 @@ class: search_field_class(extra: "w-64") %>
- <%= label_tag :sector_names_all, "Sector", class: "text-sm font-medium text-gray-700 mb-1 block" %> - <%= select_tag :sector_names_all, - options_for_select((@sector_options || []).map { |name| [ name, name ] }, params[:sector_names_all]), + <%= label_tag :sector_name, "Sector", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :sector_name, + options_for_select((@sector_options || []).map { |name| [ name, name ] }, params[:sector_name]), include_blank: "All sectors", class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
- <%= label_tag :category_names_all, "Age group", class: "text-sm font-medium text-gray-700 mb-1 block" %> - <%= select_tag :category_names_all, - options_for_select((@age_group_options || []).map { |name| [ name, name ] }, params[:category_names_all]), + <%= label_tag :age_group_name, "Age group", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :age_group_name, + options_for_select((@age_group_options || []).map { |name| [ name, name ] }, params[:age_group_name]), include_blank: "All age groups", class: "w-40 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 081c156e1f..fcc1e36029 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -29,7 +29,7 @@
<% @organizations.each do |organization| %> <% cache [organization, @program_since_display[organization.id], organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> - + "> <% @organizations.each do |organization| %> - <% cache [organization, @program_since_display[organization.id], organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> - "> + <%# The status bucket is in the key because it turns on facilitator-affiliation + activity, which never touches the org row. %> + <% cache [organization, @program_since_display[organization.id], organization.decorate.organization_status_bucket, organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> + <% published = organization.published? %> + "> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 96136bf9e9..44fd3fc31d 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -110,18 +110,12 @@ <% if allowed_to?(:manage?, @organization) %> -
+

Program status

<%= org_decorated.organization_status_chip %> - <% @organization_events.each do |event| %> - <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", - title: event.title, - class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> - <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> - <% end %> - <% end %> + <%= render "organizations/program_status_event_chips", + organization: @organization, events: @organization_events, return_to: "organization" %>
<% end %> diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index 846b7f896c..28bbeb9d27 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -53,18 +53,28 @@ only. Blank when the org has never facilitated. See ### D3 — Org-wide status chip: Active / Formerly active / Never active Three display buckets (`OrganizationDecorator#organization_status_bucket`), **not -event-relative**: +event-relative**, derived from **facilitator affiliations only**: -- When the org has **any** facilitator affiliation: any **active** one → **Active**; - otherwise → **Formerly active**. -- When the org has **no** facilitator affiliation: fall back to the stored - `OrganizationStatus`, bucketed via `PROGRAM_STATUS_BUCKETS`: - - `Active`, `Reinstate` → **Active** - - `Inactive`, `Suspended` → **Formerly active** - - `Pending`, `Unknown` → **Never active** +- Any **active** facilitator affiliation → **Active** +- Facilitator affiliation(s), but **all ended** → **Formerly active** +- **No** facilitator affiliation → **Never active** + +The stored `OrganizationStatus` column plays **no part**. It is legacy data that +was maintained by hand and drifted; an org is "active" because someone is +facilitating there, not because a column says so. The same rule backs the index +filter (`Organization.program_status`), so the filter and the chip can't disagree. On the edit form this chip **live-updates** from the visible facilitator rows. +### D3a — The legacy status column: flagged, never consulted + +`OrganizationStatus::PROGRAM_STATUS_BUCKETS` survives for one purpose: bucketing +the stored value (`Active`/`Reinstate` → active, `Inactive`/`Suspended` → formerly +active, `Pending`/`Unknown`/missing → never active) so the **org edit form** can +show a warning where it contradicts the affiliations +(`OrganizationDecorator#legacy_status_mismatch?`). Nothing else reads it. Expect +the warning on a fair number of orgs — that is the drift it exists to surface. + ### D4 — Per-event program status: New / Ongoing / Reinstate **One value per (org, event)**, keyed off the **event's start date**, computed diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 57bf332aff..179a415a4e 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -40,14 +40,12 @@ end describe "#organization_status_label" do - { - "Active" => "Active", "Reinstate" => "Active", - "Inactive" => "Formerly active", "Suspended" => "Formerly active", - "Pending" => "Never active", "Unknown" => "Never active" - }.each do |stored, label| - it "collapses stored '#{stored}' to '#{label}'" do + # Every stored status reads "Never active" here: with no facilitator affiliation + # there is no program, whatever the legacy column says. + OrganizationStatus::ORGANIZATION_STATUSES.each do |stored| + it "ignores stored '#{stored}' and reads 'Never active' without facilitator affiliations" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: stored)) - expect(org.decorate.organization_status_label).to eq(label) + expect(org.decorate.organization_status_label).to eq("Never active") end end @@ -58,7 +56,7 @@ end end - describe "#organization_status_bucket (facilitator affiliations win over stored status)" do + describe "#organization_status_bucket (facilitator affiliations only)" do it "is :active for an active facilitator affiliation even when stored status is Suspended" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")) create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) @@ -70,12 +68,43 @@ create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) expect(org.reload.decorate.organization_status_bucket).to eq(:formerly_active) end + + it "is :never_active when a stored 'Active' org has only non-facilitator affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:never_active) + end + end + + describe "#legacy_status_mismatch?" do + it "is true when the stored status outranks the affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + expect(org.reload.decorate).to be_legacy_status_mismatch + end + + it "is true for a stored 'Active' org that has never had a facilitator affiliation" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + expect(org.decorate).to be_legacy_status_mismatch + end + + it "is false when the stored status buckets the same way as the affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate).not_to be_legacy_status_mismatch + end + + it "is false for a stored 'Pending' org with no facilitator affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) + expect(org.decorate).not_to be_legacy_status_mismatch + end end describe "#organization_status_chip" do it "renders a pill with the bucketed label and its status color" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) - chip = org.decorate.organization_status_chip + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + chip = org.reload.decorate.organization_status_chip expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") expect(chip).to include("orange") end @@ -83,15 +112,17 @@ describe "#program_since_chip" do it "shows the years in the org's status colour (Active → green)" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) - chip = org.decorate.program_since_chip("2021") + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + chip = org.reload.decorate.program_since_chip("2021") expect(Capybara.string(chip)).to have_css("span", text: "2021") expect(chip).to include("green") end it "falls back to the status label (orange) when there are no facilitator years" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) - chip = org.decorate.program_since_chip("") + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + chip = org.reload.decorate.program_since_chip("") expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") expect(chip).to include("orange") end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index f29f97a2aa..413702f5f4 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -255,7 +255,8 @@ end context 'with program status filter' do - it 'filters to the active bucket' do + it 'filters to orgs with an active facilitator affiliation' do + create(:affiliation, organization: active_org, person: create(:person), title: "Facilitator", end_date: nil) results = Organization.search_by_params(program_status: "active") expect(results).to include(active_org) expect(results).not_to include(inactive_org) @@ -263,52 +264,40 @@ end end + # Buckets come from facilitator affiliations alone — the stored organization_status + # is deliberately ignored, so each org here carries a status that contradicts its + # affiliations (see ADR-0001 D3). describe ".program_status scope" do - let!(:active) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) } - let!(:reinstate) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")) } - let!(:inactive) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) } - let!(:suspended) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")) } - let!(:pending) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) } - let!(:unknown) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) } - let!(:no_status) { create(:organization).tap { |o| o.update_columns(organization_status_id: nil) } } - - it "buckets Active + Reinstate as active" do - expect(Organization.program_status("active")).to contain_exactly(active, reinstate) + def org_with(status_name, **affiliation_attrs) + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: status_name)).tap do |org| + next if affiliation_attrs.empty? + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", **affiliation_attrs) + end end - it "buckets Inactive + Suspended as formerly_active" do - expect(Organization.program_status("formerly_active")).to contain_exactly(inactive, suspended) + let!(:active_fac) { org_with("Suspended", start_date: 1.year.ago, end_date: nil) } + let!(:lapsed_fac) { org_with("Active", start_date: 3.years.ago, end_date: 1.year.ago) } + let!(:no_fac) { org_with("Active") } + let!(:non_fac_only) do + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")).tap do |org| + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", start_date: 1.year.ago, end_date: nil) + end end - it "buckets Pending, Unknown, and no-status as never_active" do - expect(Organization.program_status("never_active")).to contain_exactly(pending, unknown, no_status) + it "buckets an active facilitator affiliation as active" do + expect(Organization.program_status("active")).to contain_exactly(active_fac) end - it "combines formerly + never" do - expect(Organization.program_status("formerly_or_never")).to contain_exactly(inactive, suspended, pending, unknown, no_status) + it "buckets only-lapsed facilitator affiliations as formerly_active" do + expect(Organization.program_status("formerly_active")).to contain_exactly(lapsed_fac) end - context "when the org has facilitator affiliations (they win over stored status)" do - let!(:active_fac_org) do - create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")).tap do |o| - create(:affiliation, organization: o, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) - end - end - let!(:lapsed_fac_org) do - create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")).tap do |o| - create(:affiliation, organization: o, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) - end - end - - it "buckets an active facilitator as active, regardless of the stored status" do - expect(Organization.program_status("active")).to include(active_fac_org) - expect(Organization.program_status("active")).not_to include(lapsed_fac_org) - end + it "buckets orgs with no facilitator affiliation as never_active, whatever the stored status" do + expect(Organization.program_status("never_active")).to contain_exactly(no_fac, non_fac_only) + end - it "buckets only-lapsed facilitators as formerly_active, regardless of the stored status" do - expect(Organization.program_status("formerly_active")).to include(lapsed_fac_org) - expect(Organization.program_status("formerly_active")).not_to include(active_fac_org) - end + it "combines formerly + never" do + expect(Organization.program_status("formerly_or_never")).to contain_exactly(lapsed_fac, no_fac, non_fac_only) end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 1cbad10b90..8e4b6be024 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -509,6 +509,34 @@ def add_ce_registrant(target_event) ) expect(response.body).to match(/]*name="return_to"[^>]*value="events"/) end + + it "returns to the organization profile when arriving from its program-status chips" do + organization = create(:organization) + sign_in admin + get participation_events_path(event_id: training_2026.id, organization_id: organization.id, return_to: "organization") + + expect(response.body).to include("← Organization") + expect(response.body).to include(CGI.escapeHTML(organization_path(organization, anchor: "program-status"))) + # The origin has to survive a filter change, or the eyebrow reverts. + expect(response.body).to match(/]*name="organization_id"[^>]*value="#{organization.id}"/) + end + + it "returns to the organization edit form when arriving from its program-status chips" do + organization = create(:organization) + sign_in admin + get participation_events_path(event_id: training_2026.id, organization_id: organization.id, return_to: "organization_edit") + + expect(response.body).to include("← Organization") + expect(response.body).to include(CGI.escapeHTML(edit_organization_path(organization, anchor: "program-status"))) + end + + it "falls back to the reports eyebrow when the organization origin has no id" do + sign_in admin + get participation_events_path(return_to: "organization") + + expect(response.body).to include("← Reports") + expect(response.body).not_to include("← Organization") + end end context "as non-admin" do diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index 4d2d48a185..c2a9a94f97 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -69,6 +69,12 @@ def register(event:, status: "registered") expect(response.body).to include("Program status") expect(response.body).to include("TOS205") expect(response.body).to include("Ongoing") + # The chip opens in a new tab, so it must tell the report how to get back here — + # and the block needs the id the report's eyebrow anchors to. + expect(response.body).to include( + CGI.escapeHTML(participation_events_path(event_id: event.id, organization_id: organization.id, return_to: "organization")) + ) + expect(response.body).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") end it "renders the section heading and lazy frame on the profile page" do diff --git a/spec/requests/organizations_index_preloading_spec.rb b/spec/requests/organizations_index_preloading_spec.rb new file mode 100644 index 0000000000..01beb5cbc4 --- /dev/null +++ b/spec/requests/organizations_index_preloading_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +# The results frame rolls up each org's sectors and age groups across its +# affiliated people (Organization#affiliated_people). Those roll-ups re-query per +# row unless the controller preloads people with the PEOPLE_TAGGINGS nest, so this +# guards the preload rather than a specific query count: adding orgs must not add +# queries. +RSpec.describe "Organizations index preloading", type: :request do + let(:admin) { create(:user, :admin) } + let!(:sector) { create(:sector, :published, name: "Housing") } + let!(:age_type) { create(:category_type, name: "AgeRange", published: true) } + let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } + + before { sign_in admin } + + def create_orgs(count) + count.times do |i| + org = create(:organization, name: "Preload Org #{Organization.count}#{i}") + person = create(:person) + create(:affiliation, organization: org, person: person, title: "Facilitator") + person.sectorable_items.create!(sector: sector, is_primary: true) + person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + end + end + + def queries_for_results_frame + Rails.cache.clear + count = 0 + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + count += 1 unless payload[:name].to_s.match?(/SCHEMA|TRANSACTION/) + end + get organizations_url, headers: { "Turbo-Frame" => "organizations_results" } + ActiveSupport::Notifications.unsubscribe(subscriber) + expect(response).to be_successful + count + end + + it "does not issue more queries as more organizations are listed" do + create_orgs(2) + baseline = queries_for_results_frame + + create_orgs(6) + expect(queries_for_results_frame).to eq(baseline) + end +end diff --git a/spec/system/organization_program_status_spec.rb b/spec/system/organization_program_status_spec.rb index 8a7a85a4f2..96b6f08a34 100644 --- a/spec/system/organization_program_status_spec.rb +++ b/spec/system/organization_program_status_spec.rb @@ -3,8 +3,10 @@ RSpec.describe "Organization program status live update", type: :system do let(:admin) { create(:user, :admin) } let!(:person) { create(:person) } - let!(:pending_status) { create(:organization_status, name: "Pending") } - let!(:organization) { create(:organization, organization_status: pending_status) } + # A stored status that contradicts the affiliations, to prove it never feeds the + # chip: the org reads Active purely because a facilitator affiliation is active. + let!(:stored_status) { create(:organization_status, name: "Pending") } + let!(:organization) { create(:organization, organization_status: stored_status) } before do driven_by(:selenium_chrome_headless) @@ -39,7 +41,7 @@ def status_chip expect(status_chip).to have_text("Formerly active", wait: 5) end - it "falls back to the stored status when the only facilitator is removed" do + it "drops to Never active when the only facilitator row is removed" do visit_and_wait edit_organization_path(organization) expect(status_chip).to have_text("Active") diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 199a9ae958..2ca11e9347 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -79,6 +79,39 @@ def org_with_status(name) render expect(rendered).not_to include("Legacy organization status does not match affiliation status") end + + # The mismatch is judged on program-status buckets, so a stored status that + # buckets the same way as the affiliations is not a mismatch, whatever its name. + it "warns when a stored 'Active' org has affiliations but none are facilitators" do + org = org_with_status("Active") + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", inactive: false, end_date: nil) + assign(:organization, org.reload) + render + expect(rendered).to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for a stored 'Pending' org with no facilitator affiliations" do + assign(:organization, org_with_status("Pending")) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for stored 'Reinstate' with an active facilitator (both active)" do + org = org_with_status("Reinstate") + create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) + assign(:organization, org.reload) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for stored 'Suspended' with only lapsed facilitators (both formerly active)" do + org = org_with_status("Suspended") + create(:affiliation, organization: org, person: create(:person), + start_date: 3.years.ago.to_date, end_date: 1.year.ago.to_date) + assign(:organization, org.reload) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end end describe "art program since" do @@ -114,6 +147,12 @@ def org_with_status(name) render expect(rendered).to include("Aug 2026 · Ongoing · PES205") + # Opens in a new tab, so the report needs the route back to this form — + # and the block needs the id that back-link anchors to. + expect(rendered).to include(CGI.escapeHTML( + participation_events_path(event_id: event.id, organization_id: org.id, return_to: "organization_edit") + )) + expect(rendered).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") end it "always shows the general status chip, even with no events" do From 227a544459c475040afe145cb16210461debc05e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 10:06:02 -0400 Subject: [PATCH 27/40] Keep PEOPLE_TAGGINGS from orphaning the AGENCY_TYPES doc comment The constant landed between the AGENCY_TYPES explanation and the constant it documents, so that comment read as if it described the preload nest. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/organization.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 289693cb09..f48c782ead 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -32,15 +32,15 @@ class Organization < ApplicationRecord saver: { quality: 80 } end + # The affiliated-people nest every org-level roll-up reads (see #affiliated_people). + # List pages must preload people with exactly this, or each row re-queries. + PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze + # The organization classifications offered by the org form and the registration # form's "Organization Type" question, in display order. "Other" is the generic # catch-all; any stored value not in this list (e.g. a legacy label like the # pre-rename "Other (please specify below)") is folded into it for display so an # unmatched select can't silently save as the first option. - # The affiliated-people nest every org-level roll-up reads (see #affiliated_people). - # List pages must preload people with exactly this, or each row re-queries. - PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze - AGENCY_TYPE_OTHER = "Other" AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze From bdd4be1f910b9f24899b8fd58280bec973caee29 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 10:06:02 -0400 Subject: [PATCH 28/40] Record in ADR-0001 what main's affiliation changes settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing picked up two changes that bear on the program-status decisions. #2176 dates new affiliations to the actual date rather than the 1st, which retires most of the date-precision caveat — but only for rows created since, so historical rows can still misread as Ongoing. #2194 restricts facilitator minting to training registrations, which is what makes keying status off facilitator affiliations alone a read on training participation. Co-Authored-By: Claude Opus 5 (1M context) --- ...anization-affiliation-and-program-status.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index 28bbeb9d27..9c501e81dd 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -149,9 +149,15 @@ The classification anchors on the event's actual `start_date`. excludes the recipient's own affiliations to answer a scholarship-specific question). It is intentionally **not** covered by D5; reconcile with the per-event model later if the two need to agree. -- **Affiliation date precision:** if facilitator affiliations are ever recorded - at month precision (e.g. the 1st of the month) rather than the actual event - date, the raw-start-date anchor (D7) can read a same-month affiliation as - Ongoing instead of New. Observed data uses the actual event start date; - revisit if month-precision data appears (this is why `program_statuses` - originally used `beginning_of_month`). +- **Affiliation date precision:** the raw-start-date anchor (D7) reads a + month-precision affiliation (dated to the 1st) as Ongoing where the actual date + would read New — which is why `program_statuses` originally used + `beginning_of_month`. Newly minted affiliations are safe: #2176 changed both the + registration-minted and manually added rows to use the actual date. **Rows + created before that change may still be dated to the 1st**, so a historical + same-month training can still classify as Ongoing. +- **What creates a facilitator affiliation:** since #2194 only a facilitator + *training* registration mints one (non-training registrations get a job + affiliation instead), and the row records its creating `event_registration_id`. + That makes the D3 status buckets — which now key off facilitator affiliations + alone — a read on training participation rather than on any registration. From c930bacb5fe6ad60b371400361ed1ea12a80b3d6 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 11:16:22 -0400 Subject: [PATCH 29/40] Stop ai/test aborting when a branch deletes a system spec The system-spec selection reads the branch diff, which lists deleted files too, so a branch that removes a system spec handed rspec a path that no longer exists and the whole run died with a LoadError before any example ran. Co-Authored-By: Claude Opus 5 (1M context) --- ai/test | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ai/test b/ai/test index 795d46ba15..23cbb932fa 100755 --- a/ai/test +++ b/ai/test @@ -75,6 +75,9 @@ if [ -d spec/system ] && [ -n "$keywords" ]; then related=$(find spec/system -name "*_spec.rb" | grep -Ei "$pattern" | sort -u || true) fi selected=$(printf "%s\n" "$direct" "$related" | grep -E "_spec\.rb$" | sort -u || true) +# A branch that DELETES a system spec still lists it in the diff, and handing rspec +# a path that no longer exists aborts the whole run with a LoadError. +selected=$(printf "%s\n" $selected | while IFS= read -r f; do [ -f "$f" ] && printf "%s\n" "$f"; done || true) # Non-system suite = every spec dir except system (+ any root-level specs). spec_targets=$(find spec -mindepth 1 -maxdepth 1 -type d ! -name system | sort) From e89af4e190c8c6a9164c71331043bc27df02728b Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 11:16:22 -0400 Subject: [PATCH 30/40] Render "Art program since" as one value everywhere, at month precision The profile showed merged periods while the edit form built its own earliest-start-to-latest-end span, under the same label. That was not two formats of one fact: for an org that facilitated 2015-2018, lapsed, and returned in 2024, the form rendered "Aug 2015" and the gap disappeared, because facilitation_end_date is nil whenever any facilitator is active. Give AffiliationPeriods a month precision and let both surfaces read the one decorator method, so the exact month a program started or lapsed survives and the two cannot drift again. The Stimulus mirror follows suit, which drops one of its two rendering paths. The same date-range span was copy-pasted three times across the org and person forms; the two remaining person-form copies become decorator methods, and the org decorator's now-unused facilitator date readers go. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- app/decorators/organization_decorator.rb | 20 +++---- app/decorators/person_decorator.rb | 20 +++++++ .../affiliation_dates_controller.js | 56 ++++++++++++------- app/services/affiliation_periods.rb | 52 +++++++++++------ app/views/organizations/_form.html.erb | 6 +- app/views/people/_form.html.erb | 4 +- ...nization-affiliation-and-program-status.md | 14 ++++- .../decorators/organization_decorator_spec.rb | 2 +- spec/services/affiliation_periods_spec.rb | 33 +++++++++++ spec/system/affiliation_dates_spec.rb | 16 ++++++ .../views/organizations/edit.html.erb_spec.rb | 17 ++++++ 12 files changed, 184 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d144fb4657..26fa5db9ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ action, or `authorize! :workshop, to: :summary?`). ### Business Logic -- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods and formats them as year-based ranges for the "Affiliated since" display (e.g. "2010-2012, 2026"); rendered server-side on the org show/index/edit pages (single source of truth — no JS duplication) +- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods, at year precision for "Affiliated since" (e.g. "2010-2012, 2026") or month precision for "Art program since" (e.g. "Aug 2015 – Jun 2018, Feb 2024"); rendered server-side on the org show/index/edit pages, with `affiliation_dates_controller.js` mirroring it only to live-update the edit form - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index d078251e5b..509f30c014 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -98,11 +98,14 @@ def affiliated_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" end - # "Program since" display: the org's facilitator-affiliation history as merged - # year-based periods (see AffiliationPeriods). Blank when it has never - # facilitated. Pass a preloaded affiliations collection on list pages. + # "Art program since" display: the org's facilitator-affiliation history as + # merged periods (see AffiliationPeriods), at month precision — when a program + # started or lapsed is the whole point of the figure. One value for every + # surface that shows it (index chip, profile, edit form), so they can't drift. + # Blank when the org has never facilitated. Pass a preloaded affiliations + # collection on list pages. def program_since_display(affiliations = object.affiliations) - AffiliationPeriods.label(affiliations.select(&:facilitator?)) || "" + AffiliationPeriods.label(affiliations.select(&:facilitator?), precision: :month) || "" end ORG_STATUS_BUCKET_LABELS = { @@ -181,15 +184,6 @@ def program_since_chip(years = program_since_display) class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") end - def facilitator_since_date - @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) - end - - def facilitation_end_date - facilitator_affiliations = affiliations.facilitators - return nil if facilitator_affiliations.active.exists? - 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 diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 09bb4d5421..4586b2c72d 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -90,6 +90,18 @@ def affiliated_since_date @affiliated_since_date ||= affiliations.filter_map(&:start_date).min end + # The person form's two live-updating date figures. Both render a single + # "Mon YYYY – Mon YYYY" span, prefixed with a ✗ once the range has closed — + # the server-rendered twin of affiliation_dates_controller#updateDisplay, which + # replaces this content as the affiliation rows are edited. + def affiliated_since_range + date_range_display(affiliated_since_date, affiliation_end_date, ended_title: "No active affiliations") + end + + def facilitator_since_range + date_range_display(facilitator_since_date, facilitation_end_date, ended_title: "No active facilitator affiliations") + end + private def compute_badges @@ -112,6 +124,14 @@ def compute_badges badges end + def date_range_display(since, ended, ended_title:) + parts = [] + parts << h.content_tag(:i, "", class: "fa-solid fa-circle-xmark text-red-400 mr-1", title: ended_title) if ended + parts << (since&.strftime("%b %Y") || "—") + parts << " – #{ended.strftime('%b %Y')}" if ended + h.safe_join(parts) + end + def badge(label, key) { label: label, diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 36936a6f9c..f6c816b3a9 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -2,17 +2,18 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] - // "Affiliated since" has two live formats: the person form shows a single - // Mon YYYY – Mon YYYY range; the org form (affiliatedSincePeriods) shows merged - // year-based periods, mirroring the AffiliationPeriods service so the live value - // matches the server render. affiliatedSinceFallback is the org's own start_date - // (already formatted) shown when no affiliation carries a start date. + // Two live formats. The person form shows a single Mon YYYY – Mon YYYY range for + // both figures. The org form (mergedPeriods) shows merged periods mirroring the + // AffiliationPeriods service, so the live value matches the server render: + // "Affiliated since" at year precision, "Art program since" at month precision. + // affiliatedSinceFallback is the org's own start_date (already formatted), shown + // when no affiliation carries a start date. // // Program status (org edit form): derived live from the visible Facilitator rows // alone, mirroring OrganizationDecorator#organization_status_bucket. statusBuckets // holds each bucket's label + pill classes (from DomainTheme). static values = { - affiliatedSincePeriods: Boolean, + mergedPeriods: Boolean, affiliatedSinceFallback: String, statusBuckets: Object } @@ -58,8 +59,8 @@ export default class extends Controller { // Affiliated since — the org form shows merged year-based periods, the person // form a single Mon YYYY range. Both live-update from the visible rows. if (this.hasAffiliatedSinceTarget) { - if (this.affiliatedSincePeriodsValue) { - const label = this.affiliatedSincePeriodsLabel(affiliations, today) || this.affiliatedSinceFallbackValue + if (this.mergedPeriodsValue) { + const label = this.periodsLabel(affiliations, today, "year") || this.affiliatedSinceFallbackValue this.affiliatedSinceTarget.textContent = label || "—" } else { const startDates = affiliations.map(a => a.startDate).filter(Boolean) @@ -75,9 +76,9 @@ export default class extends Controller { } } - // Facilitations/program since — unchanged single-range display, filtered by - // title. Mirror Affiliation#facilitator?: an exact, case-sensitive match on - // "Facilitator" (trimmed), so the live figure matches the server render. + // Facilitator rows drive "Art program since". Mirror Affiliation#facilitator?: + // an exact, case-sensitive match on "Facilitator" (trimmed), so the live figure + // matches the server render. const facilitatorAffiliations = affiliations.filter(a => a.title.trim() === "Facilitator" ) @@ -92,7 +93,12 @@ export default class extends Controller { : null if (this.hasFacilitatorSinceTarget) { - this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) + if (this.mergedPeriodsValue) { + this.facilitatorSinceTarget.textContent = + this.periodsLabel(facilitatorAffiliations, today, "month") || "—" + } else { + this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) + } } // Program status — active when any Facilitator row is still active, formerly @@ -136,13 +142,17 @@ export default class extends Controller { return `${months[date.getUTCMonth()]} ${date.getUTCFullYear()}` } - // Merged, year-based "Affiliated since" label for the org form, mirroring the - // AffiliationPeriods service: overlapping/touching intervals collapse into one - // period (a nil end is ongoing and swallows later intervals), a real gap starts - // a new one. A single ongoing period keeps month precision when it began this - // year; any multi-period list is year-only. Returns null when no affiliation - // carries a start date, so the caller can fall back to the org's start_date. - affiliatedSincePeriodsLabel(affiliations, today) { + // Merged-period label for the org form, mirroring the AffiliationPeriods service: + // overlapping/touching intervals collapse into one period (a nil end is ongoing + // and swallows later intervals), a real gap starts a new one. + // + // precision "year": a single ongoing period keeps month precision when it began + // this year, any multi-period list is year-only. precision "month": every period + // carries its month ("Aug 2015 – Jun 2018, Feb 2024"). + // + // Returns null when no affiliation carries a start date, so the caller can fall + // back to the org's start_date. + periodsLabel(affiliations, today, precision) { const intervals = affiliations .filter(a => a.startDate) .map(a => [ new Date(a.startDate), a.endDate ? new Date(a.endDate) : null ]) @@ -160,6 +170,14 @@ export default class extends Controller { }) const ongoing = finish => finish === null || finish >= today + if (precision === "month") { + return periods + .map(([ start, finish ]) => + ongoing(finish) + ? this.formatDate(start) + : `${this.formatDate(start)} – ${this.formatDate(finish)}`) + .join(", ") + } // A single ongoing period is a fresh org — worth the month's precision. if (periods.length === 1 && ongoing(periods[0][1])) { return this.yearOrMonth(periods[0][0], today) diff --git a/app/services/affiliation_periods.rb b/app/services/affiliation_periods.rb index 59224738db..fd08fabe0d 100644 --- a/app/services/affiliation_periods.rb +++ b/app/services/affiliation_periods.rb @@ -1,24 +1,32 @@ -# Formats an organization's affiliation history as merged, year-based periods for -# the "Affiliated since" display. Each affiliation is a [start_date, end_date] -# interval (a nil end = ongoing); overlapping or touching intervals collapse into -# one period, and a real gap starts a new one. +# Formats an affiliation history as merged periods. Each affiliation is a +# [start_date, end_date] interval (a nil end = ongoing); overlapping or touching +# intervals collapse into one period, and a real gap starts a new one. Periods +# join chronologically with ", ". # -# Formatting: -# * A lone ongoing period (a fresh org) shows "Mon YYYY" when it began this year -# (e.g. "Jul 2026"), otherwise just its start year — no end. -# * In any multi-period list, every period is year-only for consistency: an -# ongoing period is its start year, a closed period is "YYYY" (same-year) or -# "YYYY-YYYY". Periods join chronologically with ", " (e.g. "2010-2012, 2026"). +# Two precisions, because the two displays want different granularity: +# * :year (default) — "Affiliated since". A lone ongoing period (a fresh org) +# shows "Mon YYYY" when it began this year (e.g. "Jul 2026"), otherwise just +# its start year. In a multi-period list every period is year-only for +# consistency: ongoing is its start year, closed is "YYYY" (same-year) or +# "YYYY-YYYY" — e.g. "2010-2012, 2026". +# * :month — "Art program since", where the exact month a program started or +# lapsed matters. Ongoing is "Mon YYYY", closed is "Mon YYYY – Mon YYYY" — +# e.g. "Aug 2015 – Jun 2018, Feb 2024". # # Returns nil when no affiliation carries a start date, so callers can fall back # to the organization's own start_date. class AffiliationPeriods - def self.label(affiliations, today: Date.current) - new(affiliations, today: today).label + PRECISIONS = %i[ year month ].freeze + + def self.label(affiliations, today: Date.current, precision: :year) + new(affiliations, today: today, precision: precision).label end - def initialize(affiliations, today: Date.current) + def initialize(affiliations, today: Date.current, precision: :year) + raise ArgumentError, "unknown precision #{precision.inspect}" unless PRECISIONS.include?(precision) + @today = today + @precision = precision @intervals = affiliations .filter_map { |affiliation| interval_for(affiliation) } .sort_by { |start, _finish| start } @@ -28,8 +36,9 @@ def label return nil if @intervals.empty? periods = merged - # A single ongoing period is a fresh org — worth the month's precision. - if periods.one? && ongoing?(periods.first[1]) + # At year precision a single ongoing period is a fresh org — worth the month's + # precision. At month precision every period already carries its month. + if @precision == :year && periods.one? && ongoing?(periods.first[1]) return year_or_month(periods.first[0]) end @@ -72,12 +81,23 @@ def ongoing?(finish) end def format_period((start, finish)) + return month_period(start, finish) if @precision == :month return start.year.to_s if ongoing?(finish) || start.year == finish.year "#{start.year}-#{finish.year}" end + def month_period(start, finish) + return month(start) if ongoing?(finish) + + "#{month(start)} – #{month(finish)}" + end + + def month(date) + date.strftime("%b %Y") + end + def year_or_month(date) - date.year == @today.year ? date.strftime("%b %Y") : date.year.to_s + date.year == @today.year ? month(date) : date.year.to_s end end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 017ff92ccf..d8e80b9a9a 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,6 +1,6 @@ <%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", - affiliation_dates_affiliated_since_periods_value: true, + affiliation_dates_merged_periods_value: true, affiliation_dates_affiliated_since_fallback_value: @organization.start_date&.strftime("%b %Y") || "", affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json } }) do |f| %> @@ -327,10 +327,10 @@ Art program since
- <% if org_decorated.facilitation_end_date %><% end %><%= org_decorated.facilitator_since_date&.strftime("%b %Y") || "—" %><%= " – #{org_decorated.facilitation_end_date.strftime('%b %Y')}" if org_decorated.facilitation_end_date %> + <%= org_decorated.program_since_display.presence || "—" %>
<% if allowed_to?(:manage?, Organization) %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 676608aa3a..edf690898f 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -274,7 +274,7 @@ <% end %>
- <% if decorated.affiliation_end_date %><% end %><%= decorated.affiliated_since_date&.strftime("%b %Y") || "—" %><%= " – #{decorated.affiliation_end_date.strftime('%b %Y')}" if decorated.affiliation_end_date %> + <%= decorated.affiliated_since_range %> <% if decorated.affiliated_since_date.nil? && person.affiliations.exists? %> <% elsif decorated.member_since_earlier_than_all_affiliations? %> @@ -302,7 +302,7 @@ <% end %>
- <% if decorated.facilitation_end_date %><% end %><%= decorated.facilitator_since_date&.strftime("%b %Y") || "—" %><%= " – #{decorated.facilitation_end_date.strftime('%b %Y')}" if decorated.facilitation_end_date %> + <%= decorated.facilitator_since_range %> <% if decorated.member_since_earlier_than_facilitator_affiliations? %>

⚠ Earlier date on file: <%= person.member_since.strftime("%b %Y") %>

<% elsif decorated.member_since_differs_from_facilitator_affiliations? %> diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index 9c501e81dd..1740940adc 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -44,12 +44,20 @@ with a registrant**. Rendered as merged year-based periods (`AffiliationPeriods.label`), e.g. `2010-2012, 2026`; falls back to the org's own `start_date`, then blank. See `OrganizationDecorator#affiliated_since_display`. -### D2 — "Facilitations/program since": facilitator affiliations only +### D2 — "Art program since": facilitator affiliations only, month precision -Same merged-year-period rendering as D1, but over **facilitator** affiliations -only. Blank when the org has never facilitated. See +Same merged-period rendering as D1 but over **facilitator** affiliations only, +and at **month** precision (`AffiliationPeriods.label(…, precision: :month)`) — +when a program started or lapsed is the point of the figure. Ongoing reads +`Feb 2024`, closed reads `Aug 2015 – Jun 2018`, e.g. +`Aug 2015 – Jun 2018, Feb 2024`. Blank when the org has never facilitated. See `OrganizationDecorator#program_since_display`. +**One value on every surface** (index chip, profile, edit form). The edit form +previously rendered its own earliest-start→latest-end span, which collapsed a +lapse-and-return into a single unbroken range and hid the gap; that is why this +is a single decorator method rather than per-page view logic. + ### D3 — Org-wide status chip: Active / Formerly active / Never active Three display buckets (`OrganizationDecorator#organization_status_bucket`), **not diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 179a415a4e..abe1619316 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -35,7 +35,7 @@ create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 6, 1)) create(:affiliation, organization: organization, person: create(:person), title: "Volunteer", start_date: Date.new(2005, 1, 1), end_date: nil) create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2024, 2, 1), end_date: nil) - expect(organization.reload.decorate.program_since_display).to eq("2015-2018, 2024") + expect(organization.reload.decorate.program_since_display).to eq("Jan 2015 – Jun 2018, Feb 2024") end end diff --git a/spec/services/affiliation_periods_spec.rb b/spec/services/affiliation_periods_spec.rb index db946425de..1d328d3964 100644 --- a/spec/services/affiliation_periods_spec.rb +++ b/spec/services/affiliation_periods_spec.rb @@ -69,4 +69,37 @@ def label(*intervals) Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)) )).to eq("2010-2012, 2026") end + + describe "month precision" do + def month_label(*intervals) + described_class.label(intervals, today: today, precision: :month) + end + + it "shows the month on both ends of a closed period" do + expect(month_label(Interval.new(Date.new(2015, 8, 1), Date.new(2018, 6, 1)))).to eq("Aug 2015 – Jun 2018") + end + + it "shows only the start month for an ongoing period" do + expect(month_label(Interval.new(Date.new(2015, 8, 1), nil))).to eq("Aug 2015") + end + + it "keeps a lapse and a return as separate periods" do + expect(month_label( + Interval.new(Date.new(2015, 8, 1), Date.new(2018, 6, 1)), + Interval.new(Date.new(2024, 2, 1), nil) + )).to eq("Aug 2015 – Jun 2018, Feb 2024") + end + + it "keeps the month even when a period starts and ends in the same year" do + expect(month_label(Interval.new(Date.new(2010, 3, 1), Date.new(2010, 9, 1)))).to eq("Mar 2010 – Sep 2010") + end + + it "returns nil when no affiliation carries a start date" do + expect(month_label(Interval.new(nil, nil))).to be_nil + end + end + + it "rejects an unknown precision rather than silently formatting as years" do + expect { described_class.label([], precision: :day) }.to raise_error(ArgumentError, /day/) + end end diff --git a/spec/system/affiliation_dates_spec.rb b/spec/system/affiliation_dates_spec.rb index 965c2aac9d..d686f9a0de 100644 --- a/spec/system/affiliation_dates_spec.rb +++ b/spec/system/affiliation_dates_spec.rb @@ -148,5 +148,21 @@ def set_text_input(input, value) expect(affiliated).to have_text("2018-2019, 2021", wait: 5) end + + # "Art program since" is the same merged-period value at month precision, so it + # has to live-update in that format too — not the old earliest→latest range. + it "live-updates 'Art program since' as month-precision periods" do + visit_and_wait edit_organization_path(merged_org) + + program = find("[data-affiliation-dates-target='facilitatorSince']") + expect(program).to have_text("Jan 2018 – Dec 2019, Jan 2022") + + ongoing_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| + f.find("input[name*='start_date']").value == "2022-01-01" + } + set_date_input(ongoing_row.find("input[name*='start_date']"), "2021-05-01") + + expect(program).to have_text("Jan 2018 – Dec 2019, May 2021", wait: 5) + end end end diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 2ca11e9347..b76a6084be 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -131,6 +131,23 @@ def org_with_status(name) assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Aug 2025/ assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Sep 2026/, count: 0 end + + # This form and the profile used to render "Art program since" two different + # ways — the profile as merged periods, this form as one earliest→latest span, + # which silently swallowed the gap. Both now render the one decorator value. + it "renders a lapse and a return as separate periods, matching the profile" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2024, 2, 1), end_date: nil) + assign(:organization, org.reload) + render + + expect(org.reload.decorate.program_since_display).to eq("Aug 2015 – Jun 2018, Feb 2024") + assert_select "[data-affiliation-dates-target='facilitatorSince']", + text: org.decorate.program_since_display + end end describe "program status" do From 8f334255b0c8d429020b903ef9373549aebf81a2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 18:50:12 -0400 Subject: [PATCH 31/40] Name the participation back-link's org param so it can't filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing picked up #2075, whose report subnav forwards REPORT_SUBNAV_PARAMS between report pages — and that list includes organization_id, which the attendees index treats as a real filter. This branch had been passing organization_id on the participation URL purely as a back-link breadcrumb, so the two together meant: org profile -> program-status chip -> Attendees tab silently narrowed the attendee list to that org, with no visible filter saying why. Neither change is wrong alone. Rename the breadcrumb to return_organization_id, which the subnav does not carry and nothing reads as a filter. Co-Authored-By: Claude Opus 5 (1M context) --- app/helpers/event_participation_helper.rb | 2 +- app/views/events/participation.html.erb | 6 ++++-- .../organizations/_program_status_event_chips.html.erb | 2 +- spec/requests/events_spec.rb | 6 +++--- spec/requests/organizations_events_section_spec.rb | 2 +- spec/views/organizations/edit.html.erb_spec.rb | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/helpers/event_participation_helper.rb b/app/helpers/event_participation_helper.rb index 1f48609ea1..0abbe4edad 100644 --- a/app/helpers/event_participation_helper.rb +++ b/app/helpers/event_participation_helper.rb @@ -8,7 +8,7 @@ module EventParticipationHelper # chips on an organization's profile or edit form; each origin passes return_to # (plus the id the path needs) so the user goes back where they came from. def participation_return_link - organization_id = params[:organization_id] + organization_id = params[:return_organization_id] case params[:return_to] when "organization" diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index e4e649713d..7ef68389ef 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -22,8 +22,10 @@ <%= form_with url: participation_events_path, method: :get, local: true, class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-8" do %> <%= hidden_field_tag :return_to, params[:return_to] %> - <%# Carries the origin org through a filter change, so the eyebrow survives it. %> - <%= hidden_field_tag :organization_id, params[:organization_id] %> + <%# Carries the origin org through a filter change, so the eyebrow survives it. + Deliberately NOT `organization_id`: that is a real attendee filter carried + across the report subnav, and this is only a breadcrumb. %> + <%= hidden_field_tag :return_organization_id, params[:return_organization_id] %>
<%= render "time_period_filter" %> <%= render "event_type_filter" %> diff --git a/app/views/organizations/_program_status_event_chips.html.erb b/app/views/organizations/_program_status_event_chips.html.erb index 415d4444c9..7a026f481e 100644 --- a/app/views/organizations/_program_status_event_chips.html.erb +++ b/app/views/organizations/_program_status_event_chips.html.erb @@ -5,7 +5,7 @@ <% decorated = organization.decorate %> <% events.each do |event| %> <% status = decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to participation_events_path(event_id: event.id, organization_id: organization.id, return_to: return_to), + <%= link_to participation_events_path(event_id: event.id, return_organization_id: organization.id, return_to: return_to), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 8e4b6be024..346f3022f5 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -513,18 +513,18 @@ def add_ce_registrant(target_event) it "returns to the organization profile when arriving from its program-status chips" do organization = create(:organization) sign_in admin - get participation_events_path(event_id: training_2026.id, organization_id: organization.id, return_to: "organization") + get participation_events_path(event_id: training_2026.id, return_organization_id: organization.id, return_to: "organization") expect(response.body).to include("← Organization") expect(response.body).to include(CGI.escapeHTML(organization_path(organization, anchor: "program-status"))) # The origin has to survive a filter change, or the eyebrow reverts. - expect(response.body).to match(/]*name="organization_id"[^>]*value="#{organization.id}"/) + expect(response.body).to match(/]*name="return_organization_id"[^>]*value="#{organization.id}"/) end it "returns to the organization edit form when arriving from its program-status chips" do organization = create(:organization) sign_in admin - get participation_events_path(event_id: training_2026.id, organization_id: organization.id, return_to: "organization_edit") + get participation_events_path(event_id: training_2026.id, return_organization_id: organization.id, return_to: "organization_edit") expect(response.body).to include("← Organization") expect(response.body).to include(CGI.escapeHTML(edit_organization_path(organization, anchor: "program-status"))) diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index c2a9a94f97..abb6d18496 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -72,7 +72,7 @@ def register(event:, status: "registered") # The chip opens in a new tab, so it must tell the report how to get back here — # and the block needs the id the report's eyebrow anchors to. expect(response.body).to include( - CGI.escapeHTML(participation_events_path(event_id: event.id, organization_id: organization.id, return_to: "organization")) + CGI.escapeHTML(participation_events_path(event_id: event.id, return_organization_id: organization.id, return_to: "organization")) ) expect(response.body).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") end diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index b76a6084be..958789bfdf 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -167,7 +167,7 @@ def org_with_status(name) # Opens in a new tab, so the report needs the route back to this form — # and the block needs the id that back-link anchors to. expect(rendered).to include(CGI.escapeHTML( - participation_events_path(event_id: event.id, organization_id: org.id, return_to: "organization_edit") + participation_events_path(event_id: event.id, return_organization_id: org.id, return_to: "organization_edit") )) expect(rendered).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") end From 5d1677b25665e7f367acd567ca24cc9930226d6b Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 16 Aug 2026 12:24:57 -0400 Subject: [PATCH 32/40] Make program status one rule anchored on the event's date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several code paths decided New/Ongoing/Reinstate their own way. Two of them excluded the registrant's own affiliation, differently, and the dashboard re-anchored on that affiliation's start date instead of the event's — so the same org at the same event could read Ongoing on the onboarding matrix and New in the dashboard pie, and the pie's answer moved when a different registrant signed up. FacilitatorProgramStatus is now the only classifier. Dropping self-exclusion is safe because a training's minted affiliation starts on the training date and "before" is strict, so a first-time org still reads New at its own first training. Statuses now carry their own reasoning, so every badge hovers to explain the anchor date, what made the program active, and the facilitator history behind it. Cross-event lists have no event to anchor on, so they read as of Jan 1 and say so rather than quietly using "today" — which the attendees filter was doing while its column would have said otherwise. Adds the annual-reporting page these counts were being assembled by hand for: organizations by status at each training, summed per year, plus the distinct- organization view, because an org at three trainings shouldn't silently count as three programs. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + app/controllers/events_controller.rb | 25 ++- app/controllers/organizations_controller.rb | 3 +- app/decorators/organization_decorator.rb | 23 ++- app/helpers/events_helper.rb | 20 +- app/models/event_registration.rb | 22 +- app/models/organization.rb | 62 +++--- app/services/attendees_breakdowns.rb | 6 +- app/services/attendees_roster.rb | 13 +- app/services/event_dashboard.rb | 55 ++--- app/services/event_program_status_report.rb | 191 ++++++++++++++++++ app/services/facilitator_program_status.rb | 123 +++++++++++ app/views/events/_breakdown_card.html.erb | 12 +- .../events/_program_status_report.html.erb | 123 +++++++++++ .../events/_program_status_summary.html.erb | 45 +++++ .../events/_registrant_breakdowns.html.erb | 4 +- app/views/events/_registrant_roster.html.erb | 16 +- app/views/events/_report_subnav.html.erb | 4 +- app/views/events/onboarding/_results.html.erb | 4 + app/views/events/onboarding/_row.html.erb | 6 +- app/views/events/program_statuses.html.erb | 48 +++++ app/views/events/reports.html.erb | 1 + app/views/events/roster.html.erb | 2 +- app/views/organizations/_form.html.erb | 2 - .../_program_status_event_chips.html.erb | 12 +- .../organizations_results.html.erb | 5 +- config/routes.rb | 1 + ...nization-affiliation-and-program-status.md | 136 ++++++++----- .../decorators/organization_decorator_spec.rb | 10 +- spec/models/event_registration_spec.rb | 33 ++- spec/models/organization_spec.rb | 77 +++---- spec/requests/events_program_statuses_spec.rb | 61 ++++++ spec/requests/events_spec.rb | 16 +- spec/services/event_dashboard_spec.rb | 36 +++- .../event_program_status_report_spec.rb | 141 +++++++++++++ .../public_registration_spec.rb | 11 + .../facilitator_program_status_spec.rb | 114 +++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 38 files changed, 1221 insertions(+), 245 deletions(-) create mode 100644 app/services/event_program_status_report.rb create mode 100644 app/services/facilitator_program_status.rb create mode 100644 app/views/events/_program_status_report.html.erb create mode 100644 app/views/events/_program_status_summary.html.erb create mode 100644 app/views/events/program_statuses.html.erb create mode 100644 spec/requests/events_program_statuses_spec.rb create mode 100644 spec/services/event_program_status_report_spec.rb create mode 100644 spec/services/facilitator_program_status_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 26fa5db9ff..dede28d1e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,8 @@ action, or `authorize! :workshop, to: :summary?`). - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build +- `FacilitatorProgramStatus` — The one rule for an organization's New / Ongoing / Reinstate program status as of a date (its facilitator affiliations, strict `<` on the anchor so the affiliation a training mints isn't prior history). Anchors on the event's start date, falling back to Jan 1 of the current year when no event is in view (`year_anchored?`). Carries the anchor, the month the program went active and its merged facilitator periods, so `#explanation` gives every badge its hover text. Reached via `Organization#facilitator_program_status(as_of:)`; see ADR-0001 D4/D5/D7 +- `EventProgramStatusReport` — Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card - `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` - `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see - `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index eb8fab51a5..b9e15a3a6e 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,18 +3,18 @@ class EventsController < ApplicationController 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 attendance sample_ticket registrants roster onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form feature_recipient_shoutout ] - before_action :set_report_filters, only: %i[ revenue participation reports scholarships signins ] + before_action :set_report_filters, only: %i[ revenue participation reports scholarships program_statuses signins ] # The cross-event report suite is visible to admins and event owners alike; what # differs is the rows, which EventPolicy's :reportable scope narrows to the # viewer's own events. #attendance is per-event, so it authorizes its own record # rather than joining this list. - before_action :authorize_report!, only: %i[ revenue participation reports scholarships attendees signins ] + before_action :authorize_report!, only: %i[ revenue participation reports scholarships program_statuses attendees signins ] # Log a visit to each event page / report. after_action so it only fires once # the action rendered successfully (authorization inside the actions has passed); # the turbo_frame_request? / redirect guards skip the lazy results/charts # sub-requests and the confirm-reminder bounce-back. send_reminder is logged # inline on a successful send (it always redirects). - after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships attendees signins confirm_reminder ] + after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships program_statuses attendees signins confirm_reminder ] def index authorize! @@ -63,6 +63,7 @@ def reports @revenue_report = EventRevenueReport.new(report_events(Event.paid)) @participation_report = EventParticipationReport.new(report_events(Event.all)) @scholarship_report = EventScholarshipReport.new(report_events(Event.facilitator_trainings)) + @program_status_report = EventProgramStatusReport.new(report_events(Event.facilitator_trainings)) end # Cross-event scholarship report: scholarship dollars and award counts (funded @@ -74,6 +75,14 @@ def scholarships @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end + # Cross-event program-status report: how many organizations were New / Ongoing / + # Reinstate at each facilitator training, by year — the annual-reporting figures. + # Sibling of the revenue, participation and scholarship reports. + def program_statuses + events, selected_year = filtered_report_events(Event.facilitator_trainings) + @report = EventProgramStatusReport.new(events, featured_year: selected_year) + end + # Cross-event index of the people behind event registrations, deduped to one row # per person. Lazy-loaded like the people index: the frame request builds the # filtered/paginated page and its roster; the full request renders the shell @@ -944,14 +953,16 @@ def org_ids_by_city_label .transform_values { |pairs| pairs.map(&:first) } end - # Person ids whose linked training org currently has the given facilitator - # program status (new / ongoing / reinstated). + # Person ids whose linked training org has the given facilitator program status + # (new / ongoing / reinstated). Anchored the same way the index's own column is — + # this list spans events, so both read as of the start of the current year (see + # FacilitatorProgramStatus) rather than the filter and the column disagreeing. def person_program_status_ids(status) status_sym = status.to_sym org_ids = Organization .where(id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id)) .includes(:affiliations) - .select { |organization| organization.facilitator_status_on(Date.current) == status_sym } + .select { |organization| organization.facilitator_status_on == status_sym } .map(&:id) return Person.none if org_ids.empty? person_linked_organization_ids(org_ids) @@ -1137,7 +1148,7 @@ def onboarding_csv_string def onboarding_csv_row(registration, cost_required, day_count, include_ce = false) person = registration.registrant scholarship = registration.scholarships.first - statuses = registration.program_statuses.map { |status| status.to_s.titleize }.join(", ") + statuses = registration.program_statuses.map(&:label).join(", ") row = [ person.first_name, diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index d208374295..a5c4580e83 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,7 +8,7 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :organization_status, :sectors, :addresses, :affiliations, + :organization_status, :sectors, :sectorable_items, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, # Feeds the sector and age-group roll-ups per row — see Organization#affiliated_people. { people: Organization::PEOPLE_TAGGINGS }, @@ -213,7 +213,6 @@ def set_form_variables end def set_index_variables - @organization_statuses = OrganizationStatus.all @sector_options = Sector.published.order(:name).pluck(:name) @age_group_options = Category.joins(:category_type) .where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 509f30c014..1daef0eb9a 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -8,10 +8,11 @@ class OrganizationDecorator < ApplicationDecorator reinstated: :program_reinstated }.freeze - # Normalize either the :new/:ongoing/:reinstated symbol (EventDashboard / index - # controller) or the "New"/"Ongoing"/"Reinstate" string (Organization#program_status) - # to the canonical symbol; nil when blank or unrecognized. + # Normalize a FacilitatorProgramStatus, the :new/:ongoing/:reinstated symbol, or + # the "New"/"Ongoing"/"Reinstate" string (Organization#program_status) to the + # canonical symbol; nil when blank or unrecognized. def self.program_status_key(status) + status = status.status if status.respond_to?(:status) return if status.blank? key = status.to_s.downcase.start_with?("reinstat") ? :reinstated : status.to_s.downcase.to_sym @@ -39,8 +40,10 @@ def program_status_badge(status = object.program_status) key = self.class.program_status_key(status) return unless key + # A FacilitatorProgramStatus explains its own verdict (anchor date, what made + # the program active); anything else can only name it. h.content_tag(:span, key.to_s.first.upcase, - title: key.to_s.titleize, + title: status.respond_to?(:explanation) ? status.explanation : key.to_s.titleize, class: "inline-flex shrink-0 items-center justify-center w-5 h-5 rounded-full border text-xs font-semibold #{self.class.program_status_classes(status)}") end @@ -185,14 +188,12 @@ def program_since_chip(years = program_since_display) 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. Delegates to Organization#facilitator_status_on (single source - # of truth), which reads the already-loaded affiliations so a profile can - # classify many events without an N+1. `date` may be a datetime (event.start_date - # is one), so normalize to a Date before the model's date comparisons. + # This org's program status as it stood on a given date, as a + # FacilitatorProgramStatus (verdict + anchor + reasoning for the hover). Reads + # the already-loaded affiliations, so a profile classifies many events without + # an N+1. `date` may be a datetime (event.start_date is one). def facilitator_status_as_of(date) - object.facilitator_status_on(date&.to_date) + object.facilitator_program_status(as_of: date&.to_date) end def badges diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 3f7b2f181e..11ee54d2c6 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -100,6 +100,24 @@ def scholarships_report_return_path anchor: params[:return_anchor].presence) end + # Header for any "Program status" column. A program status is only meaningful + # relative to a date, so the header names the event it was judged at — "Program + # status (TOS205)" — and the caveat below covers the case where there is none. + def program_status_column_label(event = nil) + return "Program status" if event.blank? + + "Program status (#{event.decorate.compact_label})" + end + + # The hover note for a Program status column: which date the verdicts were + # judged on. Cross-event lists have no event date to anchor on, so they read as + # of the start of the current year (see FacilitatorProgramStatus). + def program_status_column_note(event = nil) + return "New / Ongoing / Reinstate as of #{event.start_date.strftime('%b %-d, %Y')}, this event's start date." if event&.start_date + + "No single event in view, so each organization reads as of #{Date.current.beginning_of_year.strftime('%b %-d, %Y')} — the start of the current reporting year." + end + # Ordered column descriptors for the event Onboarding matrix. The array index # is the table-sort column index, so the header row and every body row iterate # this same list — keeping header buttons and cell positions aligned no matter @@ -116,7 +134,7 @@ def onboarding_columns(event) columns << { key: "attendance", label: "Event attendance", kind: :attendance, sortable: true, align: "center", toggle: "attendance" } columns += [ { key: "program", label: "Organization", kind: :program, sortable: true, align: "left", toggle: "program" }, - { key: "program_type", label: "Program type", kind: :program_type, sortable: true, align: "center", toggle: "program_type" } + { key: "program_type", label: program_status_column_label(event), kind: :program_type, sortable: true, align: "center", toggle: "program_type", note: program_status_column_note(event) } ] if event.cost_cents.to_i > 0 columns << { key: "payment", label: "Payment", kind: :payment, sortable: true, align: "center", toggle: "payment" } diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index ea62970ce9..02a661347d 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -914,20 +914,16 @@ def sync_attendance_status_to_days! true end - # Program status(es) for THIS registration only: classify each organization - # linked to the registration as of the training date, excluding the registrant's - # own facilitator affiliation to that org so the status reflects whether the *org* - # was already a facilitator program when they joined. Using the actual training - # date (not the 1st of its month) means a facilitator affiliation started earlier - # that same month still counts toward the org's activity. Distinct, so one linked - # org shows one badge — unlike the registrant-wide rollup, this ignores - # affiliations to other organizations. + # Program status(es) for the organizations linked to THIS registration, as of the + # training date (see FacilitatorProgramStatus — the same verdict the dashboard, + # the org profile and the annual report show for this event). Returns the status + # objects so a badge can explain itself on hover. Deduped by verdict, so two + # linked orgs at the same status show one badge; unlike the registrant-wide + # rollup, affiliations to other organizations are ignored. def program_statuses - reference_date = event&.start_date&.to_date || Date.current - organizations.filter_map do |organization| - own = registrant.affiliations.find { |affiliation| affiliation.organization_id == organization.id && affiliation.facilitator? } - organization.facilitator_status_on(reference_date, excluding_affiliation_id: own&.id) - end.uniq + reference_date = event&.start_date&.to_date + organizations.map { |organization| organization.facilitator_program_status(as_of: reference_date) } + .uniq(&:status) end remote_searchable_by :registrant, diff --git a/app/models/organization.rb b/app/models/organization.rb index f48c782ead..835b1cb9cc 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -175,45 +175,22 @@ def affiliated_workshop_logs direct.or(legacy).distinct end - # Facilitator program statuses in display order — the values #facilitator_status - # and #facilitator_status_on return, and the attendees index filters on. - FACILITATOR_PROGRAM_STATUSES = %i[ new ongoing reinstated ].freeze - - # Classifies this organization as a facilitator program relative to a reference - # ("current") facilitator affiliation — typically a registrant's affiliation - # captured through the event registration form: - # :new — the reference is the organization's first facilitator - # affiliation (none started before it) - # :ongoing — the organization already had a facilitator affiliation that - # was still active when the reference one started - # :reinstated — the organization had facilitator affiliation(s) before, but - # they all ended before the reference one started (a lapse) - def facilitator_status(current_affiliation) - facilitator_status_on(current_affiliation.start_date, excluding_affiliation_id: current_affiliation.id) + # Facilitator program statuses in display order — the values + # FacilitatorProgramStatus returns, and the attendees index filters on. + FACILITATOR_PROGRAM_STATUSES = FacilitatorProgramStatus::STATUSES + + # This organization's program status (New / Ongoing / Reinstate) as of a date — + # the event's start date, or the start of the current year when there's no event + # in view. Returns a FacilitatorProgramStatus, which carries the verdict plus the + # anchor date, the affiliation month behind it and the facilitator history, so + # every display can explain itself. See ADR-0001 D4. + def facilitator_program_status(as_of: nil) + FacilitatorProgramStatus.for(self, as_of: as_of) end - # Classifies this organization relative to a reference DATE rather than a - # reference affiliation — used when a registrant has no facilitator affiliation - # yet (admins create those manually), so we ask "if they got one today, would - # this org be new/ongoing/reinstated?": - # :new — the org has no facilitator affiliation starting before the date - # :ongoing — an earlier facilitator affiliation is still active on the date - # :reinstated — earlier facilitator affiliation(s) existed but all ended first - def facilitator_status_on(reference_date, excluding_affiliation_id: nil) - reference_start = reference_date || Date.current - - # Filter the (often preloaded) affiliations in Ruby rather than firing a query - # per org — the event dashboard classifies every represented org this way. - earlier = affiliations.select do |affiliation| - affiliation.facilitator? && - affiliation.start_date && affiliation.start_date < reference_start && - affiliation.id != excluding_affiliation_id - end - - return :new if earlier.empty? - - active_overlap = earlier.any? { |affiliation| affiliation.end_date.nil? || affiliation.end_date >= reference_start } - active_overlap ? :ongoing : :reinstated + # The bare :new / :ongoing / :reinstated symbol, for counting and filtering. + def facilitator_status_on(reference_date = nil) + facilitator_program_status(as_of: reference_date).status end # Methods @@ -325,6 +302,17 @@ def all_additional_age_groups additional_age_groups - all_primary_age_groups end + # Cache version for the roll-up cells on list pages (#all_sectors and the age + # groups), which aggregate across affiliated people: retagging a person or + # adding an affiliation leaves the organizations row untouched, so `[organization]` + # alone caches those cells stale. Count + latest timestamp over the contributing + # taggings, read from the already-preloaded associations so it costs no queries. + def rollup_cache_version + records = affiliations.to_a + sectorable_items.to_a + categorizable_items.to_a + + affiliated_people.flat_map { |person| person.sectorable_items.to_a + person.categorizable_items.to_a } + [ records.size, records.filter_map(&:updated_at).max ] + end + remote_searchable_by :name # Returns the website as a clickable, scheme-qualified URL — prepending diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index 7107e18ff7..c199144525 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -293,9 +293,13 @@ def org_registrant_pairs .pluck(:organization_id, Arel.sql("event_registrations.registrant_id")) end + # Cross-event, so there is no event date to anchor on: each org reads as of the + # start of the current year (see FacilitatorProgramStatus), which is what the + # breakdown card's note and the index's own column say. Symbols here — these + # feed counts and drill-in buckets, not a badge. def program_status_by_organization @program_status_by_organization ||= organizations.to_h do |organization| - [ organization.id, organization.facilitator_status_on(Date.current) ] + [ organization.id, organization.facilitator_status_on ] end end diff --git a/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index 7360fe5853..55cc1c6bad 100644 --- a/app/services/attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -115,12 +115,13 @@ def affiliation_statuses_by_registrant end end - # Distinct program statuses (:new / :ongoing / :reinstated) of each person's - # affiliated organizations, as they stand today. Cross-event, so reported as of - # the current date rather than any single event. + # Distinct program statuses of each person's affiliated organizations. This index + # spans events, so there is no event date to anchor on — FacilitatorProgramStatus + # falls back to the start of the current year and flags itself `year_anchored?`, + # which the column's header caveat spells out. def program_statuses_by_registrant @program_statuses_by_registrant ||= organization_ids_by_registrant.transform_values do |organization_ids| - organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq + organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq(&:status) end end @@ -209,9 +210,11 @@ def affiliation_status(affiliation) affiliation.status_on end + # No event to anchor on here (the index spans them), so the status falls back to + # the start of the current year — see FacilitatorProgramStatus. def program_status_by_organization @program_status_by_organization ||= organizations.to_h do |organization| - [ organization.id, organization.facilitator_status_on(Date.current) ] + [ organization.id, organization.facilitator_program_status ] end end end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index c87326a8f9..e6bc572165 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -499,18 +499,17 @@ def organization_count # Every organization represented at this event (the same set counted by # organization_count) bucketed as :new, :ongoing, or :reinstated — so the three - # buckets always total organization_count. Each org is classified by its - # facilitator history, using the registrant's own (earliest) affiliation to it - # as the reference when present, otherwise the org's earliest facilitator - # affiliation; an org with no facilitator history at all counts as :new. + # buckets always total organization_count. Each org is classified as it stood on + # the event's start date; an org with no facilitator history before then is :new. def program_status_counts @program_status_counts ||= program_status_by_organization.each_with_object({ new: 0, ongoing: 0, reinstated: 0 }) do |(_organization_id, status), counts| - counts[status] += 1 + counts[status.status] += 1 end end - # Program status (:new / :ongoing / :reinstated) per represented organization, - # keyed by organization id — the same classification as program_status_counts. + # FacilitatorProgramStatus per represented organization, keyed by organization + # id — the classification behind program_status_counts, carrying the anchor date + # and reasoning each display hovers to explain. def program_status_by_organization @program_status_by_organization ||= organizations.to_h { |organization| [ organization.id, program_status_for(organization) ] } end @@ -523,7 +522,7 @@ def program_status_by_organization def program_status_registrant_ids @program_status_registrant_ids ||= program_status_by_organization .each_with_object({ new: [], ongoing: [], reinstated: [] }) do |(organization_id, status), map| - map[status].concat(organization_registrant_ids_by_org.fetch(organization_id, []).to_a) + map[status.status].concat(organization_registrant_ids_by_org.fetch(organization_id, []).to_a) end .transform_values(&:uniq) end @@ -532,7 +531,7 @@ def program_status_registrant_ids # Person id — for the registrant roster's program-status column. def program_statuses_by_registrant @program_statuses_by_registrant ||= organization_ids_by_registrant.transform_values do |organization_ids| - organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq + organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq(&:status) end end @@ -1017,37 +1016,15 @@ def registrant_ids_by_status end end - # Facilitator status for one represented organization, used by the - # program-status breakdown. Prefers a registrant's own active affiliation to - # the org as the reference point, falling back to the org's earliest - # facilitator affiliation, and treating an org with no facilitator history as - # new. + # Facilitator status for one represented organization, as the org stood at the + # time of the event (#reference_date) — the shared rule, so this breakdown, the + # onboarding matrix, the org profile chips and the annual report all say the + # same thing about this org at this event (see FacilitatorProgramStatus). def program_status_for(organization) - reference = registrant_affiliations_by_org[organization.id] - &.select(&:facilitator?) - &.min_by { |affiliation| affiliation.start_date || reference_date } - if reference - return organization.facilitator_status_on(reference.start_date || reference_date, - excluding_affiliation_id: reference.id) - end - - # The registrant has no facilitator affiliation to this org as of the event - # (admins create those manually after the fact). Classify the org as it stood - # at the time of the event rather than today, so an org that already had an - # active facilitator reads as :ongoing and a lapsed one as :reinstated — and - # the breakdown doesn't drift as affiliations change afterward. - organization.facilitator_status_on(reference_date) - end - - # This event's active registrants' affiliations that overlapped the event date, - # grouped by organization id — the reference points for the program-status - # breakdown. Anchored to the event (#reference_date) rather than "now" so the - # breakdown reflects the programs as they stood at the time of the event. - def registrant_affiliations_by_org - @registrant_affiliations_by_org ||= Affiliation.active_on(reference_date) - .where(person_id: registrant_ids) - .includes(:organization) - .group_by(&:organization_id) + # The event's own start date, not #reference_date's today-fallback: an undated + # event has no anchor, and FacilitatorProgramStatus's year fallback is what the + # annual report uses for the same event — the two must not diverge. + organization.facilitator_program_status(as_of: event.start_date&.to_date) end # The fixed point in time the organization breakdown is reported as of: the diff --git a/app/services/event_program_status_report.rb b/app/services/event_program_status_report.rb new file mode 100644 index 0000000000..20ce93fd02 --- /dev/null +++ b/app/services/event_program_status_report.rb @@ -0,0 +1,191 @@ +# Program-status report: how many organizations were New / Ongoing / Reinstated at +# each facilitator training, grouped by calendar year — the figures behind annual +# reporting. The sibling of EventRevenueReport / EventParticipationReport / +# EventScholarshipReport: same year-grouped shape, counting organizations. +# +# Every verdict comes from FacilitatorProgramStatus as of the training's own start +# date (ADR-0001 D4), so a row here says exactly what that event's dashboard, its +# onboarding matrix and the org's profile chip say. +# +# TWO WAYS TO ADD THEM UP, and they answer different questions: +# +# * Org-events (the row and year totals) — one count per organization PER +# training. An org that attended three trainings in a year counts three times. +# This is the "how many program starts did each training represent" figure. +# * Distinct organizations (#distinct_status_counts) — each organization counted +# once for the period, classified at the EARLIEST training it appeared at. This +# is the "how many distinct programs did we touch this year, and what were they +# when we first saw them" figure. +# +# Give it a collection of (decorated) facilitator-training events. +class EventProgramStatusReport + STATUSES = FacilitatorProgramStatus::STATUSES + + # One training's column: the organizations represented at it, each with its + # status as of that training's start date, keyed by organization id. + Column = Struct.new(:event, :statuses, keyword_init: true) do + def new_count = count_of(:new) + def ongoing_count = count_of(:ongoing) + def reinstated_count = count_of(:reinstated) + def organization_count = statuses.size + + def label = event.compact_label + def date_label = event.start_date? ? event.short_date_range : nil + def year = event.start_date&.year + def anchor_date = event.start_date&.to_date + + def count_of(status) = statuses.count { |_id, program_status| program_status.status == status } + end + + # The additive figures — summed across a year's columns for its totals, and + # across every column for the all-time total. + SUMMABLE = %i[ new_count ongoing_count reinstated_count organization_count ].freeze + + module Aggregates + SUMMABLE.each do |attribute| + define_method(attribute) { columns.sum(&attribute) } + end + + # Each organization counted ONCE for the period, classified at the earliest + # training it appeared at, keyed by status. Reconciles against the summed row + # above: distinct_organization_count <= organization_count, the difference + # being orgs that attended more than one training in the period. + def distinct_status_counts + @distinct_status_counts ||= first_status_by_organization + .values + .each_with_object(STATUSES.index_with(0)) { |status, counts| counts[status.status] += 1 } + end + + def distinct_organization_count = first_status_by_organization.size + def distinct_new_count = distinct_status_counts[:new] + def distinct_ongoing_count = distinct_status_counts[:ongoing] + def distinct_reinstated_count = distinct_status_counts[:reinstated] + + # True when at least one organization appears at more than one training in the + # period, i.e. the two ways of adding up disagree — which is when the view + # needs to say so. + def repeat_organizations? = organization_count != distinct_organization_count + + private + + def first_status_by_organization + @first_status_by_organization ||= chronological_columns.each_with_object({}) do |column, statuses| + column.statuses.each { |organization_id, status| statuses[organization_id] ||= status } + end + end + + def chronological_columns + columns.sort_by { |column| column.event.start_date || Time.zone.at(0) } + end + end + + # One calendar year of trainings, with its columns and totals. + YearGroup = Struct.new(:year, :columns, :in_progress, keyword_init: true) do + include Aggregates + end + + include Aggregates + include ReportPeriods + + def initialize(events, current_year: Date.current.year, featured_year: nil) + @events = events.to_a + @current_year = current_year + # nil means no specific year is featured (all-time): the headline aggregates + # every training rather than collapsing to the current year. + @featured_year_value = featured_year + end + + # One column per training, each carrying its organizations' statuses. Loads the + # org links for every training at once and the orgs with their affiliations at + # once, then classifies in memory — a fixed number of queries however many + # trainings are in scope. + def columns + @columns ||= begin + links = organization_ids_by_event + organizations = Organization.where(id: links.values.flatten.uniq).includes(:affiliations).index_by(&:id) + @events.map do |event| + statuses = (links[event.id] || []).to_h do |organization_id| + [ organization_id, organizations.fetch(organization_id).facilitator_program_status(as_of: event.start_date&.to_date) ] + end + Column.new(event: event, statuses: statuses) + end + end + end + + def any? = columns.any? + + # Calendar-year groups, newest first. Trainings without a start date fall under + # a nil year that sorts last; each year's columns read chronologically. + def years + @years ||= columns + .group_by(&:year) + .map { |year, year_columns| YearGroup.new(year: year, columns: sorted(year_columns), in_progress: year == @current_year) } + .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } + end + + # The group whose figures lead the KPI strip: the filtered/navigated-from year, + # falling back to the most recent year present. When no year is featured + # (all-time), an aggregate of every training so the headline isn't year-scoped. + def featured_year + return all_trainings_group if @featured_year_value.nil? + years_by_value[@featured_year_value] || years.first + end + + # A single group spanning every training, under a nil year so the KPI strip + # reads "All trainings". Used as the all-time headline. + def all_trainings_group + @all_trainings_group ||= YearGroup.new(year: nil, columns: columns, in_progress: false) + end + + # The most recent year-group strictly older than the featured one, for a + # year-over-year delta. Nil when there's nothing older to compare against. + def prior_year + return nil unless featured_year&.year + years.find { |group| group.year && group.year < featured_year.year } + end + + # Stacked-column series by year, oldest to newest — org-events per status, for + # the reports hub card's mini chart. + def chart_series + ascending = years.reject { |group| group.year.nil? }.reverse + { + "New" => :new_count, + "Ongoing" => :ongoing_count, + "Reinstated" => :reinstated_count + }.map do |name, attribute| + { name: name, data: ascending.map { |group| [ group.year.to_s, group.public_send(attribute) ] } } + end + end + + private + + # Organization ids represented at each training, keyed by event id. "Represented" + # is the same population the event dashboard counts: organizations linked to an + # active registration. + def organization_ids_by_event + event_ids = @events.map(&:id) + return {} if event_ids.empty? + + EventRegistrationOrganization + .joins(:event_registration) + .merge(EventRegistration.active) + .where(event_registrations: { event_id: event_ids }) + .pluck(Arel.sql("event_registrations.event_id"), :organization_id) + .group_by(&:first) + .transform_values { |rows| rows.map(&:last).uniq } + end + + # A zeroed year group for a period with no trainings, so the summary card + # renders 0 rather than blank. + def empty_year_group(year) + YearGroup.new(year: year, columns: [], in_progress: false) + end + + def years_by_value + @years_by_value ||= years.index_by(&:year) + end + + def sorted(year_columns) + year_columns.sort_by { |column| column.event.start_date || Time.zone.at(0) } + end +end diff --git a/app/services/facilitator_program_status.rb b/app/services/facilitator_program_status.rb new file mode 100644 index 0000000000..382e1a1e78 --- /dev/null +++ b/app/services/facilitator_program_status.rb @@ -0,0 +1,123 @@ +# The single rule for "was this organization a New / Ongoing / Reinstate art +# program on a given date?" Every surface that shows that word — the org profile +# and edit chips, the onboarding matrix, the event dashboard breakdown, the +# registrant rosters, the annual program-status report — goes through here, so +# they can't disagree (see ADR-0001 D4/D5). +# +# The rule, judged purely on the org's Facilitator affiliations (exactly +# "Facilitator", trimmed and case-sensitive) as of an anchor date: +# +# * :new — no facilitator affiliation STARTED BEFORE the anchor. Strictly +# before: an affiliation starting ON the anchor is the one the +# event itself minted (AffiliationServices::CreateFromRegistration +# dates it to the training date), so a first-time org still reads +# New at its own first training. +# * :ongoing — an earlier facilitator affiliation is still active on the anchor +# (no end date, or it ends on/after it). +# * :reinstated — earlier facilitator affiliation(s) existed but all had ended +# before the anchor — a lapse, now returning. +# +# No affiliation is ever excluded. The question is per-EVENT ("at this event, was +# the org new/ongoing/reinstate?"), not per-registrant. +# +# ANCHOR: the event's start date. With no event in view (a cross-event roster), +# pass nothing and the anchor falls back to January 1 of the current year, so the +# figure reads as "where this program stands this reporting year"; `year_anchored?` +# is true then, for the caveat those views show. +class FacilitatorProgramStatus + STATUSES = %i[ new ongoing reinstated ].freeze + + # Classify an organization. Reads the already-loaded affiliations when the + # caller preloaded them, so a page can classify many orgs without an N+1. + def self.for(organization, as_of: nil) + new(organization.affiliations, as_of: as_of) + end + + attr_reader :as_of + + def initialize(affiliations, as_of: nil) + @as_of = (as_of || Date.current.beginning_of_year).to_date + @year_anchored = as_of.nil? + @facilitators = affiliations.select { |affiliation| affiliation.facilitator? && affiliation.start_date } + end + + # True when no date was given and the anchor fell back to the start of the + # current year — the views that show one add a caveat saying so. + def year_anchored? = @year_anchored + + def status + @status ||= if earlier.empty? + :new + elsif active_on_anchor.any? + :ongoing + else + :reinstated + end + end + + def label = status.to_s.titleize + + # The month the program was (or last was) active, which is what makes the + # status what it is: for :ongoing the most recent start still running on the + # anchor, for :reinstated the most recent start of the lapsed history. Nil for + # :new — there is nothing before the anchor. + def active_since + @active_since ||= (active_on_anchor.presence || earlier).filter_map(&:start_date).max + end + + # When a :reinstated program's history ran out — the latest end date among the + # earlier affiliations. Nil for the other statuses. + def lapsed_on + return nil unless status == :reinstated + + @lapsed_on ||= earlier.filter_map(&:end_date).max + end + + # The whole facilitator history as merged month-precision periods (e.g. + # "Aug 2015 – Jun 2018, Feb 2024") — "the relevant years" behind the verdict. + def periods_label + @periods_label ||= AffiliationPeriods.label(@facilitators, today: as_of, precision: :month) + end + + # Plain-language hover text: what the verdict is, what date it was judged on, + # what made it that, and the facilitator history behind it. One string so every + # display site explains the figure the same way. + def explanation + [ anchor_sentence, reason_sentence, periods_sentence ].compact.join(" ") + end + + private + + def anchor_sentence + anchored = year_anchored? ? "start of #{as_of.year} — no event in view" : "event start date" + "#{label} as of #{as_of.strftime('%b %-d, %Y')} (#{anchored})." + end + + def reason_sentence + case status + when :new then "No facilitator affiliation started before this date." + when :ongoing then "Active facilitator affiliation since #{month(active_since)}." + when :reinstated + ended = lapsed_on ? " through #{month(lapsed_on)}" : "" + "Previously active from #{month(active_since)}#{ended}, with none active on this date." + end + end + + def periods_sentence + return nil if periods_label.blank? + + "Facilitator periods: #{periods_label}." + end + + def month(date) = date&.strftime("%b %Y") + + # Only affiliations that began before the anchor can say anything about what the + # org was when it arrived. + def earlier + @earlier ||= @facilitators.select { |affiliation| affiliation.start_date < as_of } + end + + def active_on_anchor + @active_on_anchor ||= earlier.select { |affiliation| affiliation.end_date.nil? || affiliation.end_date >= as_of } + end +end diff --git a/app/views/events/_breakdown_card.html.erb b/app/views/events/_breakdown_card.html.erb index 3fa581c30b..8cc3a60e83 100644 --- a/app/views/events/_breakdown_card.html.erb +++ b/app/views/events/_breakdown_card.html.erb @@ -10,7 +10,10 @@ even when there's nothing to chart; omit to let the caller hide the card entirely. row_paths: optional hash of { label => path }. When a row's label has a - path, the whole row links to it (the filtered registrant list). %> + path, the whole row links to it (the filtered registrant list). + note: optional hover text on an info icon beside the title, for a + figure that needs its basis spelled out (e.g. the date a + program status was judged on). %> <% chart = local_assigns.fetch(:chart, nil) %> <% empty_message = local_assigns.fetch(:empty_message, nil) %> <%# map_color: optional base hex for the choropleth ramp (e.g. the addresses color). %> @@ -22,7 +25,12 @@ <% anchor = title.parameterize %>
-

<%= title %>

+

+ <%= title %> + <% if local_assigns[:note] %> + + <% end %> +

<%= data.size %>
diff --git a/app/views/events/_program_status_report.html.erb b/app/views/events/_program_status_report.html.erb new file mode 100644 index 0000000000..420ed33fb4 --- /dev/null +++ b/app/views/events/_program_status_report.html.erb @@ -0,0 +1,123 @@ +<%# Program-status report card: one row per facilitator training with the + organizations represented there split into New / Ongoing / Reinstated as of that + training's start date, a subtotal per year, and an all-time total. Below the + table, the distinct-organization view of the same period — each org counted + once, at the earliest training it appeared at — because the row totals count an + org once per training it attended. Pass `report:` (an EventProgramStatusReport) + and `all_time:`. Emerald-branded, matching the organizations domain colour. %> +<% years = report.years %> +<% multi_year = years.size > 1 %> +
+
+

+ + <%= all_time ? "All facilitator trainings" : "#{years.first.year || "Undated"} facilitator trainings" %> +

+ <%= pluralize(report.columns.size, "training") %> +
+ +
+
ProgramOrganization Designations Age group(s)
- <%= organization.decorate.program_status_badge(@program_statuses&.dig(organization.id)) || content_tag(:span, "—", class: "text-gray-300") %> - <% status_label = organization.published? ? nil : organization.organization_status&.name %> <%= organization_profile_button(organization, truncate_at: 30, subtitle: organization.organization_locality, label: status_label, data: { turbo_frame: "_top" }) %> diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index ea6a79d0d7..5371516af0 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -338,33 +338,6 @@ end end - describe ".program_statuses_by_id" do - it "buckets each org by facilitator history, keyed by id" do - new_org = create(:organization) - - ongoing_org = create(:organization) - create(:affiliation, organization: ongoing_org, person: create(:person), title: "Facilitator") - - reinstate_org = create(:organization) - create(:affiliation, organization: reinstate_org, person: create(:person), title: "Facilitator", end_date: 1.year.ago.to_date) - - ids = [ new_org.id, ongoing_org.id, reinstate_org.id ] - - expect(Organization.program_statuses_by_id(ids)).to eq( - new_org.id => :new, - ongoing_org.id => :ongoing, - reinstate_org.id => :reinstated - ) - end - - it "treats a non-facilitator affiliation as New" do - org = create(:organization) - create(:affiliation, organization: org, person: create(:person), title: "Member") - - expect(Organization.program_statuses_by_id([ org.id ])).to eq(org.id => :new) - end - end - describe ".awbw" do it "finds the org named by ORGANIZATION_NAME" do awbw = create(:organization, name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index b7177699c2..74f3ab776d 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -38,24 +38,6 @@ expect(response).to be_successful end - it "shows single-letter program status badges per organization" do - create(:organization, name: "Brand New Org", organization_status: organization_status) - - ongoing_org = create(:organization, name: "Ongoing Org", organization_status: organization_status) - create(:affiliation, organization: ongoing_org, person: create(:person), title: "Facilitator") - - reinstate_org = create(:organization, name: "Reinstate Org", organization_status: organization_status) - create(:affiliation, organization: reinstate_org, person: create(:person), title: "Facilitator", end_date: 1.year.ago.to_date) - - get organizations_url, headers: { "Turbo-Frame" => "organizations_results" } - - expect(response).to be_successful - page = Capybara.string(response.body) - expect(page).to have_css("span[title='New']", text: "N") - expect(page).to have_css("span[title='Ongoing']", text: "O") - expect(page).to have_css("span[title='Reinstated']", text: "R") - end - it "renders the results frame with deduped age groups from affiliated people" do organization = Organization.create!(valid_attributes) age_type = create(:category_type, name: "AgeRange", published: true) @@ -192,17 +174,6 @@ get edit_organization_url(organization) expect(response.body).to include("Monthly reports") end - - it "shows the program status in the affiliations section" do - organization = Organization.create!(valid_attributes) - create(:affiliation, organization: organization, person: create(:person), title: "Facilitator") - - get edit_organization_url(organization) - - page = Capybara.string(response.body) - expect(page).to have_content("Program status") - expect(page).to have_css("span[title='Ongoing']", text: "O") - end end describe "POST /create" do diff --git a/spec/views/organizations/index.html.erb_spec.rb b/spec/views/organizations/index.html.erb_spec.rb index c31c7e7957..3ab61a76ba 100644 --- a/spec/views/organizations/index.html.erb_spec.rb +++ b/spec/views/organizations/index.html.erb_spec.rb @@ -16,7 +16,6 @@ assign(:organization_statuses, [ organization_status1, organization_status2, organization_status3 ]) assign(:affiliated_since, {}) assign(:active_people_counts, {}) - assign(:program_statuses, {}) assign(:active_people_count, 0) assign(:organizations_count, 2) allow(view).to receive(:current_user).and_return(user) From 5084684018a4dfd1cc153925332b4319096d1bad Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 15:34:46 -0400 Subject: [PATCH 02/40] Show affiliated-since as merged periods; simplify org status to 3 values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiliated-since was already affiliation-derived, but showed a single Mon YYYY – Mon YYYY range. Admins need the real shape of an org's history — including gaps — and a status vocabulary that isn't event-specific. - AffiliationPeriods service merges affiliation intervals into periods and formats them as year-based ranges: a lone ongoing period shows "Mon YYYY" (this year) or its start year; multi-period lists are year-only, e.g. "2010-2012, 2026". Falls back to the org's start_date, then blank. - Applied on the org show page, index column, and edit form; the edit form's live preview (affiliation_dates_controller.js) mirrors the same formatting. - Simplify OrganizationStatus to Active / Formerly active / Unknown. Data migration remaps existing statuses (Reinstate->Active, Inactive/Suspended-> Formerly active, Pending->Unknown) then drops the retired records; the affiliation status-sync callback and seeds follow the new names. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + app/controllers/organizations_controller.rb | 7 +- app/decorators/organization_decorator.rb | 8 + .../affiliation_dates_controller.js | 66 ++-- app/models/affiliation.rb | 20 +- app/models/organization_status.rb | 2 +- app/services/affiliation_periods.rb | 83 +++++ app/views/organizations/_form.html.erb | 6 +- .../organizations_results.html.erb | 5 +- app/views/organizations/show.html.erb | 9 +- ...15191749_simplify_organization_statuses.rb | 31 ++ db/seeds/dev/organizations.rb | 15 +- .../decorators/organization_decorator_spec.rb | 22 ++ spec/models/affiliation_spec.rb | 286 +++++++++++++++++- spec/services/affiliation_periods_spec.rb | 72 +++++ .../index.html.erb_spec.rb | 2 +- .../views/organizations/edit.html.erb_spec.rb | 2 +- .../organizations/index.html.erb_spec.rb | 6 +- 18 files changed, 581 insertions(+), 62 deletions(-) create mode 100644 app/services/affiliation_periods.rb create mode 100644 db/migrate/20260715191749_simplify_organization_statuses.rb create mode 100644 spec/services/affiliation_periods_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 89340ea2a7..5b1fbdc67e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,7 @@ action, or `authorize! :workshop, to: :summary?`). ### Business Logic +- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods and formats them as year-based ranges for the "Affiliated since" display (e.g. "2010-2012, 2026"); mirrored client-side in `affiliation_dates_controller.js` - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 465e15814e..b37b228bd7 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,7 +8,7 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :windows_type, :organization_status, :sectors, :addresses, + :windows_type, :organization_status, :sectors, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, logo_attachment: :blob )) @@ -17,9 +17,8 @@ def index @active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id") @organizations = filtered.paginate(page: params[:page], per_page: per_page) org_ids = @organizations.map(&:id) - @affiliated_since = Affiliation.where(organization_id: org_ids) - .group(:organization_id) - .minimum(:start_date) + # Merged-period "Affiliated since" label per org, from the preloaded affiliations. + @affiliated_since_display = @organizations.to_h { |org| [ org.id, org.decorate.affiliated_since_display ] } @active_people_counts = Affiliation.active .where(organization_id: org_ids) .group(:organization_id) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index b7c513a172..ad37edbd5a 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -90,6 +90,14 @@ def affiliation_end_date affiliations.maximum(:end_date) end + # "Affiliated since" display: affiliation history as merged year-based periods + # (see AffiliationPeriods), falling back to the org's own start_date, then to a + # blank string. Pass a preloaded affiliations collection on list pages to avoid + # an N+1. + def affiliated_since_display(affiliations = object.affiliations) + AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" + end + def facilitator_since_date @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 8bf2371abe..78558918ad 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -38,25 +38,17 @@ export default class extends Controller { recalculate() { const affiliations = this.getVisibleAffiliations() - - // Affiliated since = min start_date of all affiliations - const allStartDates = affiliations.map(a => a.startDate).filter(Boolean) - const affiliatedSince = allStartDates.length - ? new Date(Math.min(...allStartDates.map(d => new Date(d)))) - : null - - // Affiliated end = only if ALL affiliations are inactive (end_date in the past) const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - const allInactive = affiliations.length > 0 && - affiliations.every(a => a.endDate && new Date(a.endDate) < today) - const affiliatedEnd = allInactive - ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) - : null - // Facilitator since/end — same logic filtered by title. Mirror - // Affiliation#facilitator?: an exact, case-sensitive match on "Facilitator" - // (trimmed), so the live figure matches what the server will render. + // Affiliated since = merged year-based periods (mirrors AffiliationPeriods). + if (this.hasAffiliatedSinceTarget) { + this.affiliatedSinceTarget.textContent = this.affiliatedSinceLabel(affiliations, today) || "—" + } + + // Facilitations/program since — unchanged single-range display, filtered by + // title. Mirror Affiliation#facilitator?: an exact, case-sensitive match on + // "Facilitator" (trimmed), so the live figure matches the server render. const facilitatorAffiliations = affiliations.filter(a => a.title.trim() === "Facilitator" ) @@ -70,14 +62,50 @@ export default class extends Controller { ? new Date(Math.max(...facilitatorAffiliations.map(a => new Date(a.endDate)))) : null - if (this.hasAffiliatedSinceTarget) { - this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) - } if (this.hasFacilitatorSinceTarget) { this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) } } + // Merge affiliation intervals into periods and format them as year-based ranges + // — the client-side mirror of app/services/affiliation_periods.rb. + affiliatedSinceLabel(affiliations, today) { + const intervals = affiliations + .filter(a => a.startDate) + .map(a => ({ start: new Date(a.startDate), end: a.endDate ? new Date(a.endDate) : null })) + .sort((a, b) => a.start - b.start) + if (!intervals.length) return "" + + const periods = [] + for (const iv of intervals) { + const last = periods[periods.length - 1] + if (last && (last.end === null || iv.start <= last.end)) { + last.end = last.end === null || iv.end === null ? null : new Date(Math.max(last.end, iv.end)) + } else { + periods.push({ start: iv.start, end: iv.end }) + } + } + + const ongoing = end => end === null || end >= today + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + // A single ongoing period is a fresh org — worth the month's precision. + if (periods.length === 1 && ongoing(periods[0].end)) { + const s = periods[0].start + return s.getUTCFullYear() === today.getUTCFullYear() + ? `${months[s.getUTCMonth()]} ${s.getUTCFullYear()}` + : `${s.getUTCFullYear()}` + } + + return periods + .map(p => { + const startYear = p.start.getUTCFullYear() + if (ongoing(p.end) || startYear === p.end.getUTCFullYear()) return `${startYear}` + return `${startYear}-${p.end.getUTCFullYear()}` + }) + .join(", ") + } + getVisibleAffiliations() { if (!this.hasAffiliationsContainerTarget) return [] const fields = this.affiliationsContainerTarget.querySelectorAll(".nested-fields") diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 6f780a6218..fac44cfe3c 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -198,29 +198,29 @@ def sync_organization_status_with_affiliations end def deactivate_organization_if_no_active_people - inactive_status = OrganizationStatus.find_by(name: "Inactive") - return unless inactive_status - return if organization.organization_status_id == inactive_status.id + formerly_active_status = OrganizationStatus.find_by(name: "Formerly active") + return unless formerly_active_status + return if organization.organization_status_id == formerly_active_status.id - organization.update_column(:organization_status_id, inactive_status.id) + organization.update_column(:organization_status_id, formerly_active_status.id) Ahoy::Tracker.new(user: Current.user).track( "autochange.organization", resource_type: "Organization", resource_id: organization.id, resource_title: organization.name, - change: "status_set_to_inactive", + change: "status_set_to_formerly_active", reason: "no_active_affiliations" ) end - # Only flip back from "Inactive" — the status the deactivation callback sets. - # Leave Pending/Reinstate/Unknown (and Active) untouched. + # Only flip back from "Formerly active" — the status the deactivation callback + # sets. Leave Unknown (and Active) untouched. def reactivate_organization_if_inactive - inactive_status = OrganizationStatus.find_by(name: "Inactive") + formerly_active_status = OrganizationStatus.find_by(name: "Formerly active") active_status = OrganizationStatus.find_by(name: "Active") - return unless inactive_status && active_status - return unless organization.organization_status_id == inactive_status.id + return unless formerly_active_status && active_status + return unless organization.organization_status_id == formerly_active_status.id organization.update_column(:organization_status_id, active_status.id) diff --git a/app/models/organization_status.rb b/app/models/organization_status.rb index fde753171b..8cce7d9d8f 100644 --- a/app/models/organization_status.rb +++ b/app/models/organization_status.rb @@ -1,5 +1,5 @@ class OrganizationStatus < ApplicationRecord - ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ] + ORGANIZATION_STATUSES = [ "Active", "Formerly active", "Unknown" ] has_many :organizations diff --git a/app/services/affiliation_periods.rb b/app/services/affiliation_periods.rb new file mode 100644 index 0000000000..59224738db --- /dev/null +++ b/app/services/affiliation_periods.rb @@ -0,0 +1,83 @@ +# Formats an organization's affiliation history as merged, year-based periods for +# the "Affiliated since" display. Each affiliation is a [start_date, end_date] +# interval (a nil end = ongoing); overlapping or touching intervals collapse into +# one period, and a real gap starts a new one. +# +# Formatting: +# * A lone ongoing period (a fresh org) shows "Mon YYYY" when it began this year +# (e.g. "Jul 2026"), otherwise just its start year — no end. +# * In any multi-period list, every period is year-only for consistency: an +# ongoing period is its start year, a closed period is "YYYY" (same-year) or +# "YYYY-YYYY". Periods join chronologically with ", " (e.g. "2010-2012, 2026"). +# +# Returns nil when no affiliation carries a start date, so callers can fall back +# to the organization's own start_date. +class AffiliationPeriods + def self.label(affiliations, today: Date.current) + new(affiliations, today: today).label + end + + def initialize(affiliations, today: Date.current) + @today = today + @intervals = affiliations + .filter_map { |affiliation| interval_for(affiliation) } + .sort_by { |start, _finish| start } + end + + def label + return nil if @intervals.empty? + + periods = merged + # A single ongoing period is a fresh org — worth the month's precision. + if periods.one? && ongoing?(periods.first[1]) + return year_or_month(periods.first[0]) + end + + periods.map { |period| format_period(period) }.join(", ") + end + + private + + # Affiliations without a start date can't be placed on the timeline. + def interval_for(affiliation) + return nil if affiliation.start_date.blank? + + [ affiliation.start_date.to_date, affiliation.end_date&.to_date ] + end + + def merged + @intervals.each_with_object([]) do |(start, finish), periods| + last = periods.last + if last && overlaps?(last, start) + last[1] = later_end(last[1], finish) + else + periods << [ start, finish ] + end + end + end + + # A nil end is ongoing and swallows every later interval. + def overlaps?(period, next_start) + period[1].nil? || next_start <= period[1] + end + + def later_end(current, other) + return nil if current.nil? || other.nil? + + [ current, other ].max + end + + def ongoing?(finish) + finish.nil? || finish >= @today + end + + def format_period((start, finish)) + return start.year.to_s if ongoing?(finish) || start.year == finish.year + + "#{start.year}-#{finish.year}" + end + + def year_or_month(date) + date.year == @today.year ? date.strftime("%b %Y") : date.year.to_s + end +end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index ead501d830..4ed73a09d4 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -160,7 +160,7 @@ <% end %> - <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %> + <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Formerly active" %> <% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %> <% show_status_select = allowed_to?(:manage?, Organization) && (params[:admin] || (f.object.persisted? && !status_matches_affiliations)) %> @@ -278,7 +278,7 @@
- <% if org_aff_ended || (f.object.end_date.present? && !has_affiliations) %><% end %><%= (org_earliest_aff || f.object.start_date)&.strftime('%b %Y') || "—" %><%= " – #{org_end_date.strftime('%b %Y')}" if org_end_date.present? %> + <%= org_decorated.affiliated_since_display.presence || "—" %> <% if has_affiliations && org_earliest_aff.nil? %> <% elsif f.object.start_date.present? && org_earliest_aff.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index eba3f4c61e..7cb49c0582 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -20,7 +20,7 @@
<% status_label = organization.published? ? nil : organization.organization_status&.name %> @@ -50,8 +50,7 @@ - <% affiliated_since = @affiliated_since[organization.id] || organization.start_date %> - <%= affiliated_since&.strftime('%b %Y') %> + <%= @affiliated_since_display[organization.id] %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 59ae7b6af9..a83868aebc 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -47,14 +47,11 @@ <%= [address.locality, [address.city, address.state].compact_blank.join(", "), address.district].compact_blank.join(" · ") %>

<% end %> - <% org_earliest_affiliation = @organization.affiliations.minimum(:start_date) %> - <% org_affiliated_since = org_earliest_affiliation || @organization.start_date %> - <% org_show_aff_ended = @organization.affiliations.any? && !@organization.affiliations.active.exists? %> - <% org_show_end_date = org_show_aff_ended ? @organization.affiliations.maximum(:end_date) : @organization.end_date %> - <% if org_affiliated_since.present? %> + <% affiliated_since = @organization.decorate.affiliated_since_display %> + <% if affiliated_since.present? %>

Affiliated since - <%= org_affiliated_since.strftime("%b %Y") %><%= " – #{org_show_end_date.strftime('%b %Y')}" if org_show_end_date.present? %> + <%= affiliated_since %>

<% end %> diff --git a/db/migrate/20260715191749_simplify_organization_statuses.rb b/db/migrate/20260715191749_simplify_organization_statuses.rb new file mode 100644 index 0000000000..0d5abac972 --- /dev/null +++ b/db/migrate/20260715191749_simplify_organization_statuses.rb @@ -0,0 +1,31 @@ +class SimplifyOrganizationStatuses < ActiveRecord::Migration[8.1] + # Retiring the six-value status set down to Active / Formerly active / Unknown. + # Each retired status folds into one of the survivors; Reinstate counts as Active. + RETIRED_TO_REPLACEMENT = { + "Reinstate" => "Active", + "Inactive" => "Formerly active", + "Suspended" => "Formerly active", + "Pending" => "Unknown" + }.freeze + + def up + %w[Active Unknown].each { |name| OrganizationStatus.find_or_create_by!(name: name) } + OrganizationStatus.find_or_create_by!(name: "Formerly active") + + RETIRED_TO_REPLACEMENT.each do |old_name, new_name| + old_status = OrganizationStatus.find_by(name: old_name) + next unless old_status + + new_status = OrganizationStatus.find_by!(name: new_name) + # Bulk remap intentionally bypasses callbacks/validations — repointing a FK. + Organization.where(organization_status_id: old_status.id).update_all(organization_status_id: new_status.id) + old_status.destroy! + end + end + + def down + # Best effort: recreate the retired status records so they're selectable again. + # The original per-organization mappings can't be reconstructed. + RETIRED_TO_REPLACEMENT.each_key { |name| OrganizationStatus.find_or_create_by!(name: name) } + end +end diff --git a/db/seeds/dev/organizations.rb b/db/seeds/dev/organizations.rb index 2860b9b793..cc97d273a7 100644 --- a/db/seeds/dev/organizations.rb +++ b/db/seeds/dev/organizations.rb @@ -3,29 +3,28 @@ puts "Creating Organizations…" active_status = OrganizationStatus.find_by!(name: "Active") -inactive_status = OrganizationStatus.find_by!(name: "Inactive") -pending_status = OrganizationStatus.find_by!(name: "Pending") -suspended_status = OrganizationStatus.find_by!(name: "Suspended") +formerly_active_status = OrganizationStatus.find_by!(name: "Formerly active") +unknown_status = OrganizationStatus.find_by!(name: "Unknown") adult_wt = WindowsType.find_by!(short_name: "Adult") children_wt = WindowsType.find_by!(short_name: "Children") combined_wt = WindowsType.find_by!(short_name: "Combined") [ - { name: "1736 Family Crisis Center", organization_status: inactive_status, windows_type: adult_wt }, + { name: "1736 Family Crisis Center", organization_status: formerly_active_status, windows_type: adult_wt }, { name: "Angel Step Inn", organization_status: active_status, windows_type: adult_wt }, { name: "YWCA of San Diego - Becky's House", organization_status: active_status, windows_type: children_wt }, { name: "Good Shepherd Shelter", organization_status: active_status, windows_type: adult_wt }, { name: "One Safe Place", organization_status: active_status, windows_type: adult_wt }, { name: "Haven Hills", organization_status: active_status, windows_type: children_wt }, { name: "Survivor's Art Circle", organization_status: active_status, windows_type: children_wt }, - { name: "YWCA Spokane", organization_status: inactive_status, windows_type: adult_wt }, - { name: "Center for Battered Women", organization_status: pending_status, windows_type: children_wt }, + { name: "YWCA Spokane", organization_status: formerly_active_status, windows_type: adult_wt }, + { name: "Center for Battered Women", organization_status: unknown_status, windows_type: children_wt }, { name: "Asian Women Shelter", organization_status: active_status, windows_type: adult_wt }, { name: "Deaf Hope", organization_status: active_status, windows_type: children_wt }, { name: "YWCA of Monterey County", organization_status: active_status, windows_type: adult_wt }, - { name: "Joyful Heart Foundation", organization_status: suspended_status, windows_type: adult_wt }, - { name: "Domestic Violence Center of Santa Clarita Valley", organization_status: pending_status, windows_type: adult_wt }, + { name: "Joyful Heart Foundation", organization_status: formerly_active_status, windows_type: adult_wt }, + { name: "Domestic Violence Center of Santa Clarita Valley", organization_status: unknown_status, windows_type: adult_wt }, { name: "Abused Women's Aid in Crisis", organization_status: active_status, windows_type: adult_wt }, { name: "Friends of the Family", organization_status: active_status, windows_type: adult_wt }, { name: "Haven House", organization_status: active_status, windows_type: adult_wt }, diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index dd1abf06b7..4e9d99bfb4 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -1,6 +1,28 @@ require "rails_helper" RSpec.describe OrganizationDecorator do + describe "#affiliated_since_display" do + let(:organization) { create(:organization) } + + it "is blank when there are no affiliations and no start date" do + organization.update_column(:start_date, nil) + expect(organization.decorate.affiliated_since_display).to eq("") + end + + it "falls back to the org start_date when there are no affiliations" do + organization.update_column(:start_date, Date.new(2015, 3, 1)) + expect(organization.decorate.affiliated_since_display).to eq("Mar 2015") + end + + it "shows merged affiliation periods" do + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2010, 1, 1), end_date: Date.new(2012, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2013, 1, 1), end_date: Date.new(2015, 6, 1)) + expect(organization.reload.decorate.affiliated_since_display).to eq("2010-2012, 2013-2015") + end + end + describe ".program_status_classes" do it "maps each status to its pill classes, accepting symbols or model strings" do expect(described_class.program_status_classes(:new)).to include("green") diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index 08c5531b95..81e976e6c2 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -23,6 +23,274 @@ end end + describe 'associations' do + it { should belong_to(:organization) } + it { should belong_to(:person) } + it { should belong_to(:organization_address).class_name("Address").optional } + end + + describe 'validations' do + subject do + build(:affiliation, organization: create(:organization), person: create(:person)) + end + it { should validate_presence_of(:organization_id) } + # it { should validate_presence_of(:person_id) } # we needed to not have this to support nested attrs + end + + describe '#organization_address' do + let(:organization) { create(:organization) } + let(:address) { create(:address, addressable: organization) } + + it 'is valid when the address belongs to the same organization' do + affiliation = build(:affiliation, organization: organization, organization_address: address) + expect(affiliation).to be_valid + end + + it 'is valid when no address is linked' do + affiliation = build(:affiliation, organization: organization, organization_address: nil) + expect(affiliation).to be_valid + end + + it 'is invalid when the address belongs to a different organization' do + other_address = create(:address, addressable: create(:organization)) + affiliation = build(:affiliation, organization: organization, organization_address: other_address) + expect(affiliation).not_to be_valid + expect(affiliation.errors[:organization_address_id]).to be_present + end + + it "is invalid when the address belongs to a person" do + person_address = create(:address, addressable: create(:person)) + affiliation = build(:affiliation, organization: organization, organization_address: person_address) + expect(affiliation).not_to be_valid + end + + it 'is nullified when its linked address is destroyed' do + affiliation = create(:affiliation, organization: organization, organization_address: address) + address.destroy + expect(affiliation.reload.organization_address_id).to be_nil + end + end + + describe '#active?' do + it 'is true when not inactive and has no end date' do + expect(build(:affiliation, inactive: false, end_date: nil).active?).to be true + end + + it 'is true when not inactive and the end date is in the future' do + expect(build(:affiliation, inactive: false, end_date: 1.month.from_now).active?).to be true + end + + it 'is false when flagged inactive' do + expect(build(:affiliation, inactive: true, end_date: nil).active?).to be false + end + + it 'is false when the end date has passed' do + expect(build(:affiliation, inactive: false, end_date: 1.day.ago).active?).to be false + end + end + + describe '.active' do + let!(:active_op) { create(:affiliation, inactive: false, end_date: nil) } + let!(:active_with_future_end) { create(:affiliation, inactive: false, end_date: 1.month.from_now) } + let!(:inactive_by_flag) { create(:affiliation, inactive: true, end_date: nil) } + let!(:inactive_by_end_date) { create(:affiliation, inactive: false, end_date: 1.day.ago) } + + it 'includes records with inactive: false and no end date' do + expect(described_class.active).to include(active_op) + end + + it 'includes records with inactive: false and future end date' do + expect(described_class.active).to include(active_with_future_end) + end + + it 'excludes records with inactive: true' do + expect(described_class.active).not_to include(inactive_by_flag) + end + + it 'excludes records with past end date' do + expect(described_class.active).not_to include(inactive_by_end_date) + end + + it 'qualifies end_date when joined with organizations (which also has end_date)' do + expect { + described_class.active.joins(:organization).to_a + }.not_to raise_error + end + end + + describe '#facilitator?' do + it 'is true for the exact title "Facilitator"' do + expect(build(:affiliation, title: "Facilitator").facilitator?).to be true + end + + it 'ignores surrounding whitespace' do + expect(build(:affiliation, title: " Facilitator ").facilitator?).to be true + end + + it 'is false for title variants like "Lead Facilitator"' do + expect(build(:affiliation, title: "Lead Facilitator").facilitator?).to be false + end + + it 'is case-sensitive' do + expect(build(:affiliation, title: "facilitator").facilitator?).to be false + expect(build(:affiliation, title: "FACILITATOR").facilitator?).to be false + end + + it 'is false when the title is blank' do + expect(build(:affiliation, title: nil).facilitator?).to be false + end + end + + describe '.facilitators' do + let!(:exact) { create(:affiliation, title: "Facilitator") } + let!(:whitespace) { create(:affiliation, title: " Facilitator ") } + let!(:variant) { create(:affiliation, title: "Lead Facilitator") } + let!(:lowercase) { create(:affiliation, title: "facilitator") } + + it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do + expect(described_class.facilitators).to contain_exactly(exact, whitespace) + end + end + + describe '#sync_organization_status_with_affiliations' do + let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") } + let!(:formerly_active_status) { OrganizationStatus.find_or_create_by!(name: "Formerly active") } + + it 'sets the organization to Formerly active when its last active affiliation goes inactive' do + org = create(:organization, organization_status: active_status) + affiliation = create(:affiliation, organization: org, inactive: false, end_date: nil) + + affiliation.update!(inactive: true) + + expect(org.reload.organization_status).to eq(formerly_active_status) + end + + it 'sets a Formerly active organization back to Active when it regains an active affiliation' do + org = create(:organization, organization_status: formerly_active_status) + + create(:affiliation, organization: org, inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(active_status) + end + + it 'ignores non-facilitator affiliations when deciding status' do + org = create(:organization, organization_status: active_status) + create(:affiliation, organization: org, title: "Volunteer", inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(formerly_active_status) + end + + it "leaves an Unknown organization untouched when it regains an active affiliation" do + status = OrganizationStatus.find_or_create_by!(name: "Unknown") + org = create(:organization, organization_status: status) + + create(:affiliation, organization: org, inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(status) + end + end + + describe '.active_on' do + let(:date) { Date.new(2024, 6, 1) } + let!(:spanning) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: Date.new(2025, 1, 1)) } + let!(:open_ended) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil) } + let!(:ended_before) { create(:affiliation, start_date: Date.new(2020, 1, 1), end_date: Date.new(2021, 1, 1)) } + let!(:starts_after) { create(:affiliation, start_date: Date.new(2025, 1, 1), end_date: nil) } + let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + + it 'includes affiliations whose span covers the date' do + expect(described_class.active_on(date)).to include(spanning, open_ended) + end + + it 'excludes affiliations that ended before the date' do + expect(described_class.active_on(date)).not_to include(ended_before) + end + + it 'excludes affiliations that start after the date' do + expect(described_class.active_on(date)).not_to include(starts_after) + end + + it 'includes affiliations with no dates on record' do + expect(described_class.active_on(date)).to include(no_dates) + end + + it 'ignores the cached inactive flag, judging purely by dates' do + flagged = create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil, inactive: true) + expect(described_class.active_on(date)).to include(flagged) + end + end + + describe 'status (#status_on and .with_status)' do + let!(:active_open) { create(:affiliation, start_date: Date.current.prev_year, end_date: nil) } + let!(:active_span) { create(:affiliation, start_date: Date.current.prev_year, end_date: Date.current.next_year) } + let!(:upcoming) { create(:affiliation, start_date: Date.current.next_year, end_date: nil) } + let!(:ended) { create(:affiliation, start_date: Date.current.prev_year(2), end_date: Date.current.prev_year) } + let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + + it 'exposes the taxonomy in display order' do + expect(Affiliation::STATUSES).to eq(%w[ Active Upcoming Inactive ]) + end + + it '#status_on classifies by flag and dates' do + expect(active_open.reload.status_on).to eq("Active") + expect(active_span.reload.status_on).to eq("Active") + expect(upcoming.reload.status_on).to eq("Upcoming") + expect(ended.reload.status_on).to eq("Inactive") + expect(no_dates.reload.status_on).to eq("Active") + end + + it '.with_status returns exactly the rows whose #status_on matches (SQL ↔ Ruby agree)' do + Affiliation::STATUSES.each do |status| + expected = Affiliation.all.select { |a| a.status_on == status }.map(&:id).sort + expect(Affiliation.with_status(status).ids.sort).to eq(expected), "mismatch for #{status}" + end + end + + it 'offers the combined filter option alongside the chip taxonomy' do + expect(Affiliation::FILTER_STATUSES) + .to eq([ "Active", "Upcoming", "Active & Upcoming", "Inactive" ]) + end + + it '.with_status("Active & Upcoming") returns exactly the Active and Upcoming rows' do + expected = Affiliation.all.select { |a| a.status_on.in?(%w[ Active Upcoming ]) }.map(&:id).sort + + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING).ids.sort).to eq(expected) + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).to include(active_open, active_span, upcoming, no_dates) + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).not_to include(ended) + end + + it '.with_status is empty for an unknown status' do + expect(Affiliation.with_status("bogus")).to be_empty + end + end + + describe '#set_inactive_from_dates' do + let(:op) { create(:affiliation, inactive: false, end_date: nil) } + + it 'sets inactive to true when end_date is set to a past date' do + op.update!(end_date: 1.day.ago) + expect(op.reload.inactive).to be true + end + + it 'sets inactive to false when end_date is set to a future date' do + op.update!(inactive: true, end_date: 1.day.ago) + op.update!(end_date: 1.month.from_now) + expect(op.reload.inactive).to be false + end + + it 'sets inactive to false when end_date is cleared' do + op.update!(end_date: 1.day.ago) + op.update!(end_date: nil) + expect(op.reload.inactive).to be false + end + + it 'does not change inactive when unrelated fields change' do + op.update!(inactive: true) + op.update!(title: "New Title") + expect(op.reload.inactive).to be true + end + end + describe "reassigning the organization" do let(:old_org) { create(:organization) } let(:new_org) { create(:organization) } @@ -46,14 +314,26 @@ expect(affiliation.reload.organization_address_id).to eq(new_address.id) end + end - it "clears the event registration link" do + describe "the registration that created the affiliation" do + it "drops the link when the affiliation is moved to a different organization" do registration = create(:event_registration) - affiliation = create(:affiliation, organization: old_org, event_registration: registration) + affiliation = create(:affiliation, event_registration: registration) + other_org = create(:organization) - affiliation.update!(organization: new_org) + affiliation.update!(organization: other_org) expect(affiliation.reload.event_registration_id).to be_nil end + + it "keeps the link when other attributes change" do + registration = create(:event_registration) + affiliation = create(:affiliation, event_registration: registration) + + affiliation.update!(title: "Lead Facilitator") + + expect(affiliation.reload.event_registration).to eq(registration) + end end end diff --git a/spec/services/affiliation_periods_spec.rb b/spec/services/affiliation_periods_spec.rb new file mode 100644 index 0000000000..db946425de --- /dev/null +++ b/spec/services/affiliation_periods_spec.rb @@ -0,0 +1,72 @@ +require "rails_helper" + +RSpec.describe AffiliationPeriods do + let(:today) { Date.new(2026, 7, 15) } + + # Lightweight stand-in for an affiliation — the service only reads dates. + Interval = Struct.new(:start_date, :end_date) + + def label(*intervals) + described_class.label(intervals, today: today) + end + + it "returns nil when there are no affiliations" do + expect(label).to be_nil + end + + it "returns nil when no affiliation carries a start date" do + expect(label(Interval.new(nil, nil))).to be_nil + end + + describe "an ongoing period" do + it "shows the month and year when it began this year" do + expect(label(Interval.new(Date.new(2026, 7, 1), nil))).to eq("Jul 2026") + end + + it "shows only the start year when it began in an earlier year" do + expect(label(Interval.new(Date.new(2024, 3, 1), nil))).to eq("2024") + end + + it "treats an end date today or later as ongoing" do + expect(label(Interval.new(Date.new(2024, 3, 1), today))).to eq("2024") + end + end + + describe "a closed past period" do + it "shows a single year when start and end fall in the same year" do + expect(label(Interval.new(Date.new(2010, 3, 1), Date.new(2010, 9, 1)))).to eq("2010") + end + + it "shows a year range across multiple years" do + expect(label(Interval.new(Date.new(2010, 3, 1), Date.new(2016, 9, 1)))).to eq("2010-2016") + end + end + + it "merges overlapping intervals into one period" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2011, 3, 1), Date.new(2012, 12, 1)) + )).to eq("2010-2012") + end + + it "keeps gapped intervals as separate periods" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2013, 1, 1), Date.new(2015, 6, 1)) + )).to eq("2010-2012, 2013-2015") + end + + it "shows a past period alongside a current ongoing one" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2026, 2, 1), nil) + )).to eq("2010-2012, 2026") + end + + it "orders periods chronologically regardless of input order" do + expect(label( + Interval.new(Date.new(2026, 2, 1), nil), + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)) + )).to eq("2010-2012, 2026") + end +end diff --git a/spec/views/organization_statuses/index.html.erb_spec.rb b/spec/views/organization_statuses/index.html.erb_spec.rb index a9865031ff..2dd247277e 100644 --- a/spec/views/organization_statuses/index.html.erb_spec.rb +++ b/spec/views/organization_statuses/index.html.erb_spec.rb @@ -3,7 +3,7 @@ RSpec.describe "organization_statuses/index", type: :view do let(:admin) { create(:user, :admin) } let(:organization_status1) { create(:organization_status, name: "Active") } - let(:organization_status2) { create(:organization_status, name: "Suspended") } + let(:organization_status2) { create(:organization_status, name: "Formerly active") } before(:each) do assign(:organization_statuses, paginated([ organization_status1, organization_status2 ])) diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 807628c2f7..e4584a4c66 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -65,7 +65,7 @@ def org_with_status(name) end it "shows the status select and the red mismatch hint when the status does not match the affiliation-calculated status" do - org = org_with_status("Pending") + org = org_with_status("Formerly active") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render diff --git a/spec/views/organizations/index.html.erb_spec.rb b/spec/views/organizations/index.html.erb_spec.rb index 3ab61a76ba..91204efc60 100644 --- a/spec/views/organizations/index.html.erb_spec.rb +++ b/spec/views/organizations/index.html.erb_spec.rb @@ -8,13 +8,13 @@ let!(:organization2) { create(:organization, name: "Organization 2") } let!(:organization_status1) { create(:organization_status, name: "Active") } - let!(:organization_status2) { create(:organization_status, name: "Suspended") } - let!(:organization_status3) { create(:organization_status, name: "Inactive") } + let!(:organization_status2) { create(:organization_status, name: "Formerly active") } + let!(:organization_status3) { create(:organization_status, name: "Unknown") } before(:each) do assign(:organizations, paginated([ organization1, organization2 ])) assign(:organization_statuses, [ organization_status1, organization_status2, organization_status3 ]) - assign(:affiliated_since, {}) + assign(:affiliated_since_display, {}) assign(:active_people_counts, {}) assign(:active_people_count, 0) assign(:organizations_count, 2) From c377f67ef2083272fd8eb146499447a2d88b3a1e Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 15:39:35 -0400 Subject: [PATCH 03/40] Scope affiliated-since periods to the org form, leaving the person form's range The affiliation-dates Stimulus controller is shared with the person edit form, whose "Affiliated since" stays a single Mon YYYY range. Gate the merged-periods formatting behind a `periods` value the org form sets, so only the org form's live preview and server render use periods. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../affiliation_dates_controller.js | 20 +++++++++++++++++-- app/views/organizations/_form.html.erb | 2 +- .../organization_affiliation_dates_spec.rb | 14 ++++++------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 78558918ad..2286dab82b 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -2,6 +2,9 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer"] + // Organizations show "Affiliated since" as merged year-based periods; the person + // form leaves it off and keeps the single Mon YYYY – Mon YYYY range. + static values = { periods: Boolean } initialize() { this.boundRecalculate = () => this.recalculate() @@ -41,9 +44,22 @@ export default class extends Controller { const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - // Affiliated since = merged year-based periods (mirrors AffiliationPeriods). + // Affiliated since — merged periods (orgs) or a single range (person form). if (this.hasAffiliatedSinceTarget) { - this.affiliatedSinceTarget.textContent = this.affiliatedSinceLabel(affiliations, today) || "—" + if (this.periodsValue) { + this.affiliatedSinceTarget.textContent = this.affiliatedSinceLabel(affiliations, today) || "—" + } else { + const startDates = affiliations.map(a => a.startDate).filter(Boolean) + const affiliatedSince = startDates.length + ? new Date(Math.min(...startDates.map(d => new Date(d)))) + : null + const allInactive = affiliations.length > 0 && + affiliations.every(a => a.endDate && new Date(a.endDate) < today) + const affiliatedEnd = allInactive + ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) + : null + this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) + } } // Facilitations/program since — unchanged single-range display, filtered by diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 4ed73a09d4..936800d438 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,4 +1,4 @@ -<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning" } }) do |f| %> +<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_periods_value: true } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> <% has_affiliations = f.object.persisted? && f.object.affiliations.any? %> diff --git a/spec/system/organization_affiliation_dates_spec.rb b/spec/system/organization_affiliation_dates_spec.rb index 7077f7caa8..c546b692b6 100644 --- a/spec/system/organization_affiliation_dates_spec.rb +++ b/spec/system/organization_affiliation_dates_spec.rb @@ -29,16 +29,18 @@ def set_date_input(input, value) it "updates Affiliated since when a start date changes" do visit_and_wait edit_organization_path(organization, admin: true) + # Two overlapping ongoing affiliations merge into one ongoing period; a period + # that began in an earlier year shows just the start year. affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("May 2019") + expect(affiliated).to have_text("2019") start_inputs = all("input[name*='affiliations_attributes'][name*='start_date']") set_date_input(start_inputs.first, "2017-02-01") - expect(affiliated).to have_text("Feb 2017", wait: 5) + expect(affiliated).to have_text("2017", wait: 5) end - it "shows end date and icon when all affiliations are inactive" do + it "shows a closed year range when all affiliations have ended" do visit_and_wait edit_organization_path(organization, admin: true) affiliated = find("[data-affiliation-dates-target='affiliatedSince']") @@ -47,10 +49,8 @@ def set_date_input(input, value) set_date_input(end_inputs[0], "2023-03-01") set_date_input(end_inputs[1], "2024-08-01") - expect(affiliated).to have_text("Aug 2024", wait: 5) - within(affiliated) do - expect(page).to have_css("i.fa-circle-xmark") - end + # Overlapping intervals merge into one closed period, shown as a year range. + expect(affiliated).to have_text("2019-2024", wait: 5) end it "removes an affiliation via the editor and recalculates" do From 17ac9552a9703a300784a7937f6200f4376ec28f Mon Sep 17 00:00:00 2001 From: maebeale Date: Fri, 31 Jul 2026 17:28:37 -0400 Subject: [PATCH 04/40] Render org affiliated-since server-side only; drop the JS period logic Keeping the merge-and-format logic in both AffiliationPeriods and the Stimulus controller was a duplication smell. The org form's "Affiliated since" doesn't need a live preview now that it's coarse year-ranges, so render it server-side via the decorator and leave that field untouched by JS (a `serverAffiliatedSince` value gates it). The person form keeps its live single Mon YYYY range. AffiliationPeriods is now the single source of truth. Replaces the org affiliation-dates system spec with a request spec asserting the server render. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- .../affiliation_dates_controller.js | 75 +++++-------------- app/views/organizations/_form.html.erb | 2 +- spec/requests/organizations_spec.rb | 12 +++ .../organization_affiliation_dates_spec.rb | 67 ----------------- 5 files changed, 31 insertions(+), 127 deletions(-) delete mode 100644 spec/system/organization_affiliation_dates_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 5b1fbdc67e..d144fb4657 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ action, or `authorize! :workshop, to: :summary?`). ### Business Logic -- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods and formats them as year-based ranges for the "Affiliated since" display (e.g. "2010-2012, 2026"); mirrored client-side in `affiliation_dates_controller.js` +- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods and formats them as year-based ranges for the "Affiliated since" display (e.g. "2010-2012, 2026"); rendered server-side on the org show/index/edit pages (single source of truth — no JS duplication) - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 2286dab82b..0a5a6befe2 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -2,9 +2,10 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer"] - // Organizations show "Affiliated since" as merged year-based periods; the person - // form leaves it off and keeps the single Mon YYYY – Mon YYYY range. - static values = { periods: Boolean } + // Orgs render "Affiliated since" server-side as merged periods (AffiliationPeriods), + // so the controller leaves that field alone; the person form keeps the live single + // Mon YYYY – Mon YYYY range. + static values = { serverAffiliatedSince: Boolean } initialize() { this.boundRecalculate = () => this.recalculate() @@ -44,22 +45,19 @@ export default class extends Controller { const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - // Affiliated since — merged periods (orgs) or a single range (person form). - if (this.hasAffiliatedSinceTarget) { - if (this.periodsValue) { - this.affiliatedSinceTarget.textContent = this.affiliatedSinceLabel(affiliations, today) || "—" - } else { - const startDates = affiliations.map(a => a.startDate).filter(Boolean) - const affiliatedSince = startDates.length - ? new Date(Math.min(...startDates.map(d => new Date(d)))) - : null - const allInactive = affiliations.length > 0 && - affiliations.every(a => a.endDate && new Date(a.endDate) < today) - const affiliatedEnd = allInactive - ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) - : null - this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) - } + // Affiliated since — single Mon YYYY range (person form). Orgs render this + // server-side as merged periods, so skip it there. + if (this.hasAffiliatedSinceTarget && !this.serverAffiliatedSinceValue) { + const startDates = affiliations.map(a => a.startDate).filter(Boolean) + const affiliatedSince = startDates.length + ? new Date(Math.min(...startDates.map(d => new Date(d)))) + : null + const allInactive = affiliations.length > 0 && + affiliations.every(a => a.endDate && new Date(a.endDate) < today) + const affiliatedEnd = allInactive + ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) + : null + this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) } // Facilitations/program since — unchanged single-range display, filtered by @@ -83,45 +81,6 @@ export default class extends Controller { } } - // Merge affiliation intervals into periods and format them as year-based ranges - // — the client-side mirror of app/services/affiliation_periods.rb. - affiliatedSinceLabel(affiliations, today) { - const intervals = affiliations - .filter(a => a.startDate) - .map(a => ({ start: new Date(a.startDate), end: a.endDate ? new Date(a.endDate) : null })) - .sort((a, b) => a.start - b.start) - if (!intervals.length) return "" - - const periods = [] - for (const iv of intervals) { - const last = periods[periods.length - 1] - if (last && (last.end === null || iv.start <= last.end)) { - last.end = last.end === null || iv.end === null ? null : new Date(Math.max(last.end, iv.end)) - } else { - periods.push({ start: iv.start, end: iv.end }) - } - } - - const ongoing = end => end === null || end >= today - const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - - // A single ongoing period is a fresh org — worth the month's precision. - if (periods.length === 1 && ongoing(periods[0].end)) { - const s = periods[0].start - return s.getUTCFullYear() === today.getUTCFullYear() - ? `${months[s.getUTCMonth()]} ${s.getUTCFullYear()}` - : `${s.getUTCFullYear()}` - } - - return periods - .map(p => { - const startYear = p.start.getUTCFullYear() - if (ongoing(p.end) || startYear === p.end.getUTCFullYear()) return `${startYear}` - return `${startYear}-${p.end.getUTCFullYear()}` - }) - .join(", ") - } - getVisibleAffiliations() { if (!this.hasAffiliationsContainerTarget) return [] const fields = this.affiliationsContainerTarget.querySelectorAll(".nested-fields") diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 936800d438..d3f03d8148 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,4 +1,4 @@ -<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_periods_value: true } }) do |f| %> +<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_server_affiliated_since_value: true } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> <% has_affiliations = f.object.persisted? && f.object.affiliations.any? %> diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index 74f3ab776d..afec6f9380 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -174,6 +174,18 @@ get edit_organization_url(organization) expect(response.body).to include("Monthly reports") end + + it "renders affiliated-since as merged year-based periods (server-side)" do + organization = Organization.create!(valid_attributes) + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2010, 1, 1), end_date: Date.new(2012, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2013, 1, 1), end_date: Date.new(2015, 6, 1)) + + get edit_organization_url(organization) + + expect(response.body).to include("2010-2012, 2013-2015") + end end describe "POST /create" do diff --git a/spec/system/organization_affiliation_dates_spec.rb b/spec/system/organization_affiliation_dates_spec.rb deleted file mode 100644 index c546b692b6..0000000000 --- a/spec/system/organization_affiliation_dates_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require "rails_helper" - -RSpec.describe "Organization affiliation dates auto-update", type: :system do - let(:admin) { create(:user, :admin) } - let!(:admin_person) { create(:person, user: admin) } - let!(:person1) { create(:person) } - let!(:person2) { create(:person) } - let!(:organization) { create(:organization) } - - before do - driven_by(:selenium_chrome_headless) - create(:affiliation, organization: organization, person: person1, title: "Facilitator", start_date: "2019-05-01", end_date: nil) - create(:affiliation, organization: organization, person: person2, title: "Volunteer", start_date: "2021-09-15", end_date: nil) - sign_in admin - end - - def visit_and_wait(path) - visit path - expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) - end - - def set_date_input(input, value) - page.execute_script( - "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))", - input, value - ) - end - - it "updates Affiliated since when a start date changes" do - visit_and_wait edit_organization_path(organization, admin: true) - - # Two overlapping ongoing affiliations merge into one ongoing period; a period - # that began in an earlier year shows just the start year. - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("2019") - - start_inputs = all("input[name*='affiliations_attributes'][name*='start_date']") - set_date_input(start_inputs.first, "2017-02-01") - - expect(affiliated).to have_text("2017", wait: 5) - end - - it "shows a closed year range when all affiliations have ended" do - visit_and_wait edit_organization_path(organization, admin: true) - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - - end_inputs = all("input[name*='affiliations_attributes'][name*='end_date']") - set_date_input(end_inputs[0], "2023-03-01") - set_date_input(end_inputs[1], "2024-08-01") - - # Overlapping intervals merge into one closed period, shown as a year range. - expect(affiliated).to have_text("2019-2024", wait: 5) - end - - it "removes an affiliation via the editor and recalculates" do - # Persisted affiliations are now deleted from the affiliation editor (reached - # via the row's gear); removing the Facilitator (May 2019) leaves Volunteer (Sep 2021). - facilitator = organization.affiliations.find_by!(title: "Facilitator") - visit edit_affiliation_path(facilitator, return_to: "organization", origin_id: organization.id) - - accept_confirm { click_button "Delete" } - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) - expect(affiliated).to have_text("Sep 2021", wait: 5) - end -end From 7069592d3666935f16757663f148edbc26f4340d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 06:51:08 -0400 Subject: [PATCH 05/40] Show "Program since" (facilitator periods) on org index/show/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the org index "Affiliated since" column with "Program since" — the org's facilitator-affiliation history as merged year-based periods (e.g. "2015-2018, 2024") via a new OrganizationDecorator#program_since_display. Adds a "Program since" row to the org show page, and converts the edit form's "Facilitations/program since" value to the same period format. Both org-form "since" fields are now server-rendered, so the affiliation-dates Stimulus controller is dropped from the org form (it still drives the person form). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 15 ++++++- app/decorators/organization_decorator.rb | 7 ++++ app/views/organizations/_form.html.erb | 41 ++++++++++++++----- .../organizations_results.html.erb | 6 +-- app/views/organizations/show.html.erb | 10 ++++- .../decorators/organization_decorator_spec.rb | 16 ++++++++ .../views/organizations/edit.html.erb_spec.rb | 24 +++++++++++ .../organizations/index.html.erb_spec.rb | 2 +- 8 files changed, 104 insertions(+), 17 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index b37b228bd7..a982ecb16c 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -17,8 +17,9 @@ def index @active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id") @organizations = filtered.paginate(page: params[:page], per_page: per_page) org_ids = @organizations.map(&:id) - # Merged-period "Affiliated since" label per org, from the preloaded affiliations. - @affiliated_since_display = @organizations.to_h { |org| [ org.id, org.decorate.affiliated_since_display ] } + # Merged-period "Program since" label per org (facilitator affiliations), + # from the preloaded affiliations. + @program_since_display = @organizations.to_h { |org| [ org.id, org.decorate.program_since_display ] } @active_people_counts = Affiliation.active .where(organization_id: org_ids) .group(:organization_id) @@ -176,6 +177,16 @@ def set_form_variables @organization.affiliations.proxy_association.target.replace(sorted) end + # Events the org is represented at, newest first — drives the per-event + # "Program status by event" chips in the Affiliations section. Program status + # (New/Ongoing/Reinstate) is only meaningful relative to a specific event date. + @organization_events = if @organization.persisted? + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .order(start_date: :desc) + else + Event.none + end + @org_categories_grouped = Category .includes(:category_type) .published diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index ad37edbd5a..530e590ee3 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -98,6 +98,13 @@ def affiliated_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" end + # "Program since" display: the org's facilitator-affiliation history as merged + # year-based periods (see AffiliationPeriods). Blank when it has never + # facilitated. Pass a preloaded affiliations collection on list pages. + def program_since_display(affiliations = object.affiliations) + AffiliationPeriods.label(affiliations.select(&:facilitator?)) || "" + end + def facilitator_since_date @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index d3f03d8148..713dafa8bf 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,4 +1,4 @@ -<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_server_affiliated_since_value: true } }) do |f| %> +<%= simple_form_for(@organization, html: { data: { controller: "affiliation-facilitator-warning" } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> <% has_affiliations = f.object.persisted? && f.object.affiliations.any? %> @@ -269,8 +269,6 @@ <% org_latest_end = f.object.persisted? ? f.object.affiliations.maximum(:end_date) : nil %> <% org_end_date = org_aff_ended ? org_latest_end : f.object.end_date %> <% org_decorated = f.object.decorate %> - <% org_fac_start = org_decorated.facilitator_since_date %> - <% org_fac_ended = org_decorated.facilitation_end_date %>
- <%= org_decorated.affiliated_since_display.presence || "—" %> + <%= org_decorated.affiliated_since_display.presence || "—" %> <% if has_affiliations && org_earliest_aff.nil? %> <% elsif f.object.start_date.present? && org_earliest_aff.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %> @@ -317,18 +315,41 @@ Facilitations/program since
- <% if org_fac_ended %><% end %><%= org_fac_start&.strftime("%b %Y") || "—" %><%= " – #{org_fac_ended.strftime('%b %Y')}" if org_fac_ended %> + <%= org_decorated.program_since_display.presence || "—" %>
+ <% if allowed_to?(:manage?, Organization) %> + <% org_events = @organization_events || [] %> +
+ + +
+ <% if org_events.any? %> +
+ <% org_events.each do |event| %> + <% status = org_decorated.facilitator_status_as_of(event.start_date) %> + + <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> + + <% end %> +
+ <% else %> + + <% end %> +
+
+ <% end %>
<% if allowed_to?(:manage?, Organization) %> -
- <% if f.object.affiliations.present? %> - <%= render "affiliations/header", label: "Person" %> - <% end %> +
<%= f.fields_for :affiliations do |affiliation_form| %>
<%= render "affiliation_fields", diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 7cb49c0582..90c6bad767 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -10,7 +10,7 @@
Organization Designations Age group(s)Affiliated sinceProgram since People (<%= number_with_delimiter(@active_people_count) %>)Actions
<% status_label = organization.published? ? nil : organization.organization_status&.name %> @@ -50,7 +50,7 @@ - <%= @affiliated_since_display[organization.id] %> + <%= @program_since_display[organization.id] %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index a83868aebc..f414f66009 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -47,13 +47,21 @@ <%= [address.locality, [address.city, address.state].compact_blank.join(", "), address.district].compact_blank.join(" · ") %>

<% end %> - <% affiliated_since = @organization.decorate.affiliated_since_display %> + <% org_decorated = @organization.decorate %> + <% affiliated_since = org_decorated.affiliated_since_display %> <% if affiliated_since.present? %>

Affiliated since <%= affiliated_since %>

<% end %> + <% program_since = org_decorated.program_since_display %> + <% if program_since.present? %> +

+ Program since + <%= program_since %> +

+ <% end %>
<% if @organization.profile_show_email? && @organization.email.present? %> diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 4e9d99bfb4..9bf266ec6d 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -23,6 +23,22 @@ end end + describe "#program_since_display" do + let(:organization) { create(:organization) } + + it "is blank when the org has never had a facilitator affiliation" do + create(:affiliation, organization: organization, person: create(:person), title: "Volunteer", start_date: Date.new(2010, 1, 1)) + expect(organization.reload.decorate.program_since_display).to eq("") + end + + it "shows merged facilitator-affiliation periods, ignoring non-facilitator ones" do + create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), title: "Volunteer", start_date: Date.new(2005, 1, 1), end_date: nil) + create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2024, 2, 1), end_date: nil) + expect(organization.reload.decorate.program_since_display).to eq("2015-2018, 2024") + end + end + describe ".program_status_classes" do it "maps each status to its pill classes, accepting symbols or model strings" do expect(described_class.program_status_classes(:new)).to include("green") diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index e4584a4c66..2f91218c02 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -74,6 +74,30 @@ def org_with_status(name) end end + describe "program status by event" do + it "renders an 'event · status' chip for each event the org is represented at" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + person = create(:person) + create(:affiliation, organization: org, person: person, title: "Facilitator", + start_date: 1.year.ago, end_date: nil) + event = create(:event, title: "August Training", abbreviation: "PES205", start_date: 2.days.from_now) + + assign(:organization, org.reload) + assign(:organization_statuses, OrganizationStatus.all) + assign(:organization_events, Event.where(id: event.id)) + render + + expect(rendered).to include("PES205 · Ongoing") + end + + it "shows a dash when the org has no events" do + assign(:organization_events, Event.none) + render + + assert_select "label", text: /Program status by event/ + end + end + describe "new affiliation defaults" do it "defaults the start date to today and leaves primary contact unchecked" do organization.affiliations.build diff --git a/spec/views/organizations/index.html.erb_spec.rb b/spec/views/organizations/index.html.erb_spec.rb index 91204efc60..d97ecfb898 100644 --- a/spec/views/organizations/index.html.erb_spec.rb +++ b/spec/views/organizations/index.html.erb_spec.rb @@ -14,7 +14,7 @@ before(:each) do assign(:organizations, paginated([ organization1, organization2 ])) assign(:organization_statuses, [ organization_status1, organization_status2, organization_status3 ]) - assign(:affiliated_since_display, {}) + assign(:program_since_display, {}) assign(:active_people_counts, {}) assign(:active_people_count, 0) assign(:organizations_count, 2) From de7a8c9650ffa720185c91a9695f8de7664ea1b9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 07:22:02 -0400 Subject: [PATCH 06/40] =?UTF-8?q?Add=20general=20"Program=20status"=20chip?= =?UTF-8?q?=20+=20index=20bucket=20filter;=20New=E2=86=92indigo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the org-wide program status (from the stored organization_status, with Unknown/blank shown as "Never active") as a colored chip, and shows it before the per-event New/Ongoing/Reinstate chips under a single "Program status" heading on both the edit form and a new admin-only block on the org profile (per-event chips moved out of the events-attended cards). - Index: a "Program status" chip under the "Program since" date (GA), plus a staff-only bucket filter — Active / Formerly active / Never active / Formerly + Never active — backed by an Organization.program_status scope (Unknown and no-status both count as never active). - Palette: New moves green→indigo site-wide (DomainTheme + scholarship decorator) so it never collides with the green "Active" chip; general chip colors are green / orange / gray. - Per-event chips link to the event background report in a new tab (placeholder for the in-development attendance report). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 8 +++++ app/decorators/organization_decorator.rb | 27 ++++++++++++++++ app/decorators/scholarship_decorator.rb | 2 +- app/models/organization.rb | 17 +++++++++- app/views/organizations/_form.html.erb | 26 +++++++--------- .../organizations/_search_boxes.html.erb | 15 ++++++--- .../organizations_results.html.erb | 3 +- .../organizations/sections/_events.html.erb | 17 ---------- app/views/organizations/show.html.erb | 16 ++++++++++ lib/domain_theme.rb | 11 +++++-- .../decorators/organization_decorator_spec.rb | 29 ++++++++++++++++- spec/models/organization_spec.rb | 31 ++++++++++++++++--- .../views/organizations/edit.html.erb_spec.rb | 11 +++++-- 13 files changed, 163 insertions(+), 50 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index a982ecb16c..c193a5132d 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -46,6 +46,14 @@ def show track_view(@organization) + # Events for the admin-only "Program status" block (facilitator status as of + # each event). Skip the query for non-managers, who don't see the block. + @organization_events = if allowed_to?(:manage?, @organization) + Event.where(id: @organization.event_registrations.active.select(:event_id)).order(start_date: :desc) + else + Event.none + end + workshop_logs = WorkshopLog.where(organization_id: @organization.id) @month_year_options = workshop_logs.group("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m')") .select("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m') AS ym, diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 530e590ee3..1303f54a41 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -105,6 +105,33 @@ def program_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations.select(&:facilitator?)) || "" end + # The org's stored program status (organization_status), with "Unknown" — and a + # missing status — displayed as "Never active". + def organization_status_label + name = object.organization_status&.name + name.blank? || name == "Unknown" ? "Never active" : name + end + + # Pill classes for the org-wide status chip, keyed off the stored status name. + def organization_status_classes + theme_key = case object.organization_status&.name + when "Active" then :org_active + when "Formerly active" then :org_formerly_active + else :org_never_active + end + [ + DomainTheme.bg_class_for(theme_key, intensity: 100), + DomainTheme.text_class_for(theme_key, intensity: 700), + DomainTheme.border_class_for(theme_key, intensity: 200) + ].join(" ") + end + + # Rendered org-wide status chip (Active / Formerly active / Never active). + def organization_status_chip + h.content_tag(:span, organization_status_label, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") + end + def facilitator_since_date @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) end diff --git a/app/decorators/scholarship_decorator.rb b/app/decorators/scholarship_decorator.rb index d9346ec10d..258fb48a83 100644 --- a/app/decorators/scholarship_decorator.rb +++ b/app/decorators/scholarship_decorator.rb @@ -36,7 +36,7 @@ def program_status def program_status_classes case program&.program_status(object.recipient) when "Ongoing" then "bg-blue-50 text-blue-700 border-blue-200" - when "New" then "bg-green-50 text-green-700 border-green-200" + when "New" then "bg-indigo-50 text-indigo-700 border-indigo-200" when "Reinstate" then "bg-purple-50 text-purple-700 border-purple-200" else "bg-gray-50 text-gray-500 border-gray-200" end diff --git a/app/models/organization.rb b/app/models/organization.rb index 6463d6d846..a448d964d8 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -105,6 +105,21 @@ def self.awbw end scope.distinct end + # Index filter over the stored organization_status, bucketed for display: + # "never_active" covers stored "Unknown" and orgs with no status at all; + # "formerly_or_never" is either of the two non-active buckets. + scope :program_status, ->(bucket) { + by_name = ->(name) { where(organization_status_id: OrganizationStatus.where(name: name).select(:id)) } + never = -> { by_name.call("Unknown").or(where(organization_status_id: nil)) } + case bucket.to_s + when "active" then by_name.call("Active") + when "formerly_active" then by_name.call("Formerly active") + when "never_active" then never.call + when "formerly_or_never" then by_name.call("Formerly active").or(never.call) + else all + end + } + scope :organization_ids, ->(organization_ids) { where(id: organization_ids.to_s.split("-").map(&:to_i)) } scope :project_ids, ->(project_ids) { where(id: project_ids.to_s.split("-").map(&:to_i)) } scope :published, -> { active } @@ -117,7 +132,7 @@ def self.search_by_params(params) organizations = organizations.address(params[:address]) if params[:address].present? organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? - organizations = organizations.where(organization_status_id: params[:organization_status_id]) if params[:organization_status_id].present? + organizations = organizations.program_status(params[:program_status]) if params[:program_status].present? organizations end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 713dafa8bf..a12d756d30 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -325,24 +325,20 @@ <% org_events = @organization_events || [] %>
-
- <% if org_events.any? %> -
- <% org_events.each do |event| %> - <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - - <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> - - <% end %> -
- <% else %> - +
+ <%= org_decorated.organization_status_chip %> + <% org_events.each do |event| %> + <% status = org_decorated.facilitator_status_as_of(event.start_date) %> + <%= link_to background_event_path(event), target: "_blank", rel: "noopener noreferrer", + title: event.title, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> + <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> + <% end %> <% end %>
diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index e8159e42b3..6106561d6c 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -24,12 +24,17 @@ <% if allowed_to?(:manage?, Organization) %>
- <%= label_tag :status, "Status", class: search_label_class %> - <%= select_tag :organization_status_id, - options_for_select(@organization_statuses.map { |ps| [ps.name, ps.id] }, - params[:organization_status_id].to_s), + <%= label_tag :program_status, "Program status", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :program_status, + options_for_select([ + [ "Active", "active" ], + [ "Formerly active", "formerly_active" ], + [ "Never active", "never_active" ], + [ "Formerly + Never active", "formerly_or_never" ] + ], params[:program_status]), include_blank: "All statuses", - class: search_field_class(extra: "w-40 search-select-placeholder") %> + class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
<% end %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 90c6bad767..c53e109bd1 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -20,7 +20,7 @@
<% status_label = organization.published? ? nil : organization.organization_status&.name %> @@ -51,6 +51,7 @@ <%= @program_since_display[organization.id] %> +
<%= organization.decorate.organization_status_chip %>
diff --git a/app/views/organizations/sections/_events.html.erb b/app/views/organizations/sections/_events.html.erb index f8becefa6e..b3949771c8 100644 --- a/app/views/organizations/sections/_events.html.erb +++ b/app/views/organizations/sections/_events.html.erb @@ -1,22 +1,5 @@ <%= 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) %> - <%= render "shared/badge", - label: "#{status.to_s.titleize} · #{event.decorate.compact_label.truncate(24)}", - classes: OrganizationDecorator.program_status_classes(status), - href: event_path(event), - title: event.title %> - <% end %> -
-
- <% end %> -
<% events.each do |event| %> <% decorated = event.decorate %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index f414f66009..ef6e5288a0 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -109,6 +109,22 @@ <% end %>
+ <% if allowed_to?(:manage?, @organization) %> +
+

Program status

+
+ <%= org_decorated.organization_status_chip %> + <% @organization_events.each do |event| %> + <% status = org_decorated.facilitator_status_as_of(event.start_date) %> + <%= link_to background_event_path(event), target: "_blank", rel: "noopener noreferrer", + title: event.title, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> + <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> + <% end %> + <% end %> +
+
+ <% end %>
diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index 31006f0113..830a2b67d1 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -49,11 +49,18 @@ module DomainTheme user_only: :amber, person_bio: :purple, - # Organization program status badges (amber is reserved for warnings) - program_new: :green, + # Per-event program status badges — New is indigo (not green) so it never + # collides with the org-wide "Active" status (amber is reserved for warnings). + program_new: :indigo, program_ongoing: :blue, program_reinstated: :purple, + # Org-wide program status (the stored organization_status): Active is the + # positive current state, Formerly active a lapsed one, Never active neutral. + org_active: :green, + org_formerly_active: :orange, + org_never_active: :gray, + # Badges (non-model-specific) legacy_facilitator: :yellow, seasoned_facilitator: :sky, diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 9bf266ec6d..4c8e43a654 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -39,9 +39,36 @@ end end + describe "#organization_status_label" do + it "returns the stored status name" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + expect(org.decorate.organization_status_label).to eq("Active") + end + + it "renders Unknown as 'Never active'" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) + expect(org.decorate.organization_status_label).to eq("Never active") + end + + it "renders a missing status as 'Never active'" do + org = create(:organization) + org.update_columns(organization_status_id: nil) + expect(org.decorate.organization_status_label).to eq("Never active") + end + end + + describe "#organization_status_chip" do + it "renders a pill with the label and its status color" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) + chip = org.decorate.organization_status_chip + expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") + expect(chip).to include("orange") + end + end + describe ".program_status_classes" do it "maps each status to its pill classes, accepting symbols or model strings" do - expect(described_class.program_status_classes(:new)).to include("green") + expect(described_class.program_status_classes(:new)).to include("indigo") expect(described_class.program_status_classes(:ongoing)).to include("blue") expect(described_class.program_status_classes(:reinstated)).to include("purple") # Organization#program_status returns "Reinstate" (no trailing d). diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 5371516af0..a834951849 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -224,7 +224,7 @@ describe '.search_by_params' do let!(:active_status) { create(:organization_status, name: "Active") } - let!(:inactive_status) { create(:organization_status, name: "Inactive") } + let!(:inactive_status) { create(:organization_status, name: "Formerly active") } let!(:active_org) { create(:organization, name: "Community Center", organization_status: active_status) } let!(:inactive_org) { create(:organization, name: "Old Program", organization_status: inactive_status) } @@ -254,15 +254,38 @@ end end - context 'with status dropdown' do - it 'filters by organization_status_id' do - results = Organization.search_by_params(organization_status_id: active_status.id.to_s) + context 'with program status filter' do + it 'filters to the active bucket' do + results = Organization.search_by_params(program_status: "active") expect(results).to include(active_org) expect(results).not_to include(inactive_org) end end end + describe ".program_status scope" do + let!(:active) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) } + let!(:formerly) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) } + let!(:unknown) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) } + let!(:no_status) { create(:organization).tap { |o| o.update_columns(organization_status_id: nil) } } + + it "buckets active" do + expect(Organization.program_status("active")).to contain_exactly(active) + end + + it "buckets formerly_active" do + expect(Organization.program_status("formerly_active")).to contain_exactly(formerly) + end + + it "treats Unknown and no-status as never_active" do + expect(Organization.program_status("never_active")).to contain_exactly(unknown, no_status) + end + + it "combines formerly + never" do + expect(Organization.program_status("formerly_or_never")).to contain_exactly(formerly, unknown, no_status) + end + end + describe "age groups served" do let(:age_type) { create(:category_type, name: "AgeRange", published: true) } let!(:young) { create(:category, :published, category_type: age_type, name: "3-5") } diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 2f91218c02..dafc13e7d6 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -74,7 +74,7 @@ def org_with_status(name) end end - describe "program status by event" do + describe "program status" do it "renders an 'event · status' chip for each event the org is represented at" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) person = create(:person) @@ -90,11 +90,16 @@ def org_with_status(name) expect(rendered).to include("PES205 · Ongoing") end - it "shows a dash when the org has no events" do + it "always shows the general status chip, even with no events" do + org = organization + org.update!(organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) + assign(:organization, org.reload) + assign(:organization_statuses, OrganizationStatus.all) assign(:organization_events, Event.none) render - assert_select "label", text: /Program status by event/ + assert_select "label", text: /Program status/ + expect(rendered).to include("Never active") end end From ba32097a450e99fb875db11bf0f3b677d07ccbf4 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 07:24:00 -0400 Subject: [PATCH 07/40] Move profile program-status chip assertion to the show page The per-event chips moved out of the lazy events-attended frame into the admin-only Program status block on the show page; point the spec there. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/requests/organizations_events_section_spec.rb | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index 35e5af8a63..955d9d9918 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -47,29 +47,26 @@ def register(event:, status: "registered") end it "lists each event once even when several members attended it" do - event = create(:event, title: "Shared Event", abbreviation: "SE1") + event = create(:event, title: "Shared Event") register(event: event) register(event: event) get_events_section - # 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 ">". + # One card, not one per registration (the title renders as a ">Shared Event" node). 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 + it "shows an admin program-status chip per event in the profile's Program status block" 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 + get organization_path(organization) + expect(response.body).to include("Program status") expect(response.body).to include("TOS205") expect(response.body).to include("Ongoing") end From cb09272ecaa83c723e2c71f8e04a8f592f78972a Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 21:28:16 -0400 Subject: [PATCH 08/40] Link per-event chips to the event dashboard; gate index status chip to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-event program-status chips now open the event dashboard (the canonical event admin home) in a new tab, instead of the background report — still a placeholder for the in-development attendance report. - The org index "Program status" chip under the Program since date is now admin-only (manage?), matching how program status is treated elsewhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/_form.html.erb | 2 +- app/views/organizations/organizations_results.html.erb | 4 +++- app/views/organizations/show.html.erb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index a12d756d30..127400c961 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -334,7 +334,7 @@ <%= org_decorated.organization_status_chip %> <% org_events.each do |event| %> <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to background_event_path(event), target: "_blank", rel: "noopener noreferrer", + <%= link_to dashboard_event_path(event), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index c53e109bd1..f66367e602 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -51,7 +51,9 @@
<%= @program_since_display[organization.id] %> -
<%= organization.decorate.organization_status_chip %>
+ <% if allowed_to?(:manage?, Organization) %> +
<%= organization.decorate.organization_status_chip %>
+ <% end %>
diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index ef6e5288a0..f13d268ad6 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -116,7 +116,7 @@ <%= org_decorated.organization_status_chip %> <% @organization_events.each do |event| %> <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to background_event_path(event), target: "_blank", rel: "noopener noreferrer", + <%= link_to dashboard_event_path(event), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> From 9768097967c3b9950530f53f4559cd939162e21d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 21:29:36 -0400 Subject: [PATCH 09/40] Show Program since years in a yellow chip on the org index Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/organizations_results.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index f66367e602..df282fb236 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -50,7 +50,10 @@ - <%= @program_since_display[organization.id] %> + <% program_since = @program_since_display[organization.id] %> + <% if program_since.present? %> + <%= program_since %> + <% end %> <% if allowed_to?(:manage?, Organization) %>
<%= organization.decorate.organization_status_chip %>
<% end %> From e595045ef1fd8fc26a9e637809b7c109a05450ab Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 21:48:30 -0400 Subject: [PATCH 10/40] =?UTF-8?q?Colour=20the=20index=20Program-since=20ch?= =?UTF-8?q?ip=20by=20status;=20rename=20Designations=E2=86=92Sectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge the separate year + status chips into one: admins see the facilitator years coloured by org status (green Active / orange Formerly active / gray Never active), falling back to the status label when there are no years; non-admins keep a neutral yellow year chip. - Rename the index "Designations" column to "Sectors" and stop showing the windows-type pill there. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 8 ++++++++ .../organizations/organizations_results.html.erb | 13 ++++--------- spec/decorators/organization_decorator_spec.rb | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 1303f54a41..dc0fa1d401 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -132,6 +132,14 @@ def organization_status_chip class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") end + # Index "Program since" chip for admins: the facilitator-period years coloured + # by the org's status (green Active / orange Formerly active / gray Never + # active), falling back to the status label when there are no facilitator years. + def program_since_chip(years = program_since_display) + h.content_tag(:span, years.presence || organization_status_label, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") + end + def facilitator_since_date @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) end diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index df282fb236..081c156e1f 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -8,7 +8,7 @@
OrganizationDesignationsSectors Age group(s) Program since People (<%= number_with_delimiter(@active_people_count) %>)
- <% if organization.windows_type %> - <%= render "shared/badge", label: organization.windows_type.short_name.titleize, classes: "bg-purple-100 text-purple-800 border-purple-200" %> - <% end %> - <% organization.sectors.each do |sector| %> <%= render "sectors/tagging_label", sector: sector %> <% end %> @@ -51,11 +47,10 @@
<% program_since = @program_since_display[organization.id] %> - <% if program_since.present? %> - <%= program_since %> - <% end %> <% if allowed_to?(:manage?, Organization) %> -
<%= organization.decorate.organization_status_chip %>
+ <%= organization.decorate.program_since_chip(program_since) %> + <% elsif program_since.present? %> + <%= program_since %> <% end %>
- <% organization.sectors.each do |sector| %> + <% organization.all_sectors.sort_by { |s| s&.name.to_s }.each do |sector| %> <%= render "sectors/tagging_label", sector: sector %> <% end %>
diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index e537cef54f..93de5db853 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -290,23 +290,29 @@ let!(:age_type) { create(:category_type, name: "AgeRange", published: true) } let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } let!(:sector) { create(:sector, :published, name: "Housing") } - let!(:tagged) { create(:organization, name: "Tagged Org") } + let!(:direct_org) { create(:organization, name: "Direct Org") } + let!(:via_person_org) { create(:organization, name: "Via Person Org") } let!(:untagged) { create(:organization, name: "Untagged Org") } before do - tagged.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) - tagged.sectorable_items.create!(sector: sector, is_primary: false) + direct_org.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + direct_org.sectorable_items.create!(sector: sector, is_primary: false) + + person = create(:person) + create(:affiliation, organization: via_person_org, person: person) + person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + person.sectorable_items.create!(sector: sector, is_primary: false) end - it "filters by age group via category_names_all" do - results = Organization.search_by_params(category_names_all: "13-17") - expect(results).to include(tagged) + it "matches a sector tagged directly on the org or via an affiliated person" do + results = Organization.search_by_params(sector_name: "Housing") + expect(results).to include(direct_org, via_person_org) expect(results).not_to include(untagged) end - it "filters by sector via sector_names_all" do - results = Organization.search_by_params(sector_names_all: "Housing") - expect(results).to include(tagged) + it "matches an age group tagged directly on the org or via an affiliated person" do + results = Organization.search_by_params(age_group_name: "13-17") + expect(results).to include(direct_org, via_person_org) expect(results).not_to include(untagged) end end From f1af7720305b1684b29b78d85c6f7c24c596f00b Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 22:26:34 -0400 Subject: [PATCH 13/40] Roll up only affiliated people's PRIMARY sector/age group to the org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sectors and age groups shown (and filtered) on the org index/profile are now: tagged directly on the org (any), OR an affiliated person's PRIMARY tag — affiliated people's non-primary sectors and additional age groups no longer roll up. - affiliated_sectors: only each person's primary sector (they have at most one). - all_additional_age_groups: org's own additional only (people contribute their primary via all_primary_age_groups, not additional). - sector/age-group filter scopes: org-direct OR affiliated person's primary. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/organization.rb | 24 +++++++++--- spec/models/organization_spec.rb | 60 +++++++++++++++++++++-------- spec/requests/organizations_spec.rb | 4 +- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 2ce278ff29..08e5ebd25c 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -120,13 +120,17 @@ def self.awbw end } - # Index filters that match a sector / age group tagged directly on the org OR on - # any affiliated person — mirroring the aggregate the index/profile columns show. + # Index filters that match a sector / age group tagged directly on the org (any), + # OR as an affiliated person's PRIMARY tag — mirroring the aggregate the + # index/profile columns show. scope :sector_name_including_people, ->(name) { next all if name.blank? term = name.to_s.downcase direct = joins(:sectors).where("LOWER(sectors.name) = ?", term).select(:id) - via_people = joins(people: :sectors).where("LOWER(sectors.name) = ?", term).select(Arel.sql("organizations.id")) + via_people = joins(people: { sectorable_items: :sector }) + .where(sectorable_items: { is_primary: true }) + .where("LOWER(sectors.name) = ?", term) + .select(Arel.sql("organizations.id")) where(id: direct).or(where(id: via_people)) } @@ -137,7 +141,10 @@ def self.awbw .where("LOWER(categories.name) = ?", name.to_s.downcase) .select(:id) direct = joins(:categories).where(categories: { id: age_category_ids }).select(:id) - via_people = joins(people: :categories).where(categories: { id: age_category_ids }).select(Arel.sql("organizations.id")) + via_people = joins(people: { categorizable_items: :category }) + .where(categorizable_items: { is_primary: true }) + .where(categories: { id: age_category_ids }) + .select(Arel.sql("organizations.id")) where(id: direct).or(where(id: via_people)) } @@ -287,8 +294,11 @@ def direct_sectors sectors end + # Only affiliated people's PRIMARY sector (a person has at most one) — their + # non-primary sectors don't roll up to the org. def affiliated_sectors - people.includes(:sectors).flat_map(&:sectors) + people.includes(sectorable_items: :sector) + .flat_map { |person| person.sectorable_items.filter_map { |item| item.sector if item.is_primary? } } end def all_sectors @@ -302,8 +312,10 @@ def all_primary_age_groups collect_age_groups(:primary_age_groups) end + # Additional age groups are the org's OWN only — affiliated people contribute + # just their primary age groups (via all_primary_age_groups), not additional. def all_additional_age_groups - collect_age_groups(:additional_age_groups) - all_primary_age_groups + additional_age_groups - all_primary_age_groups end remote_searchable_by :name diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 93de5db853..673bdd3b81 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -291,29 +291,55 @@ let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } let!(:sector) { create(:sector, :published, name: "Housing") } let!(:direct_org) { create(:organization, name: "Direct Org") } - let!(:via_person_org) { create(:organization, name: "Via Person Org") } + let!(:primary_person_org) { create(:organization, name: "Primary Person Org") } + let!(:additional_person_org) { create(:organization, name: "Additional Person Org") } let!(:untagged) { create(:organization, name: "Untagged Org") } before do - direct_org.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + # Tagged directly on the org (org's own tags count regardless of primary). + direct_org.tag_age_groups(primary_ids: [], additional_ids: [ teen.id ]) direct_org.sectorable_items.create!(sector: sector, is_primary: false) - person = create(:person) - create(:affiliation, organization: via_person_org, person: person) - person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) - person.sectorable_items.create!(sector: sector, is_primary: false) + # An affiliated person's PRIMARY tags — should match. + primary_person = create(:person) + create(:affiliation, organization: primary_person_org, person: primary_person) + primary_person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + primary_person.sectorable_items.create!(sector: sector, is_primary: true) + + # An affiliated person's ADDITIONAL / non-primary tags — should NOT match. + additional_person = create(:person) + create(:affiliation, organization: additional_person_org, person: additional_person) + additional_person.tag_age_groups(primary_ids: [], additional_ids: [ teen.id ]) + additional_person.sectorable_items.create!(sector: sector, is_primary: false) end - it "matches a sector tagged directly on the org or via an affiliated person" do + it "matches a sector on the org or an affiliated person's primary, not their non-primary" do results = Organization.search_by_params(sector_name: "Housing") - expect(results).to include(direct_org, via_person_org) - expect(results).not_to include(untagged) + expect(results).to include(direct_org, primary_person_org) + expect(results).not_to include(additional_person_org, untagged) end - it "matches an age group tagged directly on the org or via an affiliated person" do + it "matches an age group on the org or an affiliated person's primary, not their additional" do results = Organization.search_by_params(age_group_name: "13-17") - expect(results).to include(direct_org, via_person_org) - expect(results).not_to include(untagged) + expect(results).to include(direct_org, primary_person_org) + expect(results).not_to include(additional_person_org, untagged) + end + end + + describe "#all_sectors" do + let!(:housing) { create(:sector, :published, name: "Housing") } + let!(:legal) { create(:sector, :published, name: "Legal") } + let!(:other) { create(:sector, :published, name: "Other Services") } + let(:organization) { create(:organization) } + + it "includes the org's own sectors and affiliated people's primary sector only" do + organization.sectorable_items.create!(sector: housing, is_primary: false) + person = create(:person) + create(:affiliation, organization: organization, person: person) + person.sectorable_items.create!(sector: legal, is_primary: true) + person.sectorable_items.create!(sector: other, is_primary: false) + + expect(organization.all_sectors).to contain_exactly(housing, legal) end end @@ -331,11 +357,13 @@ create(:affiliation, organization: organization, person: person_b) end - it "aggregates and dedupes age groups across affiliated people and the org itself" do - organization.tag_age_groups(primary_ids: [ adult.id ], additional_ids: []) - person_a.tag_age_groups(primary_ids: [ young.id ], additional_ids: [ teen.id ]) - person_b.tag_age_groups(primary_ids: [ young.id ], additional_ids: []) + it "includes org-direct age groups and affiliated people's primary (not their additional)" do + organization.tag_age_groups(primary_ids: [ adult.id ], additional_ids: [ teen.id ]) + person_a.tag_age_groups(primary_ids: [ young.id ], additional_ids: []) + person_b.tag_age_groups(primary_ids: [], additional_ids: [ young.id ]) + # org primary (adult) + people's primary (young); org's own additional (teen) + # stays; person_b's additional (young) is ignored. expect(organization.all_primary_age_groups).to contain_exactly(young, adult) expect(organization.all_additional_age_groups).to contain_exactly(teen) end diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index afec6f9380..e511b0ec06 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -103,8 +103,8 @@ affiliated_sector_2 = create(:sector, name: "Affiliated Sector 2") person_1 = create(:person) person_2 = create(:person) - create(:sectorable_item, sector: affiliated_sector_1, sectorable: person_1) - create(:sectorable_item, sector: affiliated_sector_2, sectorable: person_2) + create(:sectorable_item, sector: affiliated_sector_1, sectorable: person_1, is_primary: true) + create(:sectorable_item, sector: affiliated_sector_2, sectorable: person_2, is_primary: true) org = create(:organization, organization_status: organization_status) create(:sectorable_item, sector: create(:sector, name: "Direct Sector 1"), sectorable: org) From a5a3b7be7c3138ec94e5af7f1e948b8ef54feb20 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 1 Aug 2026 23:06:23 -0400 Subject: [PATCH 14/40] Keep the 6 legacy org statuses; bucket them for the program-status UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the org-status simplification: restores the six-value constant, drops the data migration (leaving the records/mappings as-is), and points the sync callbacks and seeds back at "Inactive". The program-status chip and index filter now collapse the stored six values into three display buckets via OrganizationStatus::PROGRAM_STATUS_BUCKETS (Active/Reinstate → Active, Inactive/Suspended → Formerly active, Pending/Unknown/none → Never active), so the data is untouched while the UI still reads as Active / Formerly active / Never active. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 26 ++++++++++------ ...iliation_facilitator_warning_controller.js | 3 ++ app/models/affiliation.rb | 20 ++++++------ app/models/organization.rb | 11 ++++--- app/models/organization_status.rb | 23 +++++++++++++- app/views/organizations/_form.html.erb | 2 +- ...15191749_simplify_organization_statuses.rb | 31 ------------------- db/seeds/dev/organizations.rb | 15 ++++----- .../decorators/organization_decorator_spec.rb | 23 +++++++------- spec/models/affiliation_spec.rb | 24 +++++++------- spec/models/organization_spec.rb | 19 +++++++----- .../organization_facilitator_warning_spec.rb | 6 ++-- .../index.html.erb_spec.rb | 2 +- .../views/organizations/edit.html.erb_spec.rb | 2 +- .../organizations/index.html.erb_spec.rb | 4 +-- 15 files changed, 109 insertions(+), 102 deletions(-) delete mode 100644 db/migrate/20260715191749_simplify_organization_statuses.rb diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index dc0fa1d401..395a9d20ac 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -105,20 +105,26 @@ def program_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations.select(&:facilitator?)) || "" end - # The org's stored program status (organization_status), with "Unknown" — and a - # missing status — displayed as "Never active". + ORG_STATUS_BUCKET_LABELS = { + active: "Active", formerly_active: "Formerly active", never_active: "Never active" + }.freeze + ORG_STATUS_BUCKET_THEMES = { + active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active + }.freeze + + # The org's program-status bucket (:active / :formerly_active / :never_active), + # collapsing the stored six-value organization_status. + def organization_status_bucket + OrganizationStatus.program_bucket(object.organization_status&.name) + end + def organization_status_label - name = object.organization_status&.name - name.blank? || name == "Unknown" ? "Never active" : name + ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) end - # Pill classes for the org-wide status chip, keyed off the stored status name. + # Pill classes for the org-wide status chip, keyed off the program-status bucket. def organization_status_classes - theme_key = case object.organization_status&.name - when "Active" then :org_active - when "Formerly active" then :org_formerly_active - else :org_never_active - end + theme_key = ORG_STATUS_BUCKET_THEMES.fetch(organization_status_bucket) [ DomainTheme.bg_class_for(theme_key, intensity: 100), DomainTheme.text_class_for(theme_key, intensity: 700), diff --git a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js index 03d63f011c..702edbf6a8 100644 --- a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js +++ b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js @@ -17,10 +17,13 @@ export default class extends Controller { this.handleSubmit = (event) => this.guardSubmit(event); // Capture phase so this runs before other submit listeners (e.g. dirty-form). this.element.addEventListener("submit", this.handleSubmit, true); + // Signal that snapshots are captured so tests can wait before interacting. + this.element.dataset.affiliationFacilitatorWarningReady = ""; } disconnect() { this.element.removeEventListener("submit", this.handleSubmit, true); + delete this.element.dataset.affiliationFacilitatorWarningReady; } guardSubmit(event) { diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index fac44cfe3c..6f780a6218 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -198,29 +198,29 @@ def sync_organization_status_with_affiliations end def deactivate_organization_if_no_active_people - formerly_active_status = OrganizationStatus.find_by(name: "Formerly active") - return unless formerly_active_status - return if organization.organization_status_id == formerly_active_status.id + inactive_status = OrganizationStatus.find_by(name: "Inactive") + return unless inactive_status + return if organization.organization_status_id == inactive_status.id - organization.update_column(:organization_status_id, formerly_active_status.id) + organization.update_column(:organization_status_id, inactive_status.id) Ahoy::Tracker.new(user: Current.user).track( "autochange.organization", resource_type: "Organization", resource_id: organization.id, resource_title: organization.name, - change: "status_set_to_formerly_active", + change: "status_set_to_inactive", reason: "no_active_affiliations" ) end - # Only flip back from "Formerly active" — the status the deactivation callback - # sets. Leave Unknown (and Active) untouched. + # Only flip back from "Inactive" — the status the deactivation callback sets. + # Leave Pending/Reinstate/Unknown (and Active) untouched. def reactivate_organization_if_inactive - formerly_active_status = OrganizationStatus.find_by(name: "Formerly active") + inactive_status = OrganizationStatus.find_by(name: "Inactive") active_status = OrganizationStatus.find_by(name: "Active") - return unless formerly_active_status && active_status - return unless organization.organization_status_id == formerly_active_status.id + return unless inactive_status && active_status + return unless organization.organization_status_id == inactive_status.id organization.update_column(:organization_status_id, active_status.id) diff --git a/app/models/organization.rb b/app/models/organization.rb index 08e5ebd25c..76a91a676b 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -109,13 +109,14 @@ def self.awbw # "never_active" covers stored "Unknown" and orgs with no status at all; # "formerly_or_never" is either of the two non-active buckets. scope :program_status, ->(bucket) { - by_name = ->(name) { where(organization_status_id: OrganizationStatus.where(name: name).select(:id)) } - never = -> { by_name.call("Unknown").or(where(organization_status_id: nil)) } + in_bucket = ->(b) { where(organization_status_id: OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(b)).select(:id)) } + # never_active also covers orgs with no stored status at all. + never = -> { in_bucket.call(:never_active).or(where(organization_status_id: nil)) } case bucket.to_s - when "active" then by_name.call("Active") - when "formerly_active" then by_name.call("Formerly active") + when "active" then in_bucket.call(:active) + when "formerly_active" then in_bucket.call(:formerly_active) when "never_active" then never.call - when "formerly_or_never" then by_name.call("Formerly active").or(never.call) + when "formerly_or_never" then in_bucket.call(:formerly_active).or(never.call) else all end } diff --git a/app/models/organization_status.rb b/app/models/organization_status.rb index 8cce7d9d8f..9bb33dda2c 100644 --- a/app/models/organization_status.rb +++ b/app/models/organization_status.rb @@ -1,7 +1,28 @@ class OrganizationStatus < ApplicationRecord - ORGANIZATION_STATUSES = [ "Active", "Formerly active", "Unknown" ] + ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ] + + # The stored values are kept as-is (legacy data), but the UI collapses them into + # three "program status" buckets for display and filtering. Anything unmapped — + # including a missing status — reads as :never_active. + PROGRAM_STATUS_BUCKETS = { + "Active" => :active, + "Reinstate" => :active, + "Inactive" => :formerly_active, + "Suspended" => :formerly_active, + "Pending" => :never_active, + "Unknown" => :never_active + }.freeze has_many :organizations validates :name, presence: true, uniqueness: true + + def self.program_bucket(name) + PROGRAM_STATUS_BUCKETS.fetch(name.to_s, :never_active) + end + + # Stored status names that fall into a given program-status bucket. + def self.names_for_bucket(bucket) + PROGRAM_STATUS_BUCKETS.select { |_, value| value == bucket }.keys + end end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 127400c961..0160441cf6 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -160,7 +160,7 @@ <% end %> - <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Formerly active" %> + <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %> <% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %> <% show_status_select = allowed_to?(:manage?, Organization) && (params[:admin] || (f.object.persisted? && !status_matches_affiliations)) %> diff --git a/db/migrate/20260715191749_simplify_organization_statuses.rb b/db/migrate/20260715191749_simplify_organization_statuses.rb deleted file mode 100644 index 0d5abac972..0000000000 --- a/db/migrate/20260715191749_simplify_organization_statuses.rb +++ /dev/null @@ -1,31 +0,0 @@ -class SimplifyOrganizationStatuses < ActiveRecord::Migration[8.1] - # Retiring the six-value status set down to Active / Formerly active / Unknown. - # Each retired status folds into one of the survivors; Reinstate counts as Active. - RETIRED_TO_REPLACEMENT = { - "Reinstate" => "Active", - "Inactive" => "Formerly active", - "Suspended" => "Formerly active", - "Pending" => "Unknown" - }.freeze - - def up - %w[Active Unknown].each { |name| OrganizationStatus.find_or_create_by!(name: name) } - OrganizationStatus.find_or_create_by!(name: "Formerly active") - - RETIRED_TO_REPLACEMENT.each do |old_name, new_name| - old_status = OrganizationStatus.find_by(name: old_name) - next unless old_status - - new_status = OrganizationStatus.find_by!(name: new_name) - # Bulk remap intentionally bypasses callbacks/validations — repointing a FK. - Organization.where(organization_status_id: old_status.id).update_all(organization_status_id: new_status.id) - old_status.destroy! - end - end - - def down - # Best effort: recreate the retired status records so they're selectable again. - # The original per-organization mappings can't be reconstructed. - RETIRED_TO_REPLACEMENT.each_key { |name| OrganizationStatus.find_or_create_by!(name: name) } - end -end diff --git a/db/seeds/dev/organizations.rb b/db/seeds/dev/organizations.rb index cc97d273a7..2860b9b793 100644 --- a/db/seeds/dev/organizations.rb +++ b/db/seeds/dev/organizations.rb @@ -3,28 +3,29 @@ puts "Creating Organizations…" active_status = OrganizationStatus.find_by!(name: "Active") -formerly_active_status = OrganizationStatus.find_by!(name: "Formerly active") -unknown_status = OrganizationStatus.find_by!(name: "Unknown") +inactive_status = OrganizationStatus.find_by!(name: "Inactive") +pending_status = OrganizationStatus.find_by!(name: "Pending") +suspended_status = OrganizationStatus.find_by!(name: "Suspended") adult_wt = WindowsType.find_by!(short_name: "Adult") children_wt = WindowsType.find_by!(short_name: "Children") combined_wt = WindowsType.find_by!(short_name: "Combined") [ - { name: "1736 Family Crisis Center", organization_status: formerly_active_status, windows_type: adult_wt }, + { name: "1736 Family Crisis Center", organization_status: inactive_status, windows_type: adult_wt }, { name: "Angel Step Inn", organization_status: active_status, windows_type: adult_wt }, { name: "YWCA of San Diego - Becky's House", organization_status: active_status, windows_type: children_wt }, { name: "Good Shepherd Shelter", organization_status: active_status, windows_type: adult_wt }, { name: "One Safe Place", organization_status: active_status, windows_type: adult_wt }, { name: "Haven Hills", organization_status: active_status, windows_type: children_wt }, { name: "Survivor's Art Circle", organization_status: active_status, windows_type: children_wt }, - { name: "YWCA Spokane", organization_status: formerly_active_status, windows_type: adult_wt }, - { name: "Center for Battered Women", organization_status: unknown_status, windows_type: children_wt }, + { name: "YWCA Spokane", organization_status: inactive_status, windows_type: adult_wt }, + { name: "Center for Battered Women", organization_status: pending_status, windows_type: children_wt }, { name: "Asian Women Shelter", organization_status: active_status, windows_type: adult_wt }, { name: "Deaf Hope", organization_status: active_status, windows_type: children_wt }, { name: "YWCA of Monterey County", organization_status: active_status, windows_type: adult_wt }, - { name: "Joyful Heart Foundation", organization_status: formerly_active_status, windows_type: adult_wt }, - { name: "Domestic Violence Center of Santa Clarita Valley", organization_status: unknown_status, windows_type: adult_wt }, + { name: "Joyful Heart Foundation", organization_status: suspended_status, windows_type: adult_wt }, + { name: "Domestic Violence Center of Santa Clarita Valley", organization_status: pending_status, windows_type: adult_wt }, { name: "Abused Women's Aid in Crisis", organization_status: active_status, windows_type: adult_wt }, { name: "Friends of the Family", organization_status: active_status, windows_type: adult_wt }, { name: "Haven House", organization_status: active_status, windows_type: adult_wt }, diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index ef38aeaf10..f7f547e6c2 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -40,14 +40,15 @@ end describe "#organization_status_label" do - it "returns the stored status name" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) - expect(org.decorate.organization_status_label).to eq("Active") - end - - it "renders Unknown as 'Never active'" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) - expect(org.decorate.organization_status_label).to eq("Never active") + { + "Active" => "Active", "Reinstate" => "Active", + "Inactive" => "Formerly active", "Suspended" => "Formerly active", + "Pending" => "Never active", "Unknown" => "Never active" + }.each do |stored, label| + it "collapses stored '#{stored}' to '#{label}'" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: stored)) + expect(org.decorate.organization_status_label).to eq(label) + end end it "renders a missing status as 'Never active'" do @@ -58,8 +59,8 @@ end describe "#organization_status_chip" do - it "renders a pill with the label and its status color" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) + it "renders a pill with the bucketed label and its status color" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) chip = org.decorate.organization_status_chip expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") expect(chip).to include("orange") @@ -75,7 +76,7 @@ end it "falls back to the status label (orange) when there are no facilitator years" do - org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) chip = org.decorate.program_since_chip("") expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") expect(chip).to include("orange") diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index 81e976e6c2..fa1ff6a1a2 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -154,19 +154,19 @@ describe '#sync_organization_status_with_affiliations' do let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") } - let!(:formerly_active_status) { OrganizationStatus.find_or_create_by!(name: "Formerly active") } + let!(:inactive_status) { OrganizationStatus.find_or_create_by!(name: "Inactive") } - it 'sets the organization to Formerly active when its last active affiliation goes inactive' do + it 'sets the organization to Inactive when its last active affiliation goes inactive' do org = create(:organization, organization_status: active_status) affiliation = create(:affiliation, organization: org, inactive: false, end_date: nil) affiliation.update!(inactive: true) - expect(org.reload.organization_status).to eq(formerly_active_status) + expect(org.reload.organization_status).to eq(inactive_status) end - it 'sets a Formerly active organization back to Active when it regains an active affiliation' do - org = create(:organization, organization_status: formerly_active_status) + it 'sets an Inactive organization back to Active when it regains an active affiliation' do + org = create(:organization, organization_status: inactive_status) create(:affiliation, organization: org, inactive: false, end_date: nil) @@ -177,16 +177,18 @@ org = create(:organization, organization_status: active_status) create(:affiliation, organization: org, title: "Volunteer", inactive: false, end_date: nil) - expect(org.reload.organization_status).to eq(formerly_active_status) + expect(org.reload.organization_status).to eq(inactive_status) end - it "leaves an Unknown organization untouched when it regains an active affiliation" do - status = OrganizationStatus.find_or_create_by!(name: "Unknown") - org = create(:organization, organization_status: status) + %w[Pending Reinstate Unknown].each do |status_name| + it "leaves a #{status_name} organization untouched when it regains an active affiliation" do + status = OrganizationStatus.find_or_create_by!(name: status_name) + org = create(:organization, organization_status: status) - create(:affiliation, organization: org, inactive: false, end_date: nil) + create(:affiliation, organization: org, inactive: false, end_date: nil) - expect(org.reload.organization_status).to eq(status) + expect(org.reload.organization_status).to eq(status) + end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 673bdd3b81..96b502536d 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -265,24 +265,27 @@ describe ".program_status scope" do let!(:active) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) } - let!(:formerly) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Formerly active")) } + let!(:reinstate) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")) } + let!(:inactive) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) } + let!(:suspended) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")) } + let!(:pending) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) } let!(:unknown) { create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) } let!(:no_status) { create(:organization).tap { |o| o.update_columns(organization_status_id: nil) } } - it "buckets active" do - expect(Organization.program_status("active")).to contain_exactly(active) + it "buckets Active + Reinstate as active" do + expect(Organization.program_status("active")).to contain_exactly(active, reinstate) end - it "buckets formerly_active" do - expect(Organization.program_status("formerly_active")).to contain_exactly(formerly) + it "buckets Inactive + Suspended as formerly_active" do + expect(Organization.program_status("formerly_active")).to contain_exactly(inactive, suspended) end - it "treats Unknown and no-status as never_active" do - expect(Organization.program_status("never_active")).to contain_exactly(unknown, no_status) + it "buckets Pending, Unknown, and no-status as never_active" do + expect(Organization.program_status("never_active")).to contain_exactly(pending, unknown, no_status) end it "combines formerly + never" do - expect(Organization.program_status("formerly_or_never")).to contain_exactly(formerly, unknown, no_status) + expect(Organization.program_status("formerly_or_never")).to contain_exactly(inactive, suspended, pending, unknown, no_status) end end diff --git a/spec/system/organization_facilitator_warning_spec.rb b/spec/system/organization_facilitator_warning_spec.rb index 3461cbc61b..98272d6d53 100644 --- a/spec/system/organization_facilitator_warning_spec.rb +++ b/spec/system/organization_facilitator_warning_spec.rb @@ -16,7 +16,7 @@ def visit_and_wait(path) visit path - expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) + expect(page).to have_css("[data-affiliation-facilitator-warning-ready]", wait: 10) end def set_date_input(input, value) @@ -27,8 +27,8 @@ def set_date_input(input, value) end def row_for(title) - all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| - f.find("input[name*='title']").value.include?(title) + all("#affiliations .nested-fields").find { |f| + f.find("textarea[name*='title']").value.include?(title) } end diff --git a/spec/views/organization_statuses/index.html.erb_spec.rb b/spec/views/organization_statuses/index.html.erb_spec.rb index 2dd247277e..a9865031ff 100644 --- a/spec/views/organization_statuses/index.html.erb_spec.rb +++ b/spec/views/organization_statuses/index.html.erb_spec.rb @@ -3,7 +3,7 @@ RSpec.describe "organization_statuses/index", type: :view do let(:admin) { create(:user, :admin) } let(:organization_status1) { create(:organization_status, name: "Active") } - let(:organization_status2) { create(:organization_status, name: "Formerly active") } + let(:organization_status2) { create(:organization_status, name: "Suspended") } before(:each) do assign(:organization_statuses, paginated([ organization_status1, organization_status2 ])) diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index dafc13e7d6..c1902bcd74 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -65,7 +65,7 @@ def org_with_status(name) end it "shows the status select and the red mismatch hint when the status does not match the affiliation-calculated status" do - org = org_with_status("Formerly active") + org = org_with_status("Pending") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render diff --git a/spec/views/organizations/index.html.erb_spec.rb b/spec/views/organizations/index.html.erb_spec.rb index d97ecfb898..614478c0b5 100644 --- a/spec/views/organizations/index.html.erb_spec.rb +++ b/spec/views/organizations/index.html.erb_spec.rb @@ -8,8 +8,8 @@ let!(:organization2) { create(:organization, name: "Organization 2") } let!(:organization_status1) { create(:organization_status, name: "Active") } - let!(:organization_status2) { create(:organization_status, name: "Formerly active") } - let!(:organization_status3) { create(:organization_status, name: "Unknown") } + let!(:organization_status2) { create(:organization_status, name: "Suspended") } + let!(:organization_status3) { create(:organization_status, name: "Inactive") } before(:each) do assign(:organizations, paginated([ organization1, organization2 ])) From b0991b62005c6996973e0940cbf25e3ac831de55 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 08:52:26 -0400 Subject: [PATCH 15/40] Rename EventRegistration organization_status scope to organization_linking_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It filters by the registrant's org-linking status (linked/pending), which is unrelated to the org's own OrganizationStatus — the old name was confusing. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events_controller.rb | 2 +- app/models/event_registration.rb | 13 +++++++------ app/services/reminder_recipient_filter.rb | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 79638d7a6d..eb8fab51a5 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -214,7 +214,7 @@ def registrants scope = scope.funder_name(params[:funder_name]) if params[:funder_name].present? scope = scope.submission_status(params[:submission_status], @event) if params[:submission_status].present? scope = scope.registrant_city(params[:city]) if params[:city].present? - scope = scope.organization_status(params[:org_status], @event) if params[:org_status].present? + scope = scope.organization_linking_status(params[:org_status], @event) if params[:org_status].present? scope = scope.account_status(params[:account_status]) if params[:account_status].present? scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present? scope = scope.registrant_state(params[:state]) if params[:state].present? diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 41e9666078..ea62970ce9 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -428,12 +428,13 @@ def self.scholarship_allocatable_ids(scholarships) else all end } - # "linked" = at least one organization linked; "unlinked" = no organization - # linked (whether or not an agency name was submitted); "pending" = the - # registrant submitted an agency name on the event's registration form but - # nothing is linked yet (mirrors the Pending chip on the roster). Needs the - # event to resolve its registration form's agency_name field. - scope :organization_status, ->(value, event) { + # Filters by the registrant's organization-LINKING status (not the org's own + # OrganizationStatus): "linked" = at least one organization linked; "unlinked" = + # no organization linked (whether or not an agency name was submitted); "pending" + # = the registrant submitted an agency name on the event's registration form but + # nothing is linked yet (mirrors the Pending chip on the roster). Needs the event + # to resolve its registration form's agency_name field. + scope :organization_linking_status, ->(value, event) { linked = EventRegistrationOrganization.select(:event_registration_id) case value when "linked" then where(id: linked) diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index f8e28cde9f..715a8142ce 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -71,7 +71,7 @@ def dropdown_matched_ids scope = scope.ce_status(@params[:ce_status]) if @params[:ce_status].present? scope = scope.scholarship_status(@params[:scholarship]) if @params[:scholarship].present? scope = scope.comment_status(@params[:comment_status]) if @params[:comment_status].present? - scope = scope.organization_status(@params[:org_status], @event) if @params[:org_status].present? + scope = scope.organization_linking_status(@params[:org_status], @event) if @params[:org_status].present? scope = scope.account_status(@params[:account_status]) if @params[:account_status].present? scope = scope.submission_status(@params[:submission_status], @event) if @params[:submission_status].present? scope = scope.registrant_state(@params[:state]) if @params[:state].present? From 0f604018bdfbe67f091bfa4cbe81928af8c107af Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 09:00:46 -0400 Subject: [PATCH 16/40] Derive program status from facilitator affiliations, stored status as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The program-status chip (OrganizationDecorator#organization_status_bucket) and the index filter (Organization.program_status scope) now compute from facilitator affiliations: an active facilitator affiliation => Active, facilitator affiliations but none active => Formerly active. Only when an org has NO facilitator affiliations do they fall back to the stored organization_status bucket — so a manual "Active" still backs an org into Active, and Pending/Suspended keep their buckets. Manual override and the stored data are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 12 +++++++--- app/models/organization.rb | 24 +++++++++++++------ .../decorators/organization_decorator_spec.rb | 14 +++++++++++ spec/models/organization_spec.rb | 23 ++++++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 395a9d20ac..ff71432445 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -112,10 +112,16 @@ def program_since_display(affiliations = object.affiliations) active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active }.freeze - # The org's program-status bucket (:active / :formerly_active / :never_active), - # collapsing the stored six-value organization_status. + # The org's program-status bucket (:active / :formerly_active / :never_active). + # Derived from facilitator affiliations when the org has any (an active one => + # :active, otherwise :formerly_active); only when the org has NO facilitator + # affiliations does it fall back to the stored organization_status (so a manual + # "Active" backs it into :active, and Pending/Suspended keep their buckets). def organization_status_bucket - OrganizationStatus.program_bucket(object.organization_status&.name) + facilitators = object.affiliations.select(&:facilitator?) + return OrganizationStatus.program_bucket(object.organization_status&.name) if facilitators.none? + + facilitators.any?(&:active?) ? :active : :formerly_active end def organization_status_label diff --git a/app/models/organization.rb b/app/models/organization.rb index 76a91a676b..27ca980e3d 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -108,15 +108,25 @@ def self.awbw # Index filter over the stored organization_status, bucketed for display: # "never_active" covers stored "Unknown" and orgs with no status at all; # "formerly_or_never" is either of the two non-active buckets. + # Program status filter: facilitator affiliations win — an active facilitator + # affiliation => active, facilitator affiliations but none active => formerly + # active. Orgs with NO facilitator affiliations fall back to the stored + # organization_status bucket (a missing status counts as never active). scope :program_status, ->(bucket) { - in_bucket = ->(b) { where(organization_status_id: OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(b)).select(:id)) } - # never_active also covers orgs with no stored status at all. - never = -> { in_bucket.call(:never_active).or(where(organization_status_id: nil)) } + fac_ids = Affiliation.facilitators.select(:organization_id) + active_fac_ids = Affiliation.facilitators.active.select(:organization_id) + # Orgs with no facilitator affiliations whose stored status is in the bucket. + stored = ->(b) { where.not(id: fac_ids).where(organization_status_id: OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(b)).select(:id)) } + stored_never = -> { + never_ids = OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(:never_active)).pluck(:id) + where.not(id: fac_ids).where(organization_status_id: never_ids + [ nil ]) + } + formerly = -> { where(id: fac_ids).where.not(id: active_fac_ids).or(stored.call(:formerly_active)) } case bucket.to_s - when "active" then in_bucket.call(:active) - when "formerly_active" then in_bucket.call(:formerly_active) - when "never_active" then never.call - when "formerly_or_never" then in_bucket.call(:formerly_active).or(never.call) + when "active" then where(id: active_fac_ids).or(stored.call(:active)) + when "formerly_active" then formerly.call + when "never_active" then stored_never.call + when "formerly_or_never" then formerly.call.or(stored_never.call) else all end } diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index f7f547e6c2..57bf332aff 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -58,6 +58,20 @@ end end + describe "#organization_status_bucket (facilitator affiliations win over stored status)" do + it "is :active for an active facilitator affiliation even when stored status is Suspended" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:active) + end + + it "is :formerly_active for only-lapsed facilitator affiliations even when stored status is Active" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + expect(org.reload.decorate.organization_status_bucket).to eq(:formerly_active) + end + end + describe "#organization_status_chip" do it "renders a pill with the bucketed label and its status color" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Inactive")) diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 96b502536d..6b86bd4b4c 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -287,6 +287,29 @@ it "combines formerly + never" do expect(Organization.program_status("formerly_or_never")).to contain_exactly(inactive, suspended, pending, unknown, no_status) end + + context "when the org has facilitator affiliations (they win over stored status)" do + let!(:active_fac_org) do + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")).tap do |o| + create(:affiliation, organization: o, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + end + end + let!(:lapsed_fac_org) do + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")).tap do |o| + create(:affiliation, organization: o, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + end + end + + it "buckets an active facilitator as active, regardless of the stored status" do + expect(Organization.program_status("active")).to include(active_fac_org) + expect(Organization.program_status("active")).not_to include(lapsed_fac_org) + end + + it "buckets only-lapsed facilitators as formerly_active, regardless of the stored status" do + expect(Organization.program_status("formerly_active")).to include(lapsed_fac_org) + expect(Organization.program_status("formerly_active")).not_to include(active_fac_org) + end + end end describe "search_by_params sector and age-group filters" do From 9d6c87cff9b99f7fd675369223c12551eb0a7f39 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 23:29:13 -0400 Subject: [PATCH 17/40] Point per-event program-status chips at the participation report; drop dead windows-type filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The per-event chips now open the event participation report filtered to that event (participation_events_path event_id: …, with a dashboard back-link), replacing the dashboard placeholder now that the report exists on main. - Remove the now-dead windows_type_name filter from Organization.search_by_params and the unused :windows_type eager-load on the index (the windows-type column and dropdown were both removed earlier). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 2 +- app/models/organization.rb | 1 - app/views/organizations/_form.html.erb | 2 +- app/views/organizations/show.html.erb | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 612b5e6097..6dbceab04f 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,7 +8,7 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :windows_type, :organization_status, :sectors, :addresses, :affiliations, + :organization_status, :sectors, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, logo_attachment: :blob )) diff --git a/app/models/organization.rb b/app/models/organization.rb index 27ca980e3d..a62d4aaba3 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -169,7 +169,6 @@ def self.search_by_params(params) organizations = organizations.sector_name_including_people(params[:sector_name]) if params[:sector_name].present? organizations = organizations.age_group_name_including_people(params[:age_group_name]) if params[:age_group_name].present? organizations = organizations.address(params[:address]) if params[:address].present? - organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? organizations = organizations.program_status(params[:program_status]) if params[:program_status].present? organizations diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 0160441cf6..7a15a16523 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -334,7 +334,7 @@ <%= org_decorated.organization_status_chip %> <% org_events.each do |event| %> <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to dashboard_event_path(event), target: "_blank", rel: "noopener noreferrer", + <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index f13d268ad6..8128e65d95 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -116,7 +116,7 @@ <%= org_decorated.organization_status_chip %> <% @organization_events.each do |event| %> <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to dashboard_event_path(event), target: "_blank", rel: "noopener noreferrer", + <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> From 8e2512ab009a3eb317535cf94c7278d081d556cb Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 23:47:42 -0400 Subject: [PATCH 18/40] Use a real stored status name ("Inactive") in search spec "Formerly active" is a display bucket, not a stored OrganizationStatus value; the search spec should set up an org with an actual legacy status. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/models/organization_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 6b86bd4b4c..f29f97a2aa 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -224,7 +224,7 @@ describe '.search_by_params' do let!(:active_status) { create(:organization_status, name: "Active") } - let!(:inactive_status) { create(:organization_status, name: "Formerly active") } + let!(:inactive_status) { create(:organization_status, name: "Inactive") } let!(:active_org) { create(:organization, name: "Community Center", organization_status: active_status) } let!(:inactive_org) { create(:organization, name: "Old Program", organization_status: inactive_status) } From fe2670d61a3ea79a2472fc3582ec8d695377652b Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 00:55:56 -0400 Subject: [PATCH 19/40] Admin-only-blue org index rows (like people); bullet the Program status tooltip - The org index is admin-only (OrganizationPolicy#index? => admin?), so tint each row admin-only bg-blue-100 unless the org is published, mirroring the people index. Simplifies the Program-since cell (drops the now-dead non-admin branch). - Break the edit-form "Program status" tooltip's New/Ongoing/Reinstated onto their own bulleted lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/_form.html.erb | 5 ++++- app/views/organizations/organizations_results.html.erb | 9 ++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 7a15a16523..9bd5f69af4 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -328,7 +328,10 @@ Program status
<%= org_decorated.organization_status_chip %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index fcc1e36029..daf394ebe8 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -21,7 +21,7 @@
<% status_label = organization.published? ? nil : organization.organization_status&.name %> <%= organization_profile_button(organization, truncate_at: 30, subtitle: organization.organization_locality, label: status_label, data: { turbo_frame: "_top" }) %> @@ -46,12 +46,7 @@ - <% program_since = @program_since_display[organization.id] %> - <% if allowed_to?(:manage?, Organization) %> - <%= organization.decorate.program_since_chip(program_since) %> - <% elsif program_since.present? %> - <%= program_since %> - <% end %> + <%= organization.decorate.program_since_chip(@program_since_display[organization.id]) %> From c542fc44b8241e2cca98a01d0444efb98817ee81 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 01:04:30 -0400 Subject: [PATCH 20/40] Live-update the org edit form's Program status chip The affiliation-dates Stimulus controller was dropped from the org form when "Affiliated since" moved server-side, which also stopped the Program status chip from reacting as facilitator rows are edited. Re-attach the controller and derive the chip's bucket client-side (mirroring OrganizationDecorator#organization_status_bucket): active when any Facilitator row is still active, formerly active when they've all ended, else the stored-status fallback. Bucket labels/classes come from the decorator so no theme classes are hard-coded in JS. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 32 ++++++++++-- .../affiliation_dates_controller.js | 33 +++++++++++- app/views/organizations/_form.html.erb | 13 +++-- .../organization_program_status_spec.rb | 51 +++++++++++++++++++ 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 spec/system/organization_program_status_spec.rb diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index ff71432445..ef7d450bf8 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -124,13 +124,22 @@ def organization_status_bucket facilitators.any?(&:active?) ? :active : :formerly_active end + # The stored-status bucket, used as the fallback when an org has no facilitator + # affiliations (mirrors organization_status_bucket's fallback branch). Exposed + # so the edit form's Stimulus controller can restore it live if the last + # facilitator row is removed. + def stored_status_bucket + OrganizationStatus.program_bucket(object.organization_status&.name) + end + def organization_status_label ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) end - # Pill classes for the org-wide status chip, keyed off the program-status bucket. - def organization_status_classes - theme_key = ORG_STATUS_BUCKET_THEMES.fetch(organization_status_bucket) + # Pill classes for a given program-status bucket, built from the DomainTheme + # swatch so the colours stay consistent with the rest of the app. + def self.status_classes_for_bucket(bucket) + theme_key = ORG_STATUS_BUCKET_THEMES.fetch(bucket) [ DomainTheme.bg_class_for(theme_key, intensity: 100), DomainTheme.text_class_for(theme_key, intensity: 700), @@ -138,9 +147,24 @@ def organization_status_classes ].join(" ") end + # Every bucket's label + pill classes, so the edit form's Stimulus controller + # can re-render the status chip live as facilitator rows change without + # hard-coding any theme classes in JS. + def self.status_bucket_styles + ORG_STATUS_BUCKET_LABELS.each_key.to_h do |bucket| + [ bucket, { label: ORG_STATUS_BUCKET_LABELS.fetch(bucket), classes: status_classes_for_bucket(bucket) } ] + end + end + + # Pill classes for the org-wide status chip, keyed off the program-status bucket. + def organization_status_classes + self.class.status_classes_for_bucket(organization_status_bucket) + end + # Rendered org-wide status chip (Active / Formerly active / Never active). - def organization_status_chip + def organization_status_chip(data: {}) h.content_tag(:span, organization_status_label, + data: data, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 0a5a6befe2..d88710888c 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -1,11 +1,20 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer"] + static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] // Orgs render "Affiliated since" server-side as merged periods (AffiliationPeriods), // so the controller leaves that field alone; the person form keeps the live single // Mon YYYY – Mon YYYY range. - static values = { serverAffiliatedSince: Boolean } + // + // Program status (org edit form): derived live from the visible Facilitator rows, + // mirroring OrganizationDecorator#organization_status_bucket. statusBuckets holds + // each bucket's label + pill classes (from DomainTheme) and statusFallback is the + // stored-status bucket to show when there are no facilitator rows. + static values = { + serverAffiliatedSince: Boolean, + statusBuckets: Object, + statusFallback: String + } initialize() { this.boundRecalculate = () => this.recalculate() @@ -79,6 +88,26 @@ export default class extends Controller { if (this.hasFacilitatorSinceTarget) { this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) } + + // Program status — active when any Facilitator row is still active, formerly + // active when they've all ended, else the stored-status fallback. + if (this.hasProgramStatusTarget) { + let bucket + if (facilitatorAffiliations.length === 0) { + bucket = this.statusFallbackValue + } else { + bucket = allFacInactive ? "formerly_active" : "active" + } + this.updateProgramStatus(bucket) + } + } + + updateProgramStatus(bucket) { + const style = this.statusBucketsValue[bucket] + if (!style) return + const base = "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5" + this.programStatusTarget.textContent = style.label + this.programStatusTarget.className = `${base} ${style.classes}` } getVisibleAffiliations() { diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 9bd5f69af4..27c8955b02 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,4 +1,9 @@ -<%= simple_form_for(@organization, html: { data: { controller: "affiliation-facilitator-warning" } }) do |f| %> +<%= simple_form_for(@organization, html: { data: { + controller: "affiliation-dates affiliation-facilitator-warning", + affiliation_dates_server_affiliated_since_value: true, + affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json, + affiliation_dates_status_fallback_value: @organization.decorate.stored_status_bucket + } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> <% has_affiliations = f.object.persisted? && f.object.affiliations.any? %> @@ -334,13 +339,13 @@ • Reinstated — had facilitators but all have ended.

- <%= org_decorated.organization_status_chip %> + <%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }) %> <% org_events.each do |event| %> <% status = org_decorated.facilitator_status_as_of(event.start_date) %> <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> - <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> + <%= status.to_s.titleize %> · <%= event.decorate.dated_compact_label.truncate(24) %> <% end %> <% end %>
@@ -348,7 +353,7 @@ <% end %> <% if allowed_to?(:manage?, Organization) %> -
+
<%= f.fields_for :affiliations do |affiliation_form| %>
<%= render "affiliation_fields", diff --git a/spec/system/organization_program_status_spec.rb b/spec/system/organization_program_status_spec.rb new file mode 100644 index 0000000000..8a7a85a4f2 --- /dev/null +++ b/spec/system/organization_program_status_spec.rb @@ -0,0 +1,51 @@ +require "rails_helper" + +RSpec.describe "Organization program status live update", type: :system do + let(:admin) { create(:user, :admin) } + let!(:person) { create(:person) } + let!(:pending_status) { create(:organization_status, name: "Pending") } + let!(:organization) { create(:organization, organization_status: pending_status) } + + before do + driven_by(:selenium_chrome_headless) + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: "2020-03-01", end_date: nil) + sign_in admin + end + + def visit_and_wait(path) + visit path + expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) + end + + def set_date_input(input, value) + page.execute_script( + "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))", + input, value + ) + end + + def status_chip + find("[data-affiliation-dates-target='programStatus']") + end + + it "flips Active to Formerly active when the facilitator's end date moves to the past" do + visit_and_wait edit_organization_path(organization) + expect(status_chip).to have_text("Active") + + end_input = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields input[name*='end_date']") + set_date_input(end_input, "2020-06-01") + + expect(status_chip).to have_text("Formerly active", wait: 5) + end + + it "falls back to the stored status when the only facilitator is removed" do + visit_and_wait edit_organization_path(organization) + expect(status_chip).to have_text("Active") + + row = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields") + row.find("a", text: "Remove").click + + expect(status_chip).to have_text("Never active", wait: 5) + end +end From dd881f6889f5205bfa36b44551a64f907cc9f00a Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 01:12:09 -0400 Subject: [PATCH 21/40] Clarify the Reinstated tooltip: re-engaging after being formerly active Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 27c8955b02..3d9198fd37 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -336,7 +336,7 @@

Overall status first (Active / Formerly active / Never active), then the facilitator-program status as of each event's date:
New — no facilitator affiliations yet
Ongoing — has an active facilitator
- • Reinstated — had facilitators but all have ended.

+ • Reinstated — new again, but formerly active (all prior facilitations had ended).

<%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }) %> From 84603706fa9a97460a11c2967253938f8bcd3524 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 02:28:53 -0400 Subject: [PATCH 22/40] Document org affiliation/program-status semantics (ADR-0001); gate per-event chips to trainings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0001 pins the definitions we kept re-deriving: affiliated-since (all affiliations), program-since (facilitator affiliations), the org-wide status bucket, and the per-event New/Ongoing/Reinstate status — including that it's per-event (not per-registrant, no self-exclusion) and only meaningful on facilitator-training events. Per that decision, the per-event "Program status by event" chips now render only for facilitator_training events the org is represented at. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 16 +- ...nization-affiliation-and-program-status.md | 147 ++++++++++++++++++ spec/requests/organizations_spec.rb | 19 +++ 3 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0001-organization-affiliation-and-program-status.md diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 6dbceab04f..4ac6ce2831 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -47,9 +47,13 @@ def show track_view(@organization) # Events for the admin-only "Program status" block (facilitator status as of - # each event). Skip the query for non-managers, who don't see the block. + # each event). Only facilitator-training events — program status is meaningless + # for other events (see ADR-0001). Skip the query for non-managers, who don't + # see the block. @organization_events = if allowed_to?(:manage?, @organization) - Event.where(id: @organization.event_registrations.active.select(:event_id)).order(start_date: :desc) + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .where(facilitator_training: true) + .order(start_date: :desc) else Event.none end @@ -185,11 +189,13 @@ def set_form_variables @organization.affiliations.proxy_association.target.replace(sorted) end - # Events the org is represented at, newest first — drives the per-event - # "Program status by event" chips in the Affiliations section. Program status - # (New/Ongoing/Reinstate) is only meaningful relative to a specific event date. + # Facilitator-training events the org is represented at, newest first — drives + # the per-event "Program status by event" chips in the Affiliations section. + # Program status (New/Ongoing/Reinstate) is only meaningful for a facilitator- + # training event, relative to its start date (see ADR-0001). @organization_events = if @organization.persisted? Event.where(id: @organization.event_registrations.active.select(:event_id)) + .where(facilitator_training: true) .order(start_date: :desc) else Event.none diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md new file mode 100644 index 0000000000..846b7f896c --- /dev/null +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -0,0 +1,147 @@ +# ADR-0001 — Organization affiliation dates & program status + +- **Status:** Accepted +- **Date:** 2026-08-03 + +## Context + +The organization profile/edit page surfaces several affiliation-derived figures +that are easy to confuse with one another: + +- **Affiliated since** +- **Facilitations/program since** +- an org-wide **status chip** (Active / Formerly active / Never active) +- per-event **program-status chips** (New / Ongoing / Reinstate) + +Several code paths compute overlapping-but-distinct classifications with subtle +differences — different reference dates, whether the "registrant's own" +affiliation is excluded, and strict-vs-inclusive date boundaries. We kept +re-deriving these rules from scratch. This ADR pins the definitions and the +decisions that resolve the ambiguities so they're written down once. + +## Vocabulary + +- **Affiliation** — an Org ↔ Person link (`affiliations` table) with `title`, + `start_date`, `end_date`, and a cached `inactive` flag. **Not tied to any + event** (there is no `event_id` on an affiliation). +- **Facilitator affiliation** — an affiliation whose `title` is **exactly + `"Facilitator"`** (trimmed, case-sensitive). No fuzzy/`LIKE` matching; "Lead + Facilitator" and "facilitator" do **not** count. See `Affiliation#facilitator?` + and the `.facilitators` scope. +- **Active affiliation** — `inactive == false` **and** (`end_date` is null or + `>= today`). `inactive` is a cached column derived from the dates on save + (`set_inactive_from_dates`: `inactive = end_date.present? && end_date < today`), + so in practice "active" reduces to **no end date, or end date ≥ today**. +- **Facilitator-training event** — `events.facilitator_training == true`. The + only events for which per-event program status is meaningful. + +## Decisions + +### D1 — "Affiliated since": all affiliations, org-only + +Keyed off **all** of the org's affiliations (any title), with **nothing to do +with a registrant**. Rendered as merged year-based periods +(`AffiliationPeriods.label`), e.g. `2010-2012, 2026`; falls back to the org's own +`start_date`, then blank. See `OrganizationDecorator#affiliated_since_display`. + +### D2 — "Facilitations/program since": facilitator affiliations only + +Same merged-year-period rendering as D1, but over **facilitator** affiliations +only. Blank when the org has never facilitated. See +`OrganizationDecorator#program_since_display`. + +### D3 — Org-wide status chip: Active / Formerly active / Never active + +Three display buckets (`OrganizationDecorator#organization_status_bucket`), **not +event-relative**: + +- When the org has **any** facilitator affiliation: any **active** one → **Active**; + otherwise → **Formerly active**. +- When the org has **no** facilitator affiliation: fall back to the stored + `OrganizationStatus`, bucketed via `PROGRAM_STATUS_BUCKETS`: + - `Active`, `Reinstate` → **Active** + - `Inactive`, `Suspended` → **Formerly active** + - `Pending`, `Unknown` → **Never active** + +On the edit form this chip **live-updates** from the visible facilitator rows. + +### D4 — Per-event program status: New / Ongoing / Reinstate + +**One value per (org, event)**, keyed off the **event's start date**, computed +over **all** of the org's facilitator affiliations as of that date +(`Organization#facilitator_status_on` / `OrganizationDecorator#facilitator_status_as_of`): + +- **New** — no facilitator affiliation started **before** the event date + (strict `<`). An affiliation starting **on** the event date, if it is the org's + first, reads **New**. _(e.g. event starts Feb 14, the org's first facilitator + affiliation starts Feb 14 → New as of that event.)_ +- **Ongoing** — an earlier facilitator affiliation is **still active** at the + event date. +- **Reinstate** — earlier facilitator affiliation(s) existed but **all ended** + before the event date (a lapse, now returning). + +### D5 — Per-event status is per-EVENT, not per-registrant (no self-exclusion) + +Program status includes **all** of the org's facilitator affiliations, including +those held by the registrants at the event. We do **not** exclude "the +registrant's own affiliation." + +Previously `EventRegistration#program_statuses` and +`EventDashboard#program_status_for` excluded it (to answer "was the org already a +program *before I joined*"); that per-registrant framing is **dropped**. The +question is per-event: "at this event, was the org New / Ongoing / Reinstate?" + +### D6 — Per-event chips only on facilitator-training events + +The org-profile per-event chips render **only** for events where +`facilitator_training == true` (among the events the org is represented at via +active registrations). Attendance at a non-training event does **not** produce a +program-status chip — this is what stops attendance-only events from reading +"New" or "Reinstate". + +### D7 — Reference date = the event's start date + +The classification anchors on the event's actual `start_date`. + +## Boundary conventions + +- **Strict `<`** for "earlier": `start_date == event date` is **not** earlier + (so a same-day first affiliation is **New**, not Ongoing). +- **Active-at-date** uses `end_date IS NULL OR end_date >= reference`. + +## The classifiers (map) + +| Method | Role | +|---|---| +| `Organization#facilitator_status_on(date, excluding_affiliation_id:)` | Canonical SQL classifier. | +| `OrganizationDecorator#facilitator_status_as_of(date)` | In-memory mirror for the org-profile chips. | +| `Organization#facilitator_status(affiliation)` | Thin wrapper — **no callers, retire it**. | +| `Organization#program_status(recipient)` | Scholarship-index string variant, **recipient-relative** — a distinct context (see below). | + +## Consequences / follow-up code changes + +1. **Remove self-exclusion (D5):** `EventRegistration#program_statuses` → + `organization.facilitator_status_on(reference_date)` (drop the `own` lookup); + `EventDashboard#program_status_for` collapses to + `organization.facilitator_status_on(reference_date)`. Update their specs (they + currently assert the exclusion). +2. **Gate org-profile chips to facilitator-training events (D6):** filter + `@organization_events` by `facilitator_training: true`. +3. **Align the reference date (D7):** `EventRegistration#program_statuses` + currently anchors on `event.start_date.beginning_of_month`; the org-profile + chip and `EventDashboard#reference_date` use the raw `event.start_date`. + Standardize on the **raw start date**. +4. **Retire** the dead `Organization#facilitator_status(affiliation)` wrapper. + +## Notes / open items + +- **Scholarship `program_status(recipient)`** stays recipient-relative (it + excludes the recipient's own affiliations to answer a scholarship-specific + question). It is intentionally **not** covered by D5; reconcile with the + per-event model later if the two need to agree. +- **Affiliation date precision:** if facilitator affiliations are ever recorded + at month precision (e.g. the 1st of the month) rather than the actual event + date, the raw-start-date anchor (D7) can read a same-month affiliation as + Ongoing instead of New. Observed data uses the actual event start date; + revisit if month-precision data appears (this is why `program_statuses` + originally used `beginning_of_month`). diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index e511b0ec06..9cbdee18a6 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -186,6 +186,25 @@ expect(response.body).to include("2010-2012, 2013-2015") end + + it "renders per-event program-status chips only for facilitator-training events" do + organization = Organization.create!(valid_attributes) + create(:affiliation, organization: organization, person: create(:person), + title: "Facilitator", start_date: Date.new(2020, 1, 1)) + training = create(:event, title: "Qwultz Training", facilitator_training: true, + start_date: Date.new(2026, 8, 1)) + non_training = create(:event, title: "Zibberpicnic Social", facilitator_training: false, + start_date: Date.new(2026, 8, 2)) + [ training, non_training ].each do |event| + registration = create(:event_registration, registrant: create(:person), event: event, status: "registered") + registration.event_registration_organizations.create!(organization: organization) + end + + get edit_organization_url(organization) + + expect(response.body).to include("Qwultz Training") + expect(response.body).not_to include("Zibberpicnic Social") + end end describe "POST /create" do From 5739567eb517d73b34fdba56d1f2eca5b39a4088 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 01:04:56 -0400 Subject: [PATCH 23/40] Live-update the org form's Affiliated-since periods The org edit form now live-updates "Affiliated since" as merged year-based periods (mirroring AffiliationPeriods, with the org start_date fallback), alongside the already-live program-status chip. Also adds a request spec asserting the event-registration linked-org chip links to the org profile (the chip itself is rendered by main's #2077). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../affiliation_dates_controller.js | 84 +++++++++++++++---- app/views/organizations/_form.html.erb | 23 +++-- app/views/organizations/show.html.erb | 4 +- spec/requests/event_registrations_spec.rb | 10 +++ spec/system/affiliation_dates_spec.rb | 29 +++++++ .../views/organizations/edit.html.erb_spec.rb | 52 +++++++++--- 6 files changed, 162 insertions(+), 40 deletions(-) diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index d88710888c..38f7cb5b7e 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -2,16 +2,19 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] - // Orgs render "Affiliated since" server-side as merged periods (AffiliationPeriods), - // so the controller leaves that field alone; the person form keeps the live single - // Mon YYYY – Mon YYYY range. + // "Affiliated since" has two live formats: the person form shows a single + // Mon YYYY – Mon YYYY range; the org form (affiliatedSincePeriods) shows merged + // year-based periods, mirroring the AffiliationPeriods service so the live value + // matches the server render. affiliatedSinceFallback is the org's own start_date + // (already formatted) shown when no affiliation carries a start date. // // Program status (org edit form): derived live from the visible Facilitator rows, // mirroring OrganizationDecorator#organization_status_bucket. statusBuckets holds // each bucket's label + pill classes (from DomainTheme) and statusFallback is the // stored-status bucket to show when there are no facilitator rows. static values = { - serverAffiliatedSince: Boolean, + affiliatedSincePeriods: Boolean, + affiliatedSinceFallback: String, statusBuckets: Object, statusFallback: String } @@ -54,19 +57,24 @@ export default class extends Controller { const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - // Affiliated since — single Mon YYYY range (person form). Orgs render this - // server-side as merged periods, so skip it there. - if (this.hasAffiliatedSinceTarget && !this.serverAffiliatedSinceValue) { - const startDates = affiliations.map(a => a.startDate).filter(Boolean) - const affiliatedSince = startDates.length - ? new Date(Math.min(...startDates.map(d => new Date(d)))) - : null - const allInactive = affiliations.length > 0 && - affiliations.every(a => a.endDate && new Date(a.endDate) < today) - const affiliatedEnd = allInactive - ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) - : null - this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) + // Affiliated since — the org form shows merged year-based periods, the person + // form a single Mon YYYY range. Both live-update from the visible rows. + if (this.hasAffiliatedSinceTarget) { + if (this.affiliatedSincePeriodsValue) { + const label = this.affiliatedSincePeriodsLabel(affiliations, today) || this.affiliatedSinceFallbackValue + this.affiliatedSinceTarget.textContent = label || "—" + } else { + const startDates = affiliations.map(a => a.startDate).filter(Boolean) + const affiliatedSince = startDates.length + ? new Date(Math.min(...startDates.map(d => new Date(d)))) + : null + const allInactive = affiliations.length > 0 && + affiliations.every(a => a.endDate && new Date(a.endDate) < today) + const affiliatedEnd = allInactive + ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) + : null + this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) + } } // Facilitations/program since — unchanged single-range display, filtered by @@ -130,6 +138,48 @@ export default class extends Controller { return `${months[date.getUTCMonth()]} ${date.getUTCFullYear()}` } + // Merged, year-based "Affiliated since" label for the org form, mirroring the + // AffiliationPeriods service: overlapping/touching intervals collapse into one + // period (a nil end is ongoing and swallows later intervals), a real gap starts + // a new one. A single ongoing period keeps month precision when it began this + // year; any multi-period list is year-only. Returns null when no affiliation + // carries a start date, so the caller can fall back to the org's start_date. + affiliatedSincePeriodsLabel(affiliations, today) { + const intervals = affiliations + .filter(a => a.startDate) + .map(a => [ new Date(a.startDate), a.endDate ? new Date(a.endDate) : null ]) + .sort((a, b) => a[0] - b[0]) + if (!intervals.length) return null + + const periods = [] + intervals.forEach(([ start, finish ]) => { + const last = periods[periods.length - 1] + if (last && (last[1] === null || start <= last[1])) { + last[1] = last[1] === null || finish === null ? null : new Date(Math.max(last[1], finish)) + } else { + periods.push([ start, finish ]) + } + }) + + const ongoing = finish => finish === null || finish >= today + // A single ongoing period is a fresh org — worth the month's precision. + if (periods.length === 1 && ongoing(periods[0][1])) { + return this.yearOrMonth(periods[0][0], today) + } + return periods + .map(([ start, finish ]) => + ongoing(finish) || start.getUTCFullYear() === finish.getUTCFullYear() + ? `${start.getUTCFullYear()}` + : `${start.getUTCFullYear()}-${finish.getUTCFullYear()}`) + .join(", ") + } + + yearOrMonth(date, today) { + return date.getUTCFullYear() === today.getUTCFullYear() + ? this.formatDate(date) + : `${date.getUTCFullYear()}` + } + updateDisplay(target, sinceDate, endDate) { if (!target) return let html = "" diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 3d9198fd37..73558c6726 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,6 +1,7 @@ <%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", - affiliation_dates_server_affiliated_since_value: true, + affiliation_dates_affiliated_since_periods_value: true, + affiliation_dates_affiliated_since_fallback_value: @organization.start_date&.strftime("%b %Y") || "", affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json, affiliation_dates_status_fallback_value: @organization.decorate.stored_status_bucket } }) do |f| %> @@ -167,8 +168,7 @@ <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %> <% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %> - <% show_status_select = allowed_to?(:manage?, Organization) && - (params[:admin] || (f.object.persisted? && !status_matches_affiliations)) %> + <% show_status_select = allowed_to?(:manage?, Organization) && params[:admin] %> <% unless show_status_select %> <%= f.hidden_field :organization_status_id, value: f.object.organization_status_id || OrganizationStatus.find_by(name: "Active")&.id %> <% end %> @@ -268,6 +268,13 @@

Does not match affiliations status

<% end %>
+ <% elsif has_affiliations && !status_matches_affiliations %> +
+ + +
<% end %> <% org_earliest_aff = f.object.persisted? ? f.object.affiliations.minimum(:start_date) : nil %> <% org_aff_ended = f.object.persisted? && f.object.affiliations.any? && !f.object.affiliations.active.exists? %> @@ -297,7 +304,7 @@ <% end %>
- <%= org_decorated.affiliated_since_display.presence || "—" %> + <%= org_decorated.affiliated_since_display.presence || "—" %> <% if has_affiliations && org_earliest_aff.nil? %> <% elsif f.object.start_date.present? && org_earliest_aff.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %> @@ -317,13 +324,13 @@
- <%= org_decorated.program_since_display.presence || "—" %> + <% if org_decorated.facilitation_end_date %><% end %><%= org_decorated.facilitator_since_date&.strftime("%b %Y") || "—" %><%= " – #{org_decorated.facilitation_end_date.strftime('%b %Y')}" if org_decorated.facilitation_end_date %>
<% if allowed_to?(:manage?, Organization) %> @@ -345,7 +352,7 @@ <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, 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.dated_compact_label.truncate(24) %> + <%= status.to_s.titleize %><%= " as of #{event.start_date.strftime('%b %Y')}" if event.start_date %> · <%= event.decorate.compact_label.truncate(24) %> <% end %> <% end %>
diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 8128e65d95..fc6553ef2b 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -58,7 +58,7 @@ <% program_since = org_decorated.program_since_display %> <% if program_since.present? %>

- Program since + Art program since <%= program_since %>

<% end %> @@ -119,7 +119,7 @@ <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> - <%= event.decorate.compact_label.truncate(24) %> · <%= status.to_s.titleize %> + <%= status.to_s.titleize %><%= " as of #{event.start_date.strftime('%b %Y')}" if event.start_date %> · <%= event.decorate.compact_label.truncate(24) %> <% end %> <% end %> diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index de94e15280..e3ff272eaf 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -431,6 +431,16 @@ def toggle_certificate(value) expect(response.body).to include("") end + it "renders each linked-organization chip as a new-tab link to the org profile" do + organization = create(:organization, name: "A Window Between Worlds") + existing_registration.organizations << organization + + get edit_event_registration_path(existing_registration) + + expect(response.body).to include("href=\"#{organization_path(organization)}\"") + expect(response.body).to include("A Window Between Worlds") + end + it "shows a Delete button for a deletable registration" do get edit_event_registration_path(existing_registration) diff --git a/spec/system/affiliation_dates_spec.rb b/spec/system/affiliation_dates_spec.rb index b6d7feb5d5..965c2aac9d 100644 --- a/spec/system/affiliation_dates_spec.rb +++ b/spec/system/affiliation_dates_spec.rb @@ -120,4 +120,33 @@ def set_text_input(input, value) affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) expect(affiliated).to have_text("Jun 2022", wait: 5) end + + # The org form renders "Affiliated since" as merged, year-based periods + # (AffiliationPeriods) rather than a single range; it must live-update in that + # same format so the value doesn't jump on save. + context "on the organization form" do + let!(:org_person) { create(:person) } + let!(:merged_org) { create(:organization) } + + before do + create(:affiliation, person: org_person, organization: merged_org, + title: "Facilitator", start_date: "2018-01-01", end_date: "2019-12-31") + create(:affiliation, person: org_person, organization: merged_org, + title: "Facilitator", start_date: "2022-01-01", end_date: nil) + end + + it "live-updates the merged 'Affiliated since' periods" do + visit_and_wait edit_organization_path(merged_org) + + affiliated = find("[data-affiliation-dates-target='affiliatedSince']") + expect(affiliated).to have_text("2018-2019, 2022") + + ongoing_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| + f.find("input[name*='start_date']").value == "2022-01-01" + } + set_date_input(ongoing_row.find("input[name*='start_date']"), "2021-05-01") + + expect(affiliated).to have_text("2018-2019, 2021", wait: 5) + end + end end diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index c1902bcd74..0eb263ab74 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -48,46 +48,72 @@ def org_with_status(name) ) end - it "shows the status select for an org with no affiliations" do + it "hides the status select without the admin param" do + assign(:organization, org_with_status("Active")) + render + assert_select "select[name=?]", "organization[organization_status_id]", false + assert_select "input[type=hidden][name=?]", "organization[organization_status_id]" + end + + it "shows the status select with the admin param" do + allow(view).to receive(:params).and_return(ActionController::Parameters.new(admin: "true")) assign(:organization, org_with_status("Active")) render assert_select "select[name=?]", "organization[organization_status_id]" end - it "hides the status select (and the mismatch hint) when the status matches the affiliation-calculated status" do - org = org_with_status("Active") + it "shows a warning icon (not the select) when the stored status does not match the affiliation status" do + org = org_with_status("Pending") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render assert_select "select[name=?]", "organization[organization_status_id]", false - assert_select "input[type=hidden][name=?]", "organization[organization_status_id]" - expect(rendered).not_to include("Does not match affiliations status") + assert_select "i.fa-triangle-exclamation" + expect(rendered).to include("Legacy organization status does not match affiliation status") end - it "shows the status select and the red mismatch hint when the status does not match the affiliation-calculated status" do - org = org_with_status("Pending") + it "shows no warning icon when the stored status matches the affiliation status" do + org = org_with_status("Active") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render - assert_select "select[name=?]", "organization[organization_status_id]" - assert_select "p.text-red-600", text: "Does not match affiliations status" + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + end + + describe "art program since" do + before(:each) { assign(:organization_statuses, OrganizationStatus.all) } + + around { |ex| travel_to(Date.new(2026, 8, 3)) { ex.run } } + + it "shows the earliest facilitator start (month precision), not the latest, wired for live updates" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2025, 8, 1), end_date: nil) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2026, 9, 1), end_date: nil) + assign(:organization, org.reload) + render + + assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Aug 2025/ + assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Sep 2026/, count: 0 end end describe "program status" do - it "renders an 'event · status' chip for each event the org is represented at" do + it "renders a 'status as of date · event' chip for each event the org is represented at" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) person = create(:person) create(:affiliation, organization: org, person: person, title: "Facilitator", - start_date: 1.year.ago, end_date: nil) - event = create(:event, title: "August Training", abbreviation: "PES205", start_date: 2.days.from_now) + start_date: Date.new(2020, 1, 1), end_date: nil) + event = create(:event, title: "August Training", abbreviation: "PES205", start_date: Date.new(2026, 8, 1)) assign(:organization, org.reload) assign(:organization_statuses, OrganizationStatus.all) assign(:organization_events, Event.where(id: event.id)) render - expect(rendered).to include("PES205 · Ongoing") + expect(rendered).to include("Ongoing as of Aug 2026 · PES205") end it "always shows the general status chip, even with no events" do From 83602db199fb865913c555019470e1020408d5ba Mon Sep 17 00:00:00 2001 From: maebeale Date: Thu, 6 Aug 2026 12:20:31 -0400 Subject: [PATCH 24/40] Fix events-section spec: gate the program-status chip event to trainings Per-event program-status chips only render for facilitator_training events (ADR-0001), but this example created a plain event, so no chip appeared and the "TOS205" assertion failed. Make the event a training so the chip renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/requests/organizations_events_section_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index 955d9d9918..4d2d48a185 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -58,7 +58,7 @@ def register(event:, status: "registered") end it "shows an admin program-status chip per event in the profile's Program status block" do - event = create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", start_date: 2.days.from_now) + event = create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", start_date: 2.days.from_now, facilitator_training: true) 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") From 42c031167512808b3bd3520297d7e47d92e75fe2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 15:08:41 -0400 Subject: [PATCH 25/40] =?UTF-8?q?Reorder=20per-event=20program-status=20ch?= =?UTF-8?q?ip=20to=20"Mon=20YYYY=20=C2=B7=20Status=20=C2=B7=20Event"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leads with the event date, then the facilitator-program status, then the event label — e.g. "Aug 2026 · Ongoing · PES205" — instead of "Ongoing as of Aug 2026 · …". Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/_form.html.erb | 2 +- app/views/organizations/show.html.erb | 2 +- spec/models/event_registration_spec.rb | 4 ++-- spec/views/organizations/edit.html.erb_spec.rb | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 73558c6726..b12838a05b 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -352,7 +352,7 @@ <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, 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 %><%= " as of #{event.start_date.strftime('%b %Y')}" if event.start_date %> · <%= event.decorate.compact_label.truncate(24) %> + <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> <% end %> <% end %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index fc6553ef2b..96136bf9e9 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -119,7 +119,7 @@ <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", title: event.title, 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 %><%= " as of #{event.start_date.strftime('%b %Y')}" if event.start_date %> · <%= event.decorate.compact_label.truncate(24) %> + <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> <% end %> <% end %> diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 4afa565438..ec905ea01b 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -952,8 +952,8 @@ def registration_with_scholarship create(:event_registration_organization, event_registration: linked) unlinked = create(:event_registration, event: event) - expect(EventRegistration.organization_status("linked", event)).to contain_exactly(linked) - expect(EventRegistration.organization_status("unlinked", event)).to contain_exactly(unlinked) + expect(EventRegistration.organization_linking_status("linked", event)).to contain_exactly(linked) + expect(EventRegistration.organization_linking_status("unlinked", event)).to contain_exactly(unlinked) end end diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 0eb263ab74..199a9ae958 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -101,7 +101,7 @@ def org_with_status(name) end describe "program status" do - it "renders a 'status as of date · event' chip for each event the org is represented at" do + it "renders a 'date · status · event' chip for each event the org is represented at" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) person = create(:person) create(:affiliation, organization: org, person: person, title: "Facilitator", @@ -113,7 +113,7 @@ def org_with_status(name) assign(:organization_events, Event.where(id: event.id)) render - expect(rendered).to include("Ongoing as of Aug 2026 · PES205") + expect(rendered).to include("Aug 2026 · Ongoing · PES205") end it "always shows the general status chip, even with no events" do From 2e1b9aa3076942066dbebaf9e1439b494c442d2f Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 15 Aug 2026 10:01:59 -0400 Subject: [PATCH 26/40] Remove fallback to org status --- app/controllers/organizations_controller.rb | 2 + app/decorators/organization_decorator.rb | 25 +++++--- .../affiliation_dates_controller.js | 14 ++--- app/helpers/event_participation_helper.rb | 23 +++++++ app/models/organization.rb | 51 ++++++++------- app/models/organization_status.rb | 12 ++-- app/views/events/participation.html.erb | 9 ++- app/views/organizations/_form.html.erb | 22 +++---- .../_program_status_event_chips.html.erb | 14 +++++ .../organizations_results.html.erb | 9 ++- app/views/organizations/show.html.erb | 12 +--- ...nization-affiliation-and-program-status.md | 26 +++++--- .../decorators/organization_decorator_spec.rb | 59 ++++++++++++----- spec/models/organization_spec.rb | 63 ++++++++----------- spec/requests/events_spec.rb | 28 +++++++++ .../organizations_events_section_spec.rb | 6 ++ .../organizations_index_preloading_spec.rb | 45 +++++++++++++ .../organization_program_status_spec.rb | 8 ++- .../views/organizations/edit.html.erb_spec.rb | 39 ++++++++++++ 19 files changed, 322 insertions(+), 145 deletions(-) create mode 100644 app/views/organizations/_program_status_event_chips.html.erb create mode 100644 spec/requests/organizations_index_preloading_spec.rb diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 4ac6ce2831..d208374295 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -10,6 +10,8 @@ def index base_scope = authorized_scope(Organization.includes( :organization_status, :sectors, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, + # Feeds the sector and age-group roll-ups per row — see Organization#affiliated_people. + { people: Organization::PEOPLE_TAGGINGS }, logo_attachment: :blob )) filtered = base_scope.search_by_params(params).order(:name) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index ef7d450bf8..d078251e5b 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -112,26 +112,31 @@ def program_since_display(affiliations = object.affiliations) active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active }.freeze - # The org's program-status bucket (:active / :formerly_active / :never_active). - # Derived from facilitator affiliations when the org has any (an active one => - # :active, otherwise :formerly_active); only when the org has NO facilitator - # affiliations does it fall back to the stored organization_status (so a manual - # "Active" backs it into :active, and Pending/Suspended keep their buckets). + # The org's program-status bucket (:active / :formerly_active / :never_active), + # derived purely from facilitator affiliations: an active one => :active, only + # ended ones => :formerly_active, none at all => :never_active. The stored + # organization_status never feeds into this (see ADR-0001 D3). def organization_status_bucket facilitators = object.affiliations.select(&:facilitator?) - return OrganizationStatus.program_bucket(object.organization_status&.name) if facilitators.none? + return :never_active if facilitators.none? facilitators.any?(&:active?) ? :active : :formerly_active end - # The stored-status bucket, used as the fallback when an org has no facilitator - # affiliations (mirrors organization_status_bucket's fallback branch). Exposed - # so the edit form's Stimulus controller can restore it live if the last - # facilitator row is removed. + # The bucket the stored (legacy) OrganizationStatus would imply. Not used to + # decide the org's status — only to flag on the edit form where the legacy + # column disagrees with the affiliations. def stored_status_bucket OrganizationStatus.program_bucket(object.organization_status&.name) end + # True when the legacy OrganizationStatus column contradicts what the org's + # facilitator affiliations say — e.g. a stored "Active" on an org that has never + # had a facilitator affiliation. Surfaced as a warning on the edit form. + def legacy_status_mismatch? + organization_status_bucket != stored_status_bucket + end + def organization_status_label ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 38f7cb5b7e..36936a6f9c 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -8,15 +8,13 @@ export default class extends Controller { // matches the server render. affiliatedSinceFallback is the org's own start_date // (already formatted) shown when no affiliation carries a start date. // - // Program status (org edit form): derived live from the visible Facilitator rows, - // mirroring OrganizationDecorator#organization_status_bucket. statusBuckets holds - // each bucket's label + pill classes (from DomainTheme) and statusFallback is the - // stored-status bucket to show when there are no facilitator rows. + // Program status (org edit form): derived live from the visible Facilitator rows + // alone, mirroring OrganizationDecorator#organization_status_bucket. statusBuckets + // holds each bucket's label + pill classes (from DomainTheme). static values = { affiliatedSincePeriods: Boolean, affiliatedSinceFallback: String, - statusBuckets: Object, - statusFallback: String + statusBuckets: Object } initialize() { @@ -98,11 +96,11 @@ export default class extends Controller { } // Program status — active when any Facilitator row is still active, formerly - // active when they've all ended, else the stored-status fallback. + // active when they've all ended, never active when there are none. if (this.hasProgramStatusTarget) { let bucket if (facilitatorAffiliations.length === 0) { - bucket = this.statusFallbackValue + bucket = "never_active" } else { bucket = allFacInactive ? "formerly_active" : "active" } diff --git a/app/helpers/event_participation_helper.rb b/app/helpers/event_participation_helper.rb index 1398b6c712..1f48609ea1 100644 --- a/app/helpers/event_participation_helper.rb +++ b/app/helpers/event_participation_helper.rb @@ -1,4 +1,27 @@ module EventParticipationHelper + # Anchor on both organization pages' "Program status" block, so returning from + # the participation report lands on the chips the user clicked. + PROGRAM_STATUS_ANCHOR = "program-status".freeze + + # Eyebrow back-link for the participation report as [label, path]. The report is + # reachable from the reports hub, an event dashboard, and the program-status + # chips on an organization's profile or edit form; each origin passes return_to + # (plus the id the path needs) so the user goes back where they came from. + def participation_return_link + organization_id = params[:organization_id] + + case params[:return_to] + when "organization" + return [ "← Organization", organization_path(organization_id, anchor: PROGRAM_STATUS_ANCHOR) ] if organization_id.present? + when "organization_edit" + return [ "← Organization", edit_organization_path(organization_id, anchor: PROGRAM_STATUS_ANCHOR) ] if organization_id.present? + when "dashboard" + return [ "← Dashboard", dashboard_event_path(params[:event_id]) ] if params[:event_id].present? + end + + [ "← Reports", reports_events_path(report_to_hub_params) ] + end + # A small, neutral year-over-year change indicator for a headcount, e.g. # "▲ 12" / "▼ 3". Direction only, uncoloured. Returns nil when there's no prior # period or no change. diff --git a/app/models/organization.rb b/app/models/organization.rb index a62d4aaba3..289693cb09 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -37,6 +37,10 @@ class Organization < ApplicationRecord # catch-all; any stored value not in this list (e.g. a legacy label like the # pre-rename "Other (please specify below)") is folded into it for display so an # unmatched select can't silently save as the first option. + # The affiliated-people nest every org-level roll-up reads (see #affiliated_people). + # List pages must preload people with exactly this, or each row re-queries. + PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze + AGENCY_TYPE_OTHER = "Other" AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze @@ -105,28 +109,18 @@ def self.awbw end scope.distinct end - # Index filter over the stored organization_status, bucketed for display: - # "never_active" covers stored "Unknown" and orgs with no status at all; - # "formerly_or_never" is either of the two non-active buckets. - # Program status filter: facilitator affiliations win — an active facilitator + # Program-status filter, keyed purely off facilitator affiliations (the legacy + # organization_status plays no part — see ADR-0001 D3): an active facilitator # affiliation => active, facilitator affiliations but none active => formerly - # active. Orgs with NO facilitator affiliations fall back to the stored - # organization_status bucket (a missing status counts as never active). + # active, none at all => never active. scope :program_status, ->(bucket) { fac_ids = Affiliation.facilitators.select(:organization_id) active_fac_ids = Affiliation.facilitators.active.select(:organization_id) - # Orgs with no facilitator affiliations whose stored status is in the bucket. - stored = ->(b) { where.not(id: fac_ids).where(organization_status_id: OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(b)).select(:id)) } - stored_never = -> { - never_ids = OrganizationStatus.where(name: OrganizationStatus.names_for_bucket(:never_active)).pluck(:id) - where.not(id: fac_ids).where(organization_status_id: never_ids + [ nil ]) - } - formerly = -> { where(id: fac_ids).where.not(id: active_fac_ids).or(stored.call(:formerly_active)) } case bucket.to_s - when "active" then where(id: active_fac_ids).or(stored.call(:active)) - when "formerly_active" then formerly.call - when "never_active" then stored_never.call - when "formerly_or_never" then formerly.call.or(stored_never.call) + when "active" then where(id: active_fac_ids) + when "formerly_active" then where(id: fac_ids).where.not(id: active_fac_ids) + when "never_active" then where.not(id: fac_ids) + when "formerly_or_never" then where.not(id: active_fac_ids) else all end } @@ -293,6 +287,10 @@ def organization_locality def published? # needed for my_bookmarks return true if organization_status&.name == "Active" + # Affiliation#active? is the in-memory twin of the `active` scope, so a list + # page that preloaded affiliations doesn't query once per row. + return affiliations.any?(&:active?) if affiliations.loaded? + affiliations.active.exists? end @@ -307,8 +305,7 @@ def direct_sectors # Only affiliated people's PRIMARY sector (a person has at most one) — their # non-primary sectors don't roll up to the org. def affiliated_sectors - people.includes(sectorable_items: :sector) - .flat_map { |person| person.sectorable_items.filter_map { |item| item.sector if item.is_primary? } } + affiliated_people.flat_map { |person| person.sectorable_items.filter_map { |item| item.sector if item.is_primary? } } end def all_sectors @@ -344,16 +341,18 @@ def website_link_url private - # Union of the org's own age groups and its affiliated people's, deduped. The - # affiliated people (with their taggings) are loaded once and memoized so the - # several aggregation calls a page render makes don't re-query. + # Union of the org's own age groups and its affiliated people's, deduped. def collect_age_groups(kind) - ([ self ] + affiliated_people_with_age_data).flat_map { |record| record.public_send(kind) }.uniq + ([ self ] + affiliated_people).flat_map { |record| record.public_send(kind) }.uniq end - def affiliated_people_with_age_data - @affiliated_people_with_age_data ||= - people.includes(categorizable_items: { category: :category_type }).to_a + # The affiliated people behind every org-level roll-up (sectors and age groups), + # with the taggings those roll-ups read. Memoized, and reused as-is when the + # caller has already preloaded them — list pages preload `people` with the + # PEOPLE_TAGGINGS nest (see OrganizationsController#index) so a 25-row page + # doesn't re-query per org. Preloading a bare `:people` would defeat that. + def affiliated_people + @affiliated_people ||= people.loaded? ? people.to_a : people.includes(PEOPLE_TAGGINGS).to_a end def affiliation_dates_locked diff --git a/app/models/organization_status.rb b/app/models/organization_status.rb index 9bb33dda2c..7bb0edee84 100644 --- a/app/models/organization_status.rb +++ b/app/models/organization_status.rb @@ -1,9 +1,10 @@ class OrganizationStatus < ApplicationRecord ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ] - # The stored values are kept as-is (legacy data), but the UI collapses them into - # three "program status" buckets for display and filtering. Anything unmapped — - # including a missing status — reads as :never_active. + # The stored values are legacy: nothing derives an organization's program status + # from them any more (see ADR-0001 D3). This mapping survives only so the edit + # form can flag where the stored value contradicts the facilitator affiliations. + # Anything unmapped — including a missing status — reads as :never_active. PROGRAM_STATUS_BUCKETS = { "Active" => :active, "Reinstate" => :active, @@ -20,9 +21,4 @@ class OrganizationStatus < ApplicationRecord def self.program_bucket(name) PROGRAM_STATUS_BUCKETS.fetch(name.to_s, :never_active) end - - # Stored status names that fall into a given program-status bucket. - def self.names_for_bucket(bucket) - PROGRAM_STATUS_BUCKETS.select { |_, value| value == bucket }.keys - end end diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index 5e29d4e339..e4e649713d 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -5,11 +5,8 @@
- <% if params[:return_to] == "dashboard" && params[:event_id].present? %> - <%= link_to "← Dashboard", dashboard_event_path(params[:event_id]), class: "text-sm text-gray-500 hover:text-gray-700" %> - <% else %> - <%= link_to "← Reports", reports_events_path(report_to_hub_params), class: "text-sm text-gray-500 hover:text-gray-700" %> - <% end %> + <% label, return_path = participation_return_link %> + <%= link_to label, return_path, class: "text-sm text-gray-500 hover:text-gray-700" %> <%= render "events/report_subnav", current: :details %>
@@ -25,6 +22,8 @@ <%= form_with url: participation_events_path, method: :get, local: true, class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-8" do %> <%= hidden_field_tag :return_to, params[:return_to] %> + <%# Carries the origin org through a filter change, so the eyebrow survives it. %> + <%= hidden_field_tag :organization_id, params[:organization_id] %>
<%= render "time_period_filter" %> <%= render "event_type_filter" %> diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index b12838a05b..017ff92ccf 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -2,8 +2,7 @@ controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_affiliated_since_periods_value: true, affiliation_dates_affiliated_since_fallback_value: @organization.start_date&.strftime("%b %Y") || "", - affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json, - affiliation_dates_status_fallback_value: @organization.decorate.stored_status_bucket + affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> @@ -166,8 +165,9 @@
<% end %> - <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %> - <% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %> + <%# Compared as program-status buckets, not raw status names — the legacy column + no longer drives anything, so this only flags where it has gone stale. %> + <% status_matches_affiliations = !f.object.decorate.legacy_status_mismatch? %> <% show_status_select = allowed_to?(:manage?, Organization) && params[:admin] %> <% unless show_status_select %> <%= f.hidden_field :organization_status_id, value: f.object.organization_status_id || OrganizationStatus.find_by(name: "Active")&.id %> @@ -268,7 +268,7 @@

Does not match affiliations status

<% end %>
- <% elsif has_affiliations && !status_matches_affiliations %> + <% elsif allowed_to?(:manage?, Organization) && !status_matches_affiliations %>
<% if allowed_to?(:manage?, Organization) %> <% org_events = @organization_events || [] %> -
+
@@ -347,14 +347,8 @@
<%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }) %> - <% org_events.each do |event| %> - <% status = org_decorated.facilitator_status_as_of(event.start_date) %> - <%= link_to participation_events_path(event_id: event.id, return_to: "dashboard"), target: "_blank", rel: "noopener noreferrer", - title: event.title, - class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> - <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> - <% end %> - <% end %> + <%= render "organizations/program_status_event_chips", + organization: f.object, events: org_events, return_to: "organization_edit" %>
<% end %> diff --git a/app/views/organizations/_program_status_event_chips.html.erb b/app/views/organizations/_program_status_event_chips.html.erb new file mode 100644 index 0000000000..415d4444c9 --- /dev/null +++ b/app/views/organizations/_program_status_event_chips.html.erb @@ -0,0 +1,14 @@ +<%# Per-event program status (New / Ongoing / Reinstated as of each event's start + date). Rendered on both the org profile and the org edit form, so return_to + names which of the two the participation report should send the user back to — + the chips open in a new tab, where the back button is no help. %> +<% decorated = organization.decorate %> +<% events.each do |event| %> + <% status = decorated.facilitator_status_as_of(event.start_date) %> + <%= link_to participation_events_path(event_id: event.id, organization_id: organization.id, return_to: return_to), + target: "_blank", rel: "noopener noreferrer", + title: event.title, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> + <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> + <% end %> +<% end %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index daf394ebe8..010d3a7810 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -20,10 +20,13 @@
- <% status_label = organization.published? ? nil : organization.organization_status&.name %> + <% status_label = published ? nil : organization.organization_status&.name %> <%= organization_profile_button(organization, truncate_at: 30, subtitle: organization.organization_locality, label: status_label, data: { turbo_frame: "_top" }) %>
+ + + + + + + + + + + + <% years.each do |group| %> + <% if multi_year %> + + + + <% end %> + <% group.columns.each do |column| %> + + + + + + + + + <% end %> + <% if multi_year %> + + + + + + + + <% end %> + <% end %> + + + + + + + + +
TrainingStatus as ofNewOngoingReinstatedOrganizations
<%= group.year || "Undated" %>
+ <%= link_to column.label, dashboard_event_path(column.event), class: "text-blue-600 hover:underline", title: column.event.title %> + <% if column.date_label %><%= column.date_label %><% end %> + + <%= column.anchor_date&.strftime("%b %-d, %Y") || "—" %> + <%= column.new_count %><%= column.ongoing_count %><%= column.reinstated_count %><%= column.organization_count %>
<%= group.year || "Undated" %> total<%= group.new_count %><%= group.ongoing_count %><%= group.reinstated_count %><%= group.organization_count %>
<%= all_time ? "All-time total" : "Total" %><%= report.new_count %><%= report.ongoing_count %><%= report.reinstated_count %><%= report.organization_count %>
+
+ + <%# The rows above count an organization once per training it attended. This is + the same period counted once per organization, at the earliest training it + appeared at — the figure to report when "how many programs" means distinct + programs rather than program-events. %> +
+

+ Distinct organizations + +

+ <% groups = multi_year ? years : [] %> +
+ + + + + + + + + + + + <% groups.each do |group| %> + + + + + + + + <% end %> + + + + + + + + +
PeriodNewOngoingReinstatedOrganizations
<%= group.year || "Undated" %><%= group.distinct_new_count %><%= group.distinct_ongoing_count %><%= group.distinct_reinstated_count %><%= group.distinct_organization_count %>
<%= all_time ? "All time" : (years.first&.year || "Total") %><%= report.distinct_new_count %><%= report.distinct_ongoing_count %><%= report.distinct_reinstated_count %><%= report.distinct_organization_count %>
+
+ <% if report.repeat_organizations? %> +

+ The table above totals <%= pluralize(report.organization_count, "organization-training") %> across + <%= pluralize(report.distinct_organization_count, "distinct organization") %> — the difference is organizations that attended more than one training. +

+ <% end %> +
+ diff --git a/app/views/events/_program_status_summary.html.erb b/app/views/events/_program_status_summary.html.erb new file mode 100644 index 0000000000..6b3dc1a624 --- /dev/null +++ b/app/views/events/_program_status_summary.html.erb @@ -0,0 +1,45 @@ +<%# Compact program-status headline for the reports hub, linking to the full + report. Expects `report` (an EventProgramStatusReport) and `period` (its + resolved PeriodScope: #label, #year and #metrics). The tiles count + organizations once per training; the footnote gives the distinct-organization + figure, which is what "how many programs" usually means. %> +
+
+

Program status

+ <%= link_to "Details →", program_statuses_events_path(hub_to_report_params), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ + <% if report.any? %> + <% metrics = period.metrics %> +
<%= period.label %>
+
+
+
New
+
<%= number_with_delimiter(metrics.new_count) %>
+
+
+
Ongoing
+
<%= number_with_delimiter(metrics.ongoing_count) %>
+
+
+
Reinstated
+
<%= number_with_delimiter(metrics.reinstated_count) %>
+
+
+

+ <%= pluralize(metrics.organization_count, "organization-training") %> · + <%= metrics.distinct_organization_count %> distinct organizations +

+
+ <%= column_chart report.chart_series, id: "program-status-hub-chart", stacked: true, thousands: ",", + colors: [ "#6366f1", "#3b82f6", "#a855f7" ], height: "120px", legend: false, + library: { borderRadius: 3 } %> +
+ <% else %> +

No facilitator trainings match these filters.

+ <% end %> +
diff --git a/app/views/events/_registrant_breakdowns.html.erb b/app/views/events/_registrant_breakdowns.html.erb index 37c31676df..5591321789 100644 --- a/app/views/events/_registrant_breakdowns.html.erb +++ b/app/views/events/_registrant_breakdowns.html.erb @@ -167,7 +167,9 @@ <%# Col 3: org/program status → all organizations (2× tall) → all cities %>
<% if program_status_data.any? %> - <%= render "breakdown_card", title: "Organization/program status", data: program_status_data, chart: :pie, palette: program_status_palette, row_paths: program_status_paths %> + <%= render "breakdown_card", title: "Organization/program status", data: program_status_data, chart: :pie, + palette: program_status_palette, row_paths: program_status_paths, + note: program_status_column_note(local_assigns[:event]) %> <% end %> <% if org_rows.any? %> <%= render "events/organizations_breakdown", org_rows: org_rows, extra_class: "lg:row-span-2" %> diff --git a/app/views/events/_registrant_roster.html.erb b/app/views/events/_registrant_roster.html.erb index 65c90df0e3..53a3c06506 100644 --- a/app/views/events/_registrant_roster.html.erb +++ b/app/views/events/_registrant_roster.html.erb @@ -14,7 +14,11 @@ registration's status pill, linking to its edit page). Single-event only. Default false. registrants: - override the rows shown, for a page that filters its own table - from a breakdown drill-in. Defaults to every registrant the roster holds. %> + from a breakdown drill-in. Defaults to every registrant the roster holds. + program_status_event: - the event the Program status column was judged at, so + the header can name it ("Program status (TOS205)"). Omit on a cross-event + list: the header then says the statuses read as of the start of the year. %> +<% program_status_event = local_assigns[:program_status_event] %> <% show_event_column = local_assigns.fetch(:show_event_column, false) %> <% show_affiliation_status = local_assigns.fetch(:show_affiliation_status, false) %> <% show_attendance_status = local_assigns.fetch(:show_attendance_status, false) %> @@ -38,7 +42,7 @@ { index: 0, align: "text-left", parts: [ { label: "First", key: "first" }, { label: "Last", key: "last" } ] }, { index: 1, align: "text-left", parts: [ { label: "Primary sector", key: "sector" }, { label: "Primary age group", key: "age" } ] }, { label: "Organization", index: 2, align: "text-left" }, - { label: "Program status", index: 3, align: "text-left" }, + { label: program_status_column_label(program_status_event), note: program_status_column_note(program_status_event), index: 3, align: "text-left" }, { label: "Location", index: 4, align: "text-left" }, { label: "Scholarship", index: 5, align: "text-center" }, { label: "CE", index: 6, align: "text-center" } @@ -55,6 +59,10 @@ <% if i.positive? %><% end %> <%= render "shared/sortable_header", label: part[:label], index: col[:index], key: part[:key] %> <% end %> + <%# Says which date the column's verdicts were judged on. %> + <% if col[:note] %> + + <% end %> <% end %> @@ -117,13 +125,13 @@
<% statuses = roster.program_statuses_by_registrant[person.id] || [] %> - "> + "> <%= mobile_label.("Program status") %>
<% if statuses.any? %> <% statuses.each do |status| %> - <%= status.to_s.titleize %> + <%= status.label %> <% end %> <% else %> diff --git a/app/views/events/_report_subnav.html.erb b/app/views/events/_report_subnav.html.erb index 917032915e..d606b0815d 100644 --- a/app/views/events/_report_subnav.html.erb +++ b/app/views/events/_report_subnav.html.erb @@ -11,7 +11,8 @@ keeps the same population in scope. Each destination reads what it knows and ignores the rest. The current page renders as a non-link underlined tab, matching events/_subnav. - Locals: current (one of :details, :attendees, :scholarships, :signins). %> + Locals: current (one of :details, :attendees, :scholarships, :program_statuses, + :signins). %> <% carried = report_subnav_params %> <%# The report pages narrow by time_period; the attendees index has no such vocabulary — it narrows by the event's calendar year — so the period is @@ -29,6 +30,7 @@ { key: :details, label: "Details", path: participation_events_path(carried) }, { key: :attendees, label: "Attendees", path: attendees_events_path(attendee_filters) }, { key: :scholarships, label: "Scholarships", path: scholarships_events_path(carried) }, + { key: :program_statuses, label: "Program status", path: program_statuses_events_path(carried) }, { key: :signins, label: "Sign-ins", path: signins_events_path(carried) } ] %> <% tabs.each do |tab| %> diff --git a/app/views/events/onboarding/_results.html.erb b/app/views/events/onboarding/_results.html.erb index b48e00956a..18e80790d9 100644 --- a/app/views/events/onboarding/_results.html.erb +++ b/app/views/events/onboarding/_results.html.erb @@ -57,6 +57,10 @@ <% else %> <%= column[:label] %> <% end %> + <%# Says which date the column's verdicts were judged on. %> + <% if column[:note] %> + + <% end %> <% end %> diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 2a348c92e8..52518117ec 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -52,17 +52,17 @@ <% when :program_type %> <% statuses = registration.program_statuses %> <% org = registration.organizations.first %> - " data-sort-value="<%= statuses.map(&:to_s).sort.join %>"> + " data-sort-value="<%= statuses.map(&:label).sort.join %>"> <% if statuses.any? && org %> <%= link_to edit_organization_path(org, anchor: "affiliations", **onboarding_back), class: "inline-flex flex-col items-center gap-1 hover:opacity-80", title: "Edit #{org.name} affiliations", data: { turbo_frame: "_top" } do %> <% statuses.each do |status| %> - <%= render "shared/badge", label: status.to_s.titleize, classes: OrganizationDecorator.program_status_classes(status) %> + <%= render "shared/badge", label: status.label, classes: OrganizationDecorator.program_status_classes(status.status), title: status.explanation %> <% end %> <% end %> <% elsif statuses.any? %>
<% statuses.each do |status| %> - <%= render "shared/badge", label: status.to_s.titleize, classes: OrganizationDecorator.program_status_classes(status) %> + <%= render "shared/badge", label: status.label, classes: OrganizationDecorator.program_status_classes(status.status), title: status.explanation %> <% end %>
<% else %> diff --git a/app/views/events/program_statuses.html.erb b/app/views/events/program_statuses.html.erb new file mode 100644 index 0000000000..71208041e3 --- /dev/null +++ b/app/views/events/program_statuses.html.erb @@ -0,0 +1,48 @@ +<% content_for(:page_bg_class, "admin-or-owner bg-blue-100") %> +<%# Opt out of the layout's max-w-7xl cap to match the wider event manage pages. %> +<% content_for(:full_width, true) %> + +
+
+
+ <% if params[:return_to] == "dashboard" && params[:event_id].present? %> + <%= link_to "← Dashboard", dashboard_event_path(params[:event_id]), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% else %> + <%= link_to "← Reports", reports_events_path(report_to_hub_params), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% end %> + <%= render "events/report_subnav", current: :program_statuses %> +
+ + <%= render "events/report_header", + title: "Organization program status", + icon: "fa-solid fa-seedling", + theme: :organizations, + event: @filter_event, + subtitle: "How many organizations were New, Ongoing or Reinstated at each facilitator training — judged on that training's start date — with year totals for annual reporting." %> + + <%= form_with url: program_statuses_events_path, method: :get, local: true, + class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-8" do %> + <%= hidden_field_tag :return_to, params[:return_to] %> +
+ <%= render "report_filters", event_all_label: "All trainings" %> +
+ <% end %> + + <% if @report.any? %> + <%= render "program_status_report", report: @report, + all_time: @time_period.blank? || @time_period == "all_time" %> + +

+ New — the organization had no facilitator affiliation before the training's start date. + Ongoing — a facilitator affiliation was already active on that date. + Reinstated — it had facilitator affiliations before, but all had ended (a lapse, now returning). +
+ An affiliation starting on the training date — the one that training mints — doesn't count as prior history, so a first-time organization reads New at its own first training. +

+ <% else %> +
+ No facilitator trainings match these filters yet. +
+ <% end %> +
+
diff --git a/app/views/events/reports.html.erb b/app/views/events/reports.html.erb index e9200bf832..200c1ef316 100644 --- a/app/views/events/reports.html.erb +++ b/app/views/events/reports.html.erb @@ -44,6 +44,7 @@ <%= render "events/participation_summary", report: @participation_report, period: @participation_report.period_scope(@period) %> <%= render "events/scholarship_summary", report: @scholarship_report, period: @scholarship_report.period_scope(@period) %> <%= render "events/revenue_summary", report: @revenue_report, period: @revenue_report.period_scope(@period) %> + <%= render "events/program_status_summary", report: @program_status_report, period: @program_status_report.period_scope(@period) %>
diff --git a/app/views/events/roster.html.erb b/app/views/events/roster.html.erb index b02c595b72..c8478b553e 100644 --- a/app/views/events/roster.html.erb +++ b/app/views/events/roster.html.erb @@ -135,7 +135,7 @@ <% end %>
- <%= render "events/registrant_roster", roster: @dashboard, registrants: @roster_registrants, row_return_to: "roster", show_attendance_status: true %> + <%= render "events/registrant_roster", roster: @dashboard, registrants: @roster_registrants, row_return_to: "roster", show_attendance_status: true, program_status_event: @event %>
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index d8e80b9a9a..3ad6fbb8b9 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -278,8 +278,6 @@ <% end %> <% org_earliest_aff = f.object.persisted? ? f.object.affiliations.minimum(:start_date) : nil %> <% org_aff_ended = f.object.persisted? && f.object.affiliations.any? && !f.object.affiliations.active.exists? %> - <% org_latest_end = f.object.persisted? ? f.object.affiliations.maximum(:end_date) : nil %> - <% org_end_date = org_aff_ended ? org_latest_end : f.object.end_date %> <% org_decorated = f.object.decorate %>
diff --git a/app/views/organizations/_program_status_event_chips.html.erb b/app/views/organizations/_program_status_event_chips.html.erb index 7a026f481e..0821c1d6e3 100644 --- a/app/views/organizations/_program_status_event_chips.html.erb +++ b/app/views/organizations/_program_status_event_chips.html.erb @@ -1,14 +1,16 @@ -<%# Per-event program status (New / Ongoing / Reinstated as of each event's start +<%# Per-event program status (New / Ongoing / Reinstate as of each event's start date). Rendered on both the org profile and the org edit form, so return_to names which of the two the participation report should send the user back to — - the chips open in a new tab, where the back button is no help. %> + the chips open in a new tab, where the back button is no help. Each chip hovers + to explain the verdict: the anchor date, what made the program active, and the + facilitator periods behind it (FacilitatorProgramStatus#explanation). %> <% decorated = organization.decorate %> <% events.each do |event| %> <% status = decorated.facilitator_status_as_of(event.start_date) %> <%= link_to participation_events_path(event_id: event.id, return_organization_id: organization.id, return_to: return_to), target: "_blank", rel: "noopener noreferrer", - title: event.title, - class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status)}" do %> - <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.to_s.titleize %> · <%= event.decorate.compact_label.truncate(24) %> + title: "#{event.title} — #{status.explanation}", + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status.status)}" do %> + <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.label %> · <%= event.decorate.compact_label.truncate(24) %> <% end %> <% end %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 010d3a7810..e54bc0b186 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -21,8 +21,9 @@ <% @organizations.each do |organization| %> <%# The status bucket is in the key because it turns on facilitator-affiliation - activity, which never touches the org row. %> - <% cache [organization, @program_since_display[organization.id], organization.decorate.organization_status_bucket, organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> + activity, which never touches the org row; rollup_cache_version covers the + sector/age cells, which aggregate across affiliated people. %> + <% cache [organization, organization.rollup_cache_version, @program_since_display[organization.id], organization.decorate.organization_status_bucket, organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> <% published = organization.published? %> "> diff --git a/config/routes.rb b/config/routes.rb index 6a0cbfa1ee..836f2b9c2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -165,6 +165,7 @@ get :participation get :reports get :scholarships + get :program_statuses get :attendees get :signins end diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index 1740940adc..c8c90752cb 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -83,31 +83,51 @@ show a warning where it contradicts the affiliations (`OrganizationDecorator#legacy_status_mismatch?`). Nothing else reads it. Expect the warning on a fair number of orgs — that is the drift it exists to surface. -### D4 — Per-event program status: New / Ongoing / Reinstate +### D4 — Program status: New / Ongoing / Reinstate, judged on one anchor date -**One value per (org, event)**, keyed off the **event's start date**, computed -over **all** of the org's facilitator affiliations as of that date -(`Organization#facilitator_status_on` / `OrganizationDecorator#facilitator_status_as_of`): +**One value per (organization, anchor date)**, computed over **all** of the org's +facilitator affiliations by a single class, `FacilitatorProgramStatus`: -- **New** — no facilitator affiliation started **before** the event date - (strict `<`). An affiliation starting **on** the event date, if it is the org's - first, reads **New**. _(e.g. event starts Feb 14, the org's first facilitator - affiliation starts Feb 14 → New as of that event.)_ -- **Ongoing** — an earlier facilitator affiliation is **still active** at the - event date. -- **Reinstate** — earlier facilitator affiliation(s) existed but **all ended** - before the event date (a lapse, now returning). +- **New** — no facilitator affiliation started **before** the anchor (strict `<`). +- **Ongoing** — an earlier facilitator affiliation is **still active** on the + anchor (no end date, or it ends on/after it). +- **Reinstated** — earlier facilitator affiliation(s) existed but **all ended** + before the anchor (a lapse, now returning). -### D5 — Per-event status is per-EVENT, not per-registrant (no self-exclusion) +`Organization#facilitator_program_status(as_of:)` returns that object; +`#facilitator_status_on(date)` is the bare symbol for counting and filtering. +Nothing else classifies a program — the dashboard breakdown, the onboarding +matrix, the rosters, the org profile/edit chips and the annual report all call +this one method, so they cannot disagree. + +The status object also carries **why**: the anchor date, the month the program +went (or last was) active, and the full facilitator history as merged periods +(`AffiliationPeriods`). `#explanation` renders that as one sentence, which every +display hangs on the badge as hover text. + +The symbols are `:new` / `:ongoing` / `:reinstated`, and `#label` is the one +display word — "New", "Ongoing", **"Reinstated"**. (The scholarship index's +separate `Organization#program_status(recipient)` says "Reinstate"; that string is +not this vocabulary.) + +### D5 — Per-event, not per-registrant: no self-exclusion Program status includes **all** of the org's facilitator affiliations, including those held by the registrants at the event. We do **not** exclude "the registrant's own affiliation." -Previously `EventRegistration#program_statuses` and -`EventDashboard#program_status_for` excluded it (to answer "was the org already a -program *before I joined*"); that per-registrant framing is **dropped**. The -question is per-event: "at this event, was the org New / Ongoing / Reinstate?" +The old per-registrant framing ("was the org already a program *before I +joined*?") is dropped. Two versions of it existed — +`EventRegistration#program_statuses` excluded the registrant's own affiliation as +of the event date, while `EventDashboard#program_status_for` re-anchored on that +affiliation's own start date — so the same org at the same event could read +Ongoing on the onboarding matrix and New in the dashboard pie, and the dashboard's +answer moved when a different registrant signed up. Both now ask the per-event +question: "at this event, was the org New / Ongoing / Reinstate?" + +**What makes this safe:** the affiliation a training mints starts **on the +training date** (D8), and "before" is strict, so a first-time organization still +reads New at its own first training without any exclusion. ### D6 — Per-event chips only on facilitator-training events @@ -117,39 +137,61 @@ active registrations). Attendance at a non-training event does **not** produce a program-status chip — this is what stops attendance-only events from reading "New" or "Reinstate". -### D7 — Reference date = the event's start date +### D7 — The anchor date: the event's start date, else January 1 -The classification anchors on the event's actual `start_date`. +The classification anchors on the event's actual `start_date` (not the 1st of its +month, not "today"), so revisiting a past event always reports what was true then. -## Boundary conventions +**With no event in view** — the cross-event attendees roster — there is no event +date to anchor on, so the status reads as of **January 1 of the current year**: +where each program stands this reporting year. `FacilitatorProgramStatus` applies +that fallback itself and flags `year_anchored?`, and those views carry a caveat +saying so. Any column showing a status names its anchor: "Program status (TOS205)" +in event context, with a hover giving the exact date. + +### D8 — Registration mints the facilitator affiliation, dated to the training -- **Strict `<`** for "earlier": `start_date == event date` is **not** earlier - (so a same-day first affiliation is **New**, not Ongoing). -- **Active-at-date** uses `end_date IS NULL OR end_date >= reference`. +A facilitator-training registration creates the registrant's Facilitator +affiliation at submission time (`AffiliationServices::CreateFromRegistration`, +from both the public registration flow and admin org-linking), with +`start_date = event.start_date`. Dating it to the training rather than to the +submission is what lets D5 drop self-exclusion: the minted row is never "prior +history" for its own training. An event with no start date is the one gap — the +affiliation then falls back to the creation date. That event has no anchor either, +so both its dashboard and the annual report read it as year-anchored (D7) rather +than one of them silently using "today". + +### D9 — Annual reporting counts organizations two ways + +`EventProgramStatusReport` (the "Program status" report page, alongside revenue / +participation / scholarships) reports, per facilitator training and grouped by +calendar year: + +- **Organization-trainings** (the row and year totals) — one count per + organization **per training**, using that training's own anchor date. An org at + three trainings in a year counts three times. This is the per-event figure that + adds up across a year. +- **Distinct organizations** — each organization counted **once** for the period, + classified at the **earliest** training it appeared at. This is "how many + distinct programs did we touch, and what were they when we first saw them." + +Both are shown, always labelled, because they answer different questions and only +coincide when no organization attended twice. ## The classifiers (map) | Method | Role | |---|---| -| `Organization#facilitator_status_on(date, excluding_affiliation_id:)` | Canonical SQL classifier. | -| `OrganizationDecorator#facilitator_status_as_of(date)` | In-memory mirror for the org-profile chips. | -| `Organization#facilitator_status(affiliation)` | Thin wrapper — **no callers, retire it**. | +| `FacilitatorProgramStatus` | The rule. Verdict + anchor + reasoning. | +| `Organization#facilitator_program_status(as_of:)` | Entry point; reads preloaded affiliations. | +| `Organization#facilitator_status_on(date)` | The bare symbol, for counting/filtering. | | `Organization#program_status(recipient)` | Scholarship-index string variant, **recipient-relative** — a distinct context (see below). | -## Consequences / follow-up code changes - -1. **Remove self-exclusion (D5):** `EventRegistration#program_statuses` → - `organization.facilitator_status_on(reference_date)` (drop the `own` lookup); - `EventDashboard#program_status_for` collapses to - `organization.facilitator_status_on(reference_date)`. Update their specs (they - currently assert the exclusion). -2. **Gate org-profile chips to facilitator-training events (D6):** filter - `@organization_events` by `facilitator_training: true`. -3. **Align the reference date (D7):** `EventRegistration#program_statuses` - currently anchors on `event.start_date.beginning_of_month`; the org-profile - chip and `EventDashboard#reference_date` use the raw `event.start_date`. - Standardize on the **raw start date**. -4. **Retire** the dead `Organization#facilitator_status(affiliation)` wrapper. +## Boundary conventions + +- **Strict `<`** for "earlier": `start_date == anchor` is **not** earlier (so the + affiliation a training mints is **New**, not Ongoing). +- **Active-at-date** uses `end_date IS NULL OR end_date >= anchor`. ## Notes / open items @@ -157,13 +199,13 @@ The classification anchors on the event's actual `start_date`. excludes the recipient's own affiliations to answer a scholarship-specific question). It is intentionally **not** covered by D5; reconcile with the per-event model later if the two need to agree. -- **Affiliation date precision:** the raw-start-date anchor (D7) reads a - month-precision affiliation (dated to the 1st) as Ongoing where the actual date - would read New — which is why `program_statuses` originally used - `beginning_of_month`. Newly minted affiliations are safe: #2176 changed both the - registration-minted and manually added rows to use the actual date. **Rows - created before that change may still be dated to the 1st**, so a historical - same-month training can still classify as Ongoing. +- **Legacy affiliation dates are the one risk in dropping self-exclusion.** D5 is + safe for anything minted since #2176, which dates the row to the training (D8). + **Rows created before that may still be dated to the 1st of the month**, so a + registrant's own affiliation can precede a same-month training and read that org + as Ongoing where it should be New. Worth a one-off count of facilitator + affiliations dated to the 1st that precede a training the same month before + trusting a historical year's New figures. - **What creates a facilitator affiliation:** since #2194 only a facilitator *training* registration mints one (non-training registrations get a job affiliation instead), and the row records its creating `event_registration_id`. diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index abe1619316..66b9ef57a0 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -188,27 +188,27 @@ 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) + expect(organization.decorate.facilitator_status_as_of(reference).status).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) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).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) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).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) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).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) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).to eq(:new) end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index ec905ea01b..da529dc155 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1244,6 +1244,7 @@ def registration_for(person) describe "#program_statuses" do let(:registration) { create(:event_registration) } + let(:training_date) { registration.event.start_date.to_date } let(:linked_org) { create(:organization, name: "Registration Org") } let(:other_org) { create(:organization, name: "Other Org") } @@ -1251,19 +1252,29 @@ def registration_for(person) create(:event_registration_organization, event_registration: registration, organization: linked_org) # An unrelated facilitator affiliation to a different org must be ignored. create(:affiliation, organization: other_org, person: registration.registrant, - title: "Facilitator", start_date: Date.current) + title: "Facilitator", start_date: training_date) - expect(registration.reload.program_statuses).to eq([ :new ]) + expect(registration.reload.program_statuses.map(&:status)).to eq([ :new ]) end - it "is ongoing when the linked org already had an active facilitator, excluding the registrant's own" do + # The registrant's own affiliation is dated to the training, so it isn't prior + # history — no exclusion needed to read New (ADR-0001 D5). + it "is new when the registrant's own affiliation is the org's first, dated to the training" do + create(:event_registration_organization, event_registration: registration, organization: linked_org) + create(:affiliation, organization: linked_org, person: registration.registrant, + title: "Facilitator", start_date: training_date) + + expect(registration.reload.program_statuses.map(&:status)).to eq([ :new ]) + end + + it "is ongoing when the linked org already had an active facilitator" do create(:event_registration_organization, event_registration: registration, organization: linked_org) create(:affiliation, organization: linked_org, title: "Facilitator", start_date: 2.years.ago, end_date: nil) create(:affiliation, organization: linked_org, person: registration.registrant, - title: "Facilitator", start_date: Date.current) + title: "Facilitator", start_date: training_date) - expect(registration.reload.program_statuses).to eq([ :ongoing ]) + expect(registration.reload.program_statuses.map(&:status)).to eq([ :ongoing ]) end it "counts a facilitator affiliation started earlier the same month as the training" do @@ -1276,7 +1287,17 @@ def registration_for(person) create(:affiliation, organization: linked_org, person: reg.registrant, title: "Facilitator", start_date: Date.new(2026, 6, 20)) - expect(reg.reload.program_statuses).to eq([ :ongoing ]) + expect(reg.reload.program_statuses.map(&:status)).to eq([ :ongoing ]) + end + + it "anchors on the training date and explains itself" do + create(:event_registration_organization, event_registration: registration, organization: linked_org) + + status = registration.reload.program_statuses.first + + expect(status.as_of).to eq(training_date) + expect(status.label).to eq("New") + expect(status.explanation).to include("No facilitator affiliation started before this date") end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 413702f5f4..ffaa6b3216 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -79,40 +79,6 @@ end end - describe '#facilitator_status' do - let(:organization) { create(:organization) } - let(:current) do - create(:affiliation, organization: organization, title: "Facilitator", start_date: Date.new(2026, 1, 1)) - end - - it 'is :new when it is the only facilitator affiliation' do - expect(organization.facilitator_status(current)).to eq(:new) - end - - it 'is :new when every other facilitator affiliation started on or after it' do - create(:affiliation, organization: organization, title: "Facilitator", start_date: Date.new(2026, 6, 1)) - expect(organization.facilitator_status(current)).to eq(:new) - end - - it 'is :ongoing when an earlier facilitator affiliation was still active when it started' do - create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2024, 1, 1), end_date: nil) - expect(organization.facilitator_status(current)).to eq(:ongoing) - end - - it 'is :reinstated when all earlier facilitator affiliations ended before it started' do - create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2022, 1, 1), end_date: Date.new(2023, 1, 1)) - expect(organization.facilitator_status(current)).to eq(:reinstated) - end - - it 'ignores non-facilitator affiliations when classifying' do - create(:affiliation, organization: organization, title: "Volunteer", - start_date: Date.new(2020, 1, 1), end_date: nil) - expect(organization.facilitator_status(current)).to eq(:new) - end - end - describe '#facilitator_status_on' do let(:organization) { create(:organization) } let(:reference_date) { Date.new(2026, 1, 1) } @@ -133,10 +99,16 @@ expect(organization.facilitator_status_on(reference_date)).to eq(:reinstated) end - it 'can exclude a specific affiliation from the classification' do - own = create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2020, 1, 1), end_date: nil) - expect(organization.facilitator_status_on(reference_date, excluding_affiliation_id: own.id)).to eq(:new) + it 'ignores an affiliation starting ON the date — the one that event mints' do + create(:affiliation, organization: organization, title: "Facilitator", + start_date: reference_date, end_date: nil) + expect(organization.facilitator_status_on(reference_date)).to eq(:new) + end + + it 'falls back to the start of the current year when given no date' do + create(:affiliation, organization: organization, title: "Facilitator", + start_date: Date.current.beginning_of_year - 1.day, end_date: nil) + expect(organization.facilitator_status_on).to eq(:ongoing) end end @@ -358,6 +330,35 @@ def org_with(status_name, **affiliation_attrs) end end + # The index caches each row, and the roll-up cells aggregate across affiliated + # people — none of which touches the organizations row itself. + describe "#rollup_cache_version" do + let!(:sector) { create(:sector, :published, name: "Housing") } + let(:organization) { create(:organization) } + let(:person) { create(:person) } + + # A fresh instance each time, the way a page render loads it — the roll-ups + # memoize their affiliated people, so #reload wouldn't re-read them. + def version = Organization.find(organization.id).rollup_cache_version + + it "changes when an affiliated person is retagged" do + create(:affiliation, organization: organization, person: person) + before_version = version + + person.sectorable_items.create!(sector: sector, is_primary: true) + + expect(version).not_to eq(before_version) + end + + it "changes when an affiliation is added" do + before_version = version + + create(:affiliation, organization: organization, person: person) + + expect(version).not_to eq(before_version) + end + end + describe "age groups served" do let(:age_type) { create(:category_type, name: "AgeRange", published: true) } let!(:young) { create(:category, :published, category_type: age_type, name: "3-5") } diff --git a/spec/requests/events_program_statuses_spec.rb b/spec/requests/events_program_statuses_spec.rb new file mode 100644 index 0000000000..d05332c439 --- /dev/null +++ b/spec/requests/events_program_statuses_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +# The annual-reporting page: organizations by program status at each facilitator +# training, with year totals. See ADR-0001 D4/D8. +RSpec.describe "Events program-status report", type: :request do + let(:admin) { create(:user, :admin) } + let!(:training) do + create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", + facilitator_training: true, start_date: Date.new(2026, 3, 1), end_date: Date.new(2026, 3, 2)) + end + let!(:established) { create(:organization, name: "Established Program") } + + def represent(organization, event) + registration = create(:event_registration, event: event, registrant: create(:person), status: "registered") + registration.event_registration_organizations.create!(organization: organization) + end + + before do + create(:affiliation, organization: established, person: create(:person), + title: "Facilitator", start_date: Date.new(2019, 5, 1)) + represent(established, training) + sign_in admin + end + + it "lists each training with its organizations split by status on the training's date" do + get program_statuses_events_path + + expect(response).to be_successful + expect(response.body).to include("TOS205") + # The date every verdict in the row was judged on. + expect(response.body).to include("Mar 1, 2026") + expect(response.body).to include("Distinct organizations") + end + + it "excludes events that aren't facilitator trainings" do + social = create(:event, title: "Zibberpicnic Social", facilitator_training: false, start_date: Date.new(2026, 4, 1)) + represent(established, social) + + get program_statuses_events_path + + # Only the report card — the event filter's select lists every event by name. + report = Capybara.string(response.body).find("#program-status-report") + expect(report).to have_text("TOS205") + expect(report).to have_no_text("Zibberpicnic") + end + + it "is reachable from the reports hub" do + get reports_events_path + + expect(response.body).to include("Program status") + expect(response.body).to include(program_statuses_events_path) + end + + it "is refused to a signed-out visitor" do + sign_out admin + + get program_statuses_events_path + + expect(response).to redirect_to(new_user_session_path) + end +end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 346f3022f5..fd0ce78dae 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -405,6 +405,16 @@ def add_ce_registrant(target_event) expect(nav).to have_no_link("Scholarships") end + it "renders on the program-status report with Program status current" do + sign_in admin + get program_statuses_events_path + + nav = Capybara.string(response.body).find("nav[aria-label='Report views']") + expect(nav).to have_link("Details") + expect(nav).to have_link("Scholarships") + expect(nav).to have_no_link("Program status") + end + # Breakdowns is a panel on the attendees index, not a page, so it has no tab. it "has no Breakdowns tab" do sign_in admin @@ -2544,12 +2554,14 @@ def ce_chip_text expect(page).to have_link(href: registrants_event_path(event, ce_status: "registered"), visible: :all) end - it "shows a program status badge next to each organization" do + it "shows a program status badge next to each organization, hovering to explain it" do get dashboard_event_path(event) # The org list lives inside a collapsed
, so match hidden nodes too. page = Capybara.string(response.body) - expect(page).to have_css("span[title='New']", text: "N", visible: :all) + badge = page.all("span", text: "N", visible: :all).find { |node| node[:title]&.start_with?("New as of") } + expect(badge).to be_present + expect(badge[:title]).to include("event start date") end it "renders the payments section with totals for a paid event" do diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 2b0644561b..2f51485ca1 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -847,25 +847,31 @@ def opt_in(person, text:) let(:cancelled_facilitator) { create(:person) } before do + # Each registrant's own facilitator affiliation is dated to the training + # itself, the way AffiliationServices::CreateFromRegistration mints it. It + # is NOT excluded from the classification (ADR-0001 D5) — it simply doesn't + # count as prior history, because "before" is strict. + training_date = event.start_date.to_date + # New program: the registrant's affiliation is the org's first facilitator. create(:affiliation, organization: new_org, person: new_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Ongoing program: a facilitator was already active before this registrant. create(:affiliation, organization: ongoing_org, title: "Facilitator", start_date: Date.new(2023, 1, 1), end_date: nil) create(:affiliation, organization: ongoing_org, person: ongoing_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Reinstated program: a prior facilitator ended before this registrant's. create(:affiliation, organization: reinstated_org, title: "Facilitator", start_date: Date.new(2020, 1, 1), end_date: Date.new(2021, 1, 1)) create(:affiliation, organization: reinstated_org, person: reinstated_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Cancelled registrant's program must be ignored. create(:affiliation, organization: new_org, person: cancelled_facilitator, - title: "Facilitator", start_date: Date.new(2026, 2, 1)) + title: "Facilitator", start_date: training_date + 1.day) create(:event_registration, event: event, registrant: new_facilitator, status: "registered") create(:event_registration, event: event, registrant: ongoing_facilitator, status: "registered") @@ -880,12 +886,20 @@ def opt_in(person, text:) it "maps each registrant to their organization's program status" do statuses = dashboard.program_statuses_by_registrant - expect(statuses[new_facilitator.id]).to eq([ :new ]) - expect(statuses[ongoing_facilitator.id]).to eq([ :ongoing ]) - expect(statuses[reinstated_facilitator.id]).to eq([ :reinstated ]) + expect(statuses[new_facilitator.id].map(&:status)).to eq([ :new ]) + expect(statuses[ongoing_facilitator.id].map(&:status)).to eq([ :ongoing ]) + expect(statuses[reinstated_facilitator.id].map(&:status)).to eq([ :reinstated ]) expect(statuses).not_to have_key(cancelled_facilitator.id) end + it "anchors every verdict on the event's start date, for the hover to explain" do + status = dashboard.program_statuses_by_registrant[ongoing_facilitator.id].first + + expect(status.as_of).to eq(event.start_date.to_date) + expect(status).not_to be_year_anchored + expect(status.explanation).to include("Ongoing as of", "event start date", "Jan 2023") + end + it "groups registrant ids by program status, for the breakdown drill-in" do ids = dashboard.program_status_registrant_ids @@ -938,7 +952,7 @@ def opt_in(person, text:) end it "classifies the org as ongoing, not new" do - expect(dashboard.program_statuses_by_registrant[person.id]).to eq([ :ongoing ]) + expect(dashboard.program_statuses_by_registrant[person.id].map(&:status)).to eq([ :ongoing ]) end end @@ -950,7 +964,7 @@ def opt_in(person, text:) end it "classifies the org as reinstated" do - expect(dashboard.program_statuses_by_registrant[person.id]).to eq([ :reinstated ]) + expect(dashboard.program_statuses_by_registrant[person.id].map(&:status)).to eq([ :reinstated ]) end end end @@ -973,7 +987,9 @@ def opt_in(person, text:) end it "still counts the program as it was at the time of the event" do - expect(dashboard.program_status_counts).to eq(new: 1, ongoing: 0, reinstated: 0) + # Ongoing, not reinstated: the affiliation began a year before the event and + # was still running on the event date, even though it has since ended. + expect(dashboard.program_status_counts).to eq(new: 0, ongoing: 1, reinstated: 0) end it "keeps the program in the organization count" do diff --git a/spec/services/event_program_status_report_spec.rb b/spec/services/event_program_status_report_spec.rb new file mode 100644 index 0000000000..4253131348 --- /dev/null +++ b/spec/services/event_program_status_report_spec.rb @@ -0,0 +1,141 @@ +require "rails_helper" + +RSpec.describe EventProgramStatusReport do + def training(title, start_date) + create(:event, title: title, abbreviation: title.parameterize.upcase.first(6), + facilitator_training: true, start_date: start_date, end_date: start_date) + end + + def represent(organization, event, status: "registered") + registration = create(:event_registration, event: event, registrant: create(:person), status: status) + registration.event_registration_organizations.create!(organization: organization) + registration + end + + def facilitator_since(organization, start_date, end_date = nil) + create(:affiliation, organization: organization, person: create(:person), + title: "Facilitator", start_date: start_date, end_date: end_date) + end + + let(:spring) { training("Spring Training", Date.new(2026, 3, 1)) } + let(:fall) { training("Fall Training", Date.new(2026, 9, 1)) } + let(:report) { described_class.new([ spring, fall ].map(&:decorate)) } + + describe "per training" do + let(:brand_new) { create(:organization, name: "Brand New") } + let(:established) { create(:organization, name: "Established") } + let(:lapsed) { create(:organization, name: "Lapsed") } + + before do + # Its first facilitator affiliation is minted by the training itself. + facilitator_since(brand_new, spring.start_date) + facilitator_since(established, Date.new(2019, 5, 1)) + facilitator_since(lapsed, Date.new(2015, 1, 1), Date.new(2017, 1, 1)) + + [ brand_new, established, lapsed ].each { |organization| represent(organization, spring) } + end + + it "splits the organizations represented at each training by status on its start date" do + column = report.years.first.columns.first + + expect(column.event).to eq(spring) + expect(column.new_count).to eq(1) + expect(column.ongoing_count).to eq(1) + expect(column.reinstated_count).to eq(1) + expect(column.organization_count).to eq(3) + end + + it "counts only organizations on active registrations" do + cancelled_org = create(:organization, name: "Cancelled") + represent(cancelled_org, spring, status: "cancelled") + + expect(report.years.first.columns.first.organization_count).to eq(3) + end + + it "reads each organization as of the training it attended, not as of today" do + # By the fall training the brand-new org has been facilitating since spring. + represent(brand_new, fall) + + fall_column = report.columns.find { |column| column.event == fall } + + expect(fall_column.ongoing_count).to eq(1) + expect(fall_column.new_count).to eq(0) + end + end + + describe "adding them up" do + let(:repeat_org) { create(:organization, name: "Repeat") } + let(:one_off) { create(:organization, name: "One Off") } + + before do + facilitator_since(repeat_org, spring.start_date) + facilitator_since(one_off, Date.new(2019, 5, 1)) + represent(repeat_org, spring) + represent(repeat_org, fall) + represent(one_off, fall) + end + + it "sums the rows as organization-trainings, counting a repeat attender twice" do + expect(report.organization_count).to eq(3) + expect(report.new_count).to eq(1) # Repeat, at spring + expect(report.ongoing_count).to eq(2) # Repeat at fall, One Off at fall + end + + it "counts each organization once for the period, at its earliest training" do + expect(report.distinct_organization_count).to eq(2) + expect(report.distinct_new_count).to eq(1) # Repeat, as it was in spring + expect(report.distinct_ongoing_count).to eq(1) # One Off + expect(report).to be_repeat_organizations + end + end + + describe "grouping" do + let(:last_year) { training("Prior Training", Date.new(2025, 6, 1)) } + let(:report) { described_class.new([ spring, fall, last_year ].map(&:decorate)) } + + before do + organization = create(:organization) + facilitator_since(organization, Date.new(2019, 5, 1)) + [ spring, fall, last_year ].each { |event| represent(organization, event) } + end + + it "groups columns by calendar year, newest first, chronological within a year" do + expect(report.years.map(&:year)).to eq([ 2026, 2025 ]) + expect(report.years.first.columns.map(&:event)).to eq([ spring, fall ]) + end + + it "totals each year separately" do + expect(report.years.first.organization_count).to eq(2) + expect(report.years.last.organization_count).to eq(1) + end + + it "counts the repeat organization once per year in the distinct view" do + expect(report.years.first.distinct_organization_count).to eq(1) + expect(report.distinct_organization_count).to eq(1) + end + end + + it "is empty without trainings" do + empty = described_class.new([]) + + expect(empty).not_to be_any + expect(empty.organization_count).to eq(0) + expect(empty.distinct_status_counts).to eq(new: 0, ongoing: 0, reinstated: 0) + end + + it "loads the organizations and their affiliations in a fixed number of queries" do + 6.times do |index| + organization = create(:organization, name: "Org #{index}") + facilitator_since(organization, Date.new(2019, 5, 1)) + represent(organization, spring) + end + + subject_report = report # build the events before measuring + + queries = 0 + counter = ->(*, payload) { queries += 1 unless payload[:name].to_s.match?(/SCHEMA|TRANSACTION/) } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") { subject_report.columns } + + expect(queries).to be <= 3 + end +end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 3eaae29353..49c66aa547 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -52,6 +52,17 @@ def register_with(position:) .to contain_exactly("Facilitator") end + # The facilitator affiliation is dated to the training, not to the day the form + # was submitted — the whole New/Ongoing/Reinstate rule leans on that, since an + # affiliation starting ON the training isn't prior history (ADR-0001 D8). + it "dates the facilitator affiliation to the event's start date" do + person = register_with(position: nil) + + facilitator = person.affiliations.find_by(organization: organization, title: "Facilitator") + expect(facilitator.start_date).to eq(event.start_date.to_date) + expect(organization.reload.facilitator_status_on(event.start_date.to_date)).to eq(:new) + end + it "links the created affiliations to the agency address built from the form" do params = base_form_params(first_name: "Sam", last_name: "Rowe", email: "sam@example.com").merge( field_id(described_class::ORGANIZATION_NAME_IDENTIFIER) => "Helping Hands", diff --git a/spec/services/facilitator_program_status_spec.rb b/spec/services/facilitator_program_status_spec.rb new file mode 100644 index 0000000000..2bc9df9855 --- /dev/null +++ b/spec/services/facilitator_program_status_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe FacilitatorProgramStatus do + let(:organization) { create(:organization) } + let(:anchor) { Date.new(2026, 6, 15) } + + def facilitator(start_date:, end_date: nil, title: "Facilitator") + create(:affiliation, organization: organization, person: create(:person), + title: title, start_date: start_date, end_date: end_date) + end + + def status_on(date = anchor) + organization.reload.facilitator_program_status(as_of: date) + end + + describe "the verdict" do + it "is :new without any facilitator affiliation before the anchor" do + expect(status_on.status).to eq(:new) + end + + it "is :new when the only facilitator affiliation starts ON the anchor" do + # The affiliation the training itself mints — not prior history. + facilitator(start_date: anchor) + expect(status_on.status).to eq(:new) + end + + it "is :ongoing when an earlier facilitator affiliation is still active" do + facilitator(start_date: Date.new(2019, 3, 1)) + expect(status_on.status).to eq(:ongoing) + end + + it "is :ongoing when an earlier affiliation ends exactly on the anchor" do + facilitator(start_date: Date.new(2019, 3, 1), end_date: anchor) + expect(status_on.status).to eq(:ongoing) + end + + it "is :reinstated when every earlier facilitator affiliation has ended" do + facilitator(start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + expect(status_on.status).to eq(:reinstated) + end + + it "ignores non-facilitator affiliations" do + facilitator(start_date: Date.new(2010, 1, 1), title: "Volunteer") + expect(status_on.status).to eq(:new) + end + end + + describe "the anchor" do + it "reads as of the given date" do + expect(status_on.as_of).to eq(anchor) + expect(status_on).not_to be_year_anchored + end + + it "falls back to the start of the current year when no date is given" do + status = status_on(nil) + + expect(status.as_of).to eq(Date.current.beginning_of_year) + expect(status).to be_year_anchored + end + end + + describe "#explanation" do + it "names the anchor, the date the program went active, and the periods" do + facilitator(start_date: Date.new(2019, 3, 1)) + + explanation = status_on.explanation + + expect(explanation).to include("Ongoing as of Jun 15, 2026 (event start date).") + expect(explanation).to include("Active facilitator affiliation since Mar 2019.") + expect(explanation).to include("Facilitator periods: Mar 2019.") + end + + it "names when a reinstated program last ran" do + facilitator(start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + + explanation = status_on.explanation + + expect(explanation).to include("Reinstated as of Jun 15, 2026") + expect(explanation).to include("Previously active from Aug 2015 through Jun 2018") + expect(explanation).to include("Facilitator periods: Aug 2015 – Jun 2018.") + end + + it "says there is no prior history for a new program" do + expect(status_on.explanation).to include("New as of Jun 15, 2026", "No facilitator affiliation started before this date.") + end + + it "says the year is the anchor when there is no event in view" do + expect(status_on(nil).explanation).to include("no event in view") + end + + # The most recent activation is what the hover reports, not the earliest. + it "reports the latest of several overlapping active affiliations" do + facilitator(start_date: Date.new(2019, 3, 1)) + facilitator(start_date: Date.new(2024, 9, 1)) + + expect(status_on.active_since).to eq(Date.new(2024, 9, 1)) + end + end + + # List pages classify many organizations at once, so this has to ride on the + # preloaded association rather than querying per row. + it "reads preloaded affiliations without querying" do + facilitator(start_date: Date.new(2019, 3, 1)) + preloaded = Organization.where(id: organization.id).includes(:affiliations).first + + queries = 0 + counter = ->(*, payload) { queries += 1 if payload[:sql].to_s.include?("FROM `affiliations`") } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + expect(preloaded.facilitator_program_status(as_of: anchor).explanation).to be_present + end + + expect(queries).to eq(0) + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 40d35d9011..8f12321d5b 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -129,6 +129,7 @@ "app/views/events/participation.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/reports.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/scholarships.html.erb" => "admin-or-owner bg-blue-100", + "app/views/events/program_statuses.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/attendees.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/preview_reminder.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/confirm_reminder.html.erb" => "admin-or-owner bg-blue-100", From cd86da6554a89b35bcbb3008428d48424799fc6e Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 16 Aug 2026 21:44:09 -0400 Subject: [PATCH 33/40] Trim two verbose decorator display-method comments The "affiliated since"/"art program since" doc comments restated inferable detail (single-source-of-truth intent, repeated N+1 hint); keep the non-obvious why (precision choice, fallback chain) in fewer lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 1daef0eb9a..7f4f47d969 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -93,20 +93,16 @@ def affiliation_end_date affiliations.maximum(:end_date) end - # "Affiliated since" display: affiliation history as merged year-based periods - # (see AffiliationPeriods), falling back to the org's own start_date, then to a - # blank string. Pass a preloaded affiliations collection on list pages to avoid - # an N+1. + # "Affiliated since": affiliation history as merged year-based periods (see + # AffiliationPeriods), falling back to the org's start_date, then blank. Pass + # preloaded affiliations on list pages to avoid an N+1. def affiliated_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" end - # "Art program since" display: the org's facilitator-affiliation history as - # merged periods (see AffiliationPeriods), at month precision — when a program - # started or lapsed is the whole point of the figure. One value for every - # surface that shows it (index chip, profile, edit form), so they can't drift. - # Blank when the org has never facilitated. Pass a preloaded affiliations - # collection on list pages. + # "Art program since": facilitator-affiliation history as merged periods (see + # AffiliationPeriods) at month precision — the exact start/lapse month is the + # point. Blank when the org has never facilitated. def program_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations.select(&:facilitator?), precision: :month) || "" end From be5122904bf70a1c4994bda11e7d68fc16477f91 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 17 Aug 2026 05:02:21 -0400 Subject: [PATCH 34/40] Tighten verbose comments across org/program-status code Trim leaning-verbose comment blocks (agency-type folding, self-funding org, program-status scope/method, agency_type_option, the affiliation-dates JS header + periodsLabel) to keep only the non-obvious why: drop restated code, duplicated gotchas, and the stale "confirm with the team" hedge. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 8 ++-- .../affiliation_dates_controller.js | 28 +++++------- app/models/organization.rb | 45 +++++++------------ 3 files changed, 31 insertions(+), 50 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 7f4f47d969..9f713d56ac 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -47,11 +47,9 @@ def program_status_badge(status = object.program_status) class: "inline-flex shrink-0 items-center justify-center w-5 h-5 rounded-full border text-xs font-semibold #{self.class.program_status_classes(status)}") end - # The org form's type dropdown offers Organization::AGENCY_TYPES. A record may - # still hold a value that is no longer offered (e.g. the pre-rename legacy label - # "Other (please specify below)"); rendered as-is the select finds no match and - # an untouched save would silently reclassify the org as the first option. Fold - # any unrecognized non-blank value into the catch-all "Other". Blank stays blank. + # The stored agency_type, folding any value no longer offered (e.g. the legacy + # "Other (please specify below)") into "Other" so the select can't silently + # reclassify the org — see AGENCY_TYPES. Blank stays blank. def agency_type_option return object.agency_type if object.agency_type.blank? return object.agency_type if Organization::AGENCY_TYPES.include?(object.agency_type) diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index f6c816b3a9..37dec3d8bd 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -4,14 +4,13 @@ export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] // Two live formats. The person form shows a single Mon YYYY – Mon YYYY range for // both figures. The org form (mergedPeriods) shows merged periods mirroring the - // AffiliationPeriods service, so the live value matches the server render: - // "Affiliated since" at year precision, "Art program since" at month precision. - // affiliatedSinceFallback is the org's own start_date (already formatted), shown - // when no affiliation carries a start date. + // AffiliationPeriods service so the live value matches the server render (see + // periodsLabel). affiliatedSinceFallback is the org's own start_date, shown when + // no affiliation carries a start date. // - // Program status (org edit form): derived live from the visible Facilitator rows - // alone, mirroring OrganizationDecorator#organization_status_bucket. statusBuckets - // holds each bucket's label + pill classes (from DomainTheme). + // Program status (org edit form): derived live from the visible Facilitator rows, + // mirroring OrganizationDecorator#organization_status_bucket. statusBuckets holds + // each bucket's label + pill classes (from DomainTheme). static values = { mergedPeriods: Boolean, affiliatedSinceFallback: String, @@ -142,16 +141,11 @@ export default class extends Controller { return `${months[date.getUTCMonth()]} ${date.getUTCFullYear()}` } - // Merged-period label for the org form, mirroring the AffiliationPeriods service: - // overlapping/touching intervals collapse into one period (a nil end is ongoing - // and swallows later intervals), a real gap starts a new one. - // - // precision "year": a single ongoing period keeps month precision when it began - // this year, any multi-period list is year-only. precision "month": every period - // carries its month ("Aug 2015 – Jun 2018, Feb 2024"). - // - // Returns null when no affiliation carries a start date, so the caller can fall - // back to the org's start_date. + // Merged-period label for the org form, mirroring the AffiliationPeriods service + // (see it for the merge rules). precision "year": a lone ongoing period keeps + // month precision when it began this year, multi-period lists are year-only. + // precision "month": every period carries its month. Returns null when no + // affiliation has a start date, so the caller falls back to the org's start_date. periodsLabel(affiliations, today, precision) { const intervals = affiliations .filter(a => a.startDate) diff --git a/app/models/organization.rb b/app/models/organization.rb index 835b1cb9cc..f276c9677f 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -36,18 +36,16 @@ class Organization < ApplicationRecord # List pages must preload people with exactly this, or each row re-queries. PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze - # The organization classifications offered by the org form and the registration - # form's "Organization Type" question, in display order. "Other" is the generic - # catch-all; any stored value not in this list (e.g. a legacy label like the - # pre-rename "Other (please specify below)") is folded into it for display so an - # unmatched select can't silently save as the first option. + # Org classifications offered by the org form and the registration form's + # "Organization Type" question, in display order. Any stored value not listed + # (e.g. the legacy "Other (please specify below)") folds into "Other" for display + # so an unmatched select can't silently save as the first option. AGENCY_TYPE_OTHER = "Other" AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze - # The organization that runs this app. A grant it self-funds is the org funding - # itself, so reports count it as subsidy (unfunded), not external funding. - # Identified by name via ORGANIZATION_NAME — the only marker available today. - # Not memoized: the record can be created mid-process (seeds, tests). + # The organization that runs this app. A grant it self-funds counts as subsidy + # (unfunded), not external funding, in reports. Not memoized: the record can be + # created mid-process (seeds, tests). def self.awbw find_by(name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) end @@ -109,10 +107,9 @@ def self.awbw end scope.distinct end - # Program-status filter, keyed purely off facilitator affiliations (the legacy - # organization_status plays no part — see ADR-0001 D3): an active facilitator - # affiliation => active, facilitator affiliations but none active => formerly - # active, none at all => never active. + # Program-status filter, off facilitator affiliations only (not the legacy + # organization_status — see ADR-0001 D3): active facilitator => active, only + # ended => formerly active, none => never active. scope :program_status, ->(bucket) { fac_ids = Affiliation.facilitators.select(:organization_id) active_fac_ids = Affiliation.facilitators.active.select(:organization_id) @@ -179,11 +176,9 @@ def affiliated_workshop_logs # FacilitatorProgramStatus returns, and the attendees index filters on. FACILITATOR_PROGRAM_STATUSES = FacilitatorProgramStatus::STATUSES - # This organization's program status (New / Ongoing / Reinstate) as of a date — - # the event's start date, or the start of the current year when there's no event - # in view. Returns a FacilitatorProgramStatus, which carries the verdict plus the - # anchor date, the affiliation month behind it and the facilitator history, so - # every display can explain itself. See ADR-0001 D4. + # This org's program status (New / Ongoing / Reinstate) as of a date — the + # event's start date, or the start of the current year when no event is in view. + # Returns a FacilitatorProgramStatus (verdict + reasoning). See ADR-0001 D4. def facilitator_program_status(as_of: nil) FacilitatorProgramStatus.for(self, as_of: as_of) end @@ -226,16 +221,10 @@ def program_location [ first_active.city, first_active.state ].compact_blank.join(", ").presence end - # Status of this organization as an AWBW "program," relative to a scholarship - # recipient — the New/Ongoing/Reinstate column on the scholarship index: - # * "Reinstate" — the org has facilitator affiliations but none are currently - # active (it is returning after a lapse); - # * "Ongoing" — the org has facilitator affiliations beyond this recipient - # (an established program); - # * "New" — no prior facilitator affiliations (this recipient is the - # program's first). - # Heuristic based on affiliations (computed in memory to reuse a preloaded - # association); confirm the exact business rule with the team. + # This org's program status relative to a scholarship recipient (the + # New/Ongoing/Reinstate column on the scholarship index): Reinstate = lapsed, + # Ongoing = has facilitators beyond this recipient, New = none prior. In-memory + # facilitator-affiliation heuristic, to reuse a preloaded association. def program_status(recipient = nil) facilitators = affiliations.select(&:facilitator?) return "New" if facilitators.empty? From 94d936a968f35ecec246a5c4cd989b58fad149c9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 17 Aug 2026 09:33:22 -0400 Subject: [PATCH 35/40] Scope org @organization_events through authorized_scope Per review: wrap both @organization_events queries (show's program-status block and the edit form's per-event chips) in authorized_scope so EventPolicy visibility is applied consistently with #index, instead of the manual manage?/persisted? gate. Both pages are admin-only, so the result is identical for the actual audience; the scope adds defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index a5c4580e83..d4c531c16f 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -48,17 +48,14 @@ def show track_view(@organization) - # Events for the admin-only "Program status" block (facilitator status as of - # each event). Only facilitator-training events — program status is meaningless - # for other events (see ADR-0001). Skip the query for non-managers, who don't - # see the block. - @organization_events = if allowed_to?(:manage?, @organization) + # Events for the admin-only "Program status" block — facilitator-training + # events only, since program status is meaningless for others (see ADR-0001). + # authorized_scope applies EventPolicy visibility, matching #index. + @organization_events = authorized_scope( Event.where(id: @organization.event_registrations.active.select(:event_id)) .where(facilitator_training: true) .order(start_date: :desc) - else - Event.none - end + ) workshop_logs = WorkshopLog.where(organization_id: @organization.id) @month_year_options = workshop_logs.group("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m')") @@ -192,13 +189,15 @@ def set_form_variables end # Facilitator-training events the org is represented at, newest first — drives - # the per-event "Program status by event" chips in the Affiliations section. - # Program status (New/Ongoing/Reinstate) is only meaningful for a facilitator- - # training event, relative to its start date (see ADR-0001). + # the per-event "Program status by event" chips in the Affiliations section + # (program status is only meaningful for these — see ADR-0001). authorized_scope + # applies EventPolicy visibility, matching #index. @organization_events = if @organization.persisted? - Event.where(id: @organization.event_registrations.active.select(:event_id)) - .where(facilitator_training: true) - .order(start_date: :desc) + authorized_scope( + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .where(facilitator_training: true) + .order(start_date: :desc) + ) else Event.none end From de8080be36999064f8a074c096631015ce00986c Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 18 Aug 2026 08:03:50 -0400 Subject: [PATCH 36/40] Restore affiliations column header on the org edit form Main's rebase brought in the xl:grid affiliations/_fields, which hides per-field labels at xl+ and relies on affiliations/_header for the column labels. The org form renders that grid partial, so it needs the header too. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/organizations/_form.html.erb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 3ad6fbb8b9..572895a469 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -353,6 +353,9 @@
<% if allowed_to?(:manage?, Organization) %>
+ <% if f.object.affiliations.present? %> + <%= render "affiliations/header", label: "Person" %> + <% end %> <%= f.fields_for :affiliations do |affiliation_form| %>
<%= render "affiliation_fields", From ad588b58987f22e1f0dc4aecb0eb0704f836f063 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 18 Aug 2026 08:03:50 -0400 Subject: [PATCH 37/40] Scope org events-section query through authorized_scope Applies EventPolicy visibility to the profile "Events attended" section, matching #index and the program-status block. The org show page is admin-only, so the result is unchanged; the scope adds defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/organizations_controller.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index d4c531c16f..6d09c92d3c 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -39,10 +39,11 @@ def show authorize! @organization if turbo_frame_request? && params[:section] == "events" - events = Event.where(id: @organization.event_registrations.active.select(:event_id)) - .includes(:primary_asset) - .order(start_date: :desc) - .paginate(page: params[:page], per_page: 9) + events = authorized_scope( + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .includes(:primary_asset) + .order(start_date: :desc) + ).paginate(page: params[:page], per_page: 9) return render partial: "organizations/sections/events", locals: { organization: @organization, events: events } end From ed520f93dc58bf5cd7b59d94fbe2000d14c4a5ee Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 18 Aug 2026 08:25:30 -0400 Subject: [PATCH 38/40] Match org affiliation-editor system specs to main's grid fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's affiliations/_fields (pulled in by the rebase) renders the title as an (not a textarea) and only shows an inline "Remove" for new rows — persisted rows are removed via the gear's affiliation editor. Update the two system specs: use the input selector, and drive "Never active" by retitling the sole facilitator instead of an inline remove that no longer exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/system/organization_facilitator_warning_spec.rb | 2 +- spec/system/organization_program_status_spec.rb | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/system/organization_facilitator_warning_spec.rb b/spec/system/organization_facilitator_warning_spec.rb index 98272d6d53..04193727c8 100644 --- a/spec/system/organization_facilitator_warning_spec.rb +++ b/spec/system/organization_facilitator_warning_spec.rb @@ -28,7 +28,7 @@ def set_date_input(input, value) def row_for(title) all("#affiliations .nested-fields").find { |f| - f.find("textarea[name*='title']").value.include?(title) + f.find("input[name*='title']").value.include?(title) } end diff --git a/spec/system/organization_program_status_spec.rb b/spec/system/organization_program_status_spec.rb index 96b6f08a34..3e72193ef5 100644 --- a/spec/system/organization_program_status_spec.rb +++ b/spec/system/organization_program_status_spec.rb @@ -41,12 +41,14 @@ def status_chip expect(status_chip).to have_text("Formerly active", wait: 5) end - it "drops to Never active when the only facilitator row is removed" do + it "drops to Never active when the only facilitator row is retitled to a non-facilitator" do visit_and_wait edit_organization_path(organization) expect(status_chip).to have_text("Active") - row = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields") - row.find("a", text: "Remove").click + # Persisted rows can't be removed inline (that's the gear's affiliation editor), + # so retitle the sole facilitator: the live chip counts zero facilitators. + title_input = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields input[name*='title']") + title_input.set("Volunteer") expect(status_chip).to have_text("Never active", wait: 5) end From 6cbcfabb4e1a22e70a6ec4cab10ea52992485d3e Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 18 Aug 2026 09:52:31 -0400 Subject: [PATCH 39/40] Anchor recipients-page program status on the event's own date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipients charts cover a single event, but AttendeesBreakdowns judged program status as of the start of the year — so the card's new note claimed a basis the numbers didn't have, and the same org read differently here than on the event's dashboard. Also point the New pie slice at the indigo the theme moved program_new to, so the slice and the badges beside it stop disagreeing. --- AGENTS.md | 2 +- app/controllers/events_controller.rb | 3 ++- app/services/attendees_breakdowns.rb | 16 ++++++++++------ .../events/_registrant_breakdowns.html.erb | 3 ++- spec/requests/events_spec.rb | 19 +++++++++++++++++++ 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dede28d1e0..430064124b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ action, or `authorize! :workshop, to: :summary?`). - `EventProgramStatusReport` — Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card - `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` - `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see -- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries +- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries - `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, org city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients - `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 `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the reports-hub scholarship summary card diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b9e15a3a6e..0af3743517 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -349,7 +349,8 @@ def recipients recipients = Person.where(id: @dashboard.scholarship_applicant_ids) @breakdowns = AttendeesBreakdowns.new(recipients, events: Event.where(id: @event.id), - registrations: EventRegistration.active) + registrations: EventRegistration.active, + as_of: @event.start_date&.to_date) return render :recipients_charts end diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index c199144525..c9e9ea0617 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -13,10 +13,14 @@ class AttendeesBreakdowns # registrations: the registration scope those breakdowns draw from — attended # registrations by default, or any active ones when a caller (e.g. the recipients # charts) wants a single event's people regardless of attendance. - def initialize(people, events: Event.all, registrations: EventRegistration.attended) + # as_of: the date program status is judged on. A caller scoped to one event passes + # that event's start date so its verdicts match the event's own dashboard; the + # cross-event index leaves it nil, anchoring on the start of the current year. + def initialize(people, events: Event.all, registrations: EventRegistration.attended, as_of: nil) @people = people @events = events @registrations = registrations + @as_of = as_of end def registrant_count @@ -293,13 +297,13 @@ def org_registrant_pairs .pluck(:organization_id, Arel.sql("event_registrations.registrant_id")) end - # Cross-event, so there is no event date to anchor on: each org reads as of the - # start of the current year (see FacilitatorProgramStatus), which is what the - # breakdown card's note and the index's own column say. Symbols here — these - # feed counts and drill-in buckets, not a badge. + # Judged on #as_of — the event's start date when the caller is scoped to one, + # otherwise nil, which FacilitatorProgramStatus anchors on the start of the + # current year. Either way it matches what the breakdown card's note says. + # Symbols here — these feed counts and drill-in buckets, not a badge. def program_status_by_organization @program_status_by_organization ||= organizations.to_h do |organization| - [ organization.id, organization.facilitator_status_on ] + [ organization.id, organization.facilitator_status_on(@as_of) ] end end diff --git a/app/views/events/_registrant_breakdowns.html.erb b/app/views/events/_registrant_breakdowns.html.erb index 5591321789..4271739972 100644 --- a/app/views/events/_registrant_breakdowns.html.erb +++ b/app/views/events/_registrant_breakdowns.html.erb @@ -40,7 +40,8 @@ <% state_data = data.state_counts.sort_by { |_, count| -count } %> <% country_data = data.country_counts.sort_by { |_, count| -count } %> <% school_district_data = data.school_district_counts.sort_by { |_, count| -count } %> -<% program_status_rows = [ [ "New", data.program_status_counts[:new], "#22c55e" ], +<%# Slice colours track DomainTheme's program_new/ongoing/reinstated hues. %> +<% program_status_rows = [ [ "New", data.program_status_counts[:new], "#6366f1" ], [ "Ongoing", data.program_status_counts[:ongoing], "#3b82f6" ], [ "Reinstated", data.program_status_counts[:reinstated], "#a855f7" ] ].select { |_, count, _| count.positive? } %> <% program_status_data = program_status_rows.map { |label, count, _| [ label, count ] } %> diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index fd0ce78dae..51286deaed 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -3225,6 +3225,25 @@ def ce_chip_text expect(response.body).to include("Richmond, CA") end + # These charts cover one event, so program status has to be judged on that + # event's start date — the same verdict its dashboard shows, and what the + # card's note claims. The affiliation here starts after Jan 1 but before the + # training, so the cross-event start-of-year fallback would read it as New. + it "anchors the program-status breakdown on the event's start date" do + org = create(:organization, name: "Anchored Org") + create(:affiliation, organization: org, person: create(:person), + title: "Facilitator", start_date: 2.weeks.from_now.to_date) + registration = EventRegistration.find_by!(registrant: applicant, event: event) + create(:event_registration_organization, event_registration: registration, organization: org) + + get recipients_event_path(event), headers: { "Turbo-Frame" => "recipients_charts" } + + card = Capybara.string(response.body).find("#organization-program-status", visible: :all) + expect(card).to have_text(:all, "Ongoing") + expect(card).to have_no_text(:all, "New") + expect(card).to have_css("i[title*='#{event.start_date.strftime('%b %-d, %Y')}']", visible: :all) + end + it "renders the collapsible card controls and an expand/collapse-all button" do get recipients_event_path(event) From 4acb4ae7e0ee80690661f4f81c554bb44d3999af Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 18 Aug 2026 10:30:19 -0400 Subject: [PATCH 40/40] Trim comments to the non-obvious, and catalogue the shipped features The new program-status code carried long headers restating the rule the ADR already pins down; the ADR is the source of truth, so the code now points at it and keeps only what the code can't say itself. ADR D7 gains the corollary the recipients-page bug proved was needed: the year-anchor fallback is for surfaces that genuinely span events, not for every cross-event class. --- app/controllers/events_controller.rb | 11 ++-- app/controllers/organizations_controller.rb | 15 ++--- app/decorators/organization_decorator.rb | 52 +++++---------- app/decorators/person_decorator.rb | 6 +- .../affiliation_dates_controller.js | 28 +++----- app/helpers/event_participation_helper.rb | 11 ++-- app/helpers/events_helper.rb | 10 ++- app/models/event_registration.rb | 20 +++--- app/models/organization.rb | 46 +++++-------- app/models/organization_status.rb | 7 +- app/services/affiliation_periods.rb | 26 +++----- app/services/attendees_roster.rb | 9 +-- app/services/event_dashboard.rb | 15 ++--- app/services/event_program_status_report.rb | 65 +++++-------------- app/services/facilitator_program_status.rb | 57 ++++------------ app/views/events/_breakdown_card.html.erb | 3 +- .../events/_program_status_report.html.erb | 16 ++--- .../events/_program_status_summary.html.erb | 8 +-- app/views/events/_registrant_roster.html.erb | 4 +- app/views/events/onboarding/_results.html.erb | 1 - app/views/events/participation.html.erb | 5 +- .../_program_status_event_chips.html.erb | 9 +-- .../organizations_results.html.erb | 5 +- config/features.yml | 45 +++++++++++++ ...nization-affiliation-and-program-status.md | 22 +++++-- 25 files changed, 199 insertions(+), 297 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 0af3743517..660ba21976 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -75,9 +75,8 @@ def scholarships @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end - # Cross-event program-status report: how many organizations were New / Ongoing / - # Reinstate at each facilitator training, by year — the annual-reporting figures. - # Sibling of the revenue, participation and scholarship reports. + # Organizations by program status at each facilitator training, by year — the + # annual-reporting figures. Sibling of the other cross-event reports. def program_statuses events, selected_year = filtered_report_events(Event.facilitator_trainings) @report = EventProgramStatusReport.new(events, featured_year: selected_year) @@ -954,10 +953,8 @@ def org_ids_by_city_label .transform_values { |pairs| pairs.map(&:first) } end - # Person ids whose linked training org has the given facilitator program status - # (new / ongoing / reinstated). Anchored the same way the index's own column is — - # this list spans events, so both read as of the start of the current year (see - # FacilitatorProgramStatus) rather than the filter and the column disagreeing. + # Anchored the same way the index's own column is: this list spans events, so + # both read as of the start of the current year rather than disagreeing. def person_program_status_ids(status) status_sym = status.to_sym org_ids = Organization diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 6d09c92d3c..be90534ab7 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -10,7 +10,7 @@ def index base_scope = authorized_scope(Organization.includes( :organization_status, :sectors, :sectorable_items, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, - # Feeds the sector and age-group roll-ups per row — see Organization#affiliated_people. + # Feeds the per-row sector and age-group roll-ups. { people: Organization::PEOPLE_TAGGINGS }, logo_attachment: :blob )) @@ -19,8 +19,6 @@ def index @active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id") @organizations = filtered.paginate(page: params[:page], per_page: per_page) org_ids = @organizations.map(&:id) - # Merged-period "Program since" label per org (facilitator affiliations), - # from the preloaded affiliations. @program_since_display = @organizations.to_h { |org| [ org.id, org.decorate.program_since_display ] } @active_people_counts = Affiliation.active .where(organization_id: org_ids) @@ -49,9 +47,8 @@ def show track_view(@organization) - # Events for the admin-only "Program status" block — facilitator-training - # events only, since program status is meaningless for others (see ADR-0001). - # authorized_scope applies EventPolicy visibility, matching #index. + # The admin-only "Program status" block. Trainings only — program status is + # meaningless for other events (ADR-0001 D6). @organization_events = authorized_scope( Event.where(id: @organization.event_registrations.active.select(:event_id)) .where(facilitator_training: true) @@ -189,10 +186,8 @@ def set_form_variables @organization.affiliations.proxy_association.target.replace(sorted) end - # Facilitator-training events the org is represented at, newest first — drives - # the per-event "Program status by event" chips in the Affiliations section - # (program status is only meaningful for these — see ADR-0001). authorized_scope - # applies EventPolicy visibility, matching #index. + # Drives the edit form's per-event program-status chips. Trainings only — + # program status is meaningless for other events (ADR-0001 D6). @organization_events = if @organization.persisted? authorized_scope( Event.where(id: @organization.event_registrations.active.select(:event_id)) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 9f713d56ac..63474ab8a1 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -8,9 +8,8 @@ class OrganizationDecorator < ApplicationDecorator reinstated: :program_reinstated }.freeze - # Normalize a FacilitatorProgramStatus, the :new/:ongoing/:reinstated symbol, or - # the "New"/"Ongoing"/"Reinstate" string (Organization#program_status) to the - # canonical symbol; nil when blank or unrecognized. + # Normalize a FacilitatorProgramStatus, a :new/:ongoing/:reinstated symbol, or a + # "New"/"Ongoing"/"Reinstate" string to the canonical symbol; nil if unrecognized. def self.program_status_key(status) status = status.status if status.respond_to?(:status) return if status.blank? @@ -40,8 +39,8 @@ def program_status_badge(status = object.program_status) key = self.class.program_status_key(status) return unless key - # A FacilitatorProgramStatus explains its own verdict (anchor date, what made - # the program active); anything else can only name it. + # A FacilitatorProgramStatus explains its own verdict; anything else can only + # name it. h.content_tag(:span, key.to_s.first.upcase, title: status.respond_to?(:explanation) ? status.explanation : key.to_s.titleize, class: "inline-flex shrink-0 items-center justify-center w-5 h-5 rounded-full border text-xs font-semibold #{self.class.program_status_classes(status)}") @@ -91,16 +90,14 @@ def affiliation_end_date affiliations.maximum(:end_date) end - # "Affiliated since": affiliation history as merged year-based periods (see - # AffiliationPeriods), falling back to the org's start_date, then blank. Pass + # "Affiliated since", falling back to the org's start_date, then blank. Pass # preloaded affiliations on list pages to avoid an N+1. def affiliated_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" end - # "Art program since": facilitator-affiliation history as merged periods (see - # AffiliationPeriods) at month precision — the exact start/lapse month is the - # point. Blank when the org has never facilitated. + # "Art program since" — facilitators only, at month precision, since the exact + # start/lapse month is the point. Blank when the org has never facilitated. def program_since_display(affiliations = object.affiliations) AffiliationPeriods.label(affiliations.select(&:facilitator?), precision: :month) || "" end @@ -112,10 +109,8 @@ def program_since_display(affiliations = object.affiliations) active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active }.freeze - # The org's program-status bucket (:active / :formerly_active / :never_active), - # derived purely from facilitator affiliations: an active one => :active, only - # ended ones => :formerly_active, none at all => :never_active. The stored - # organization_status never feeds into this (see ADR-0001 D3). + # Derived purely from facilitator affiliations; the stored organization_status + # never feeds into this (ADR-0001 D3). def organization_status_bucket facilitators = object.affiliations.select(&:facilitator?) return :never_active if facilitators.none? @@ -123,16 +118,12 @@ def organization_status_bucket facilitators.any?(&:active?) ? :active : :formerly_active end - # The bucket the stored (legacy) OrganizationStatus would imply. Not used to - # decide the org's status — only to flag on the edit form where the legacy - # column disagrees with the affiliations. + # Only used to flag where the legacy column disagrees with the affiliations. def stored_status_bucket OrganizationStatus.program_bucket(object.organization_status&.name) end - # True when the legacy OrganizationStatus column contradicts what the org's - # facilitator affiliations say — e.g. a stored "Active" on an org that has never - # had a facilitator affiliation. Surfaced as a warning on the edit form. + # E.g. a stored "Active" on an org that never had a facilitator affiliation. def legacy_status_mismatch? organization_status_bucket != stored_status_bucket end @@ -141,8 +132,6 @@ def organization_status_label ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) end - # Pill classes for a given program-status bucket, built from the DomainTheme - # swatch so the colours stay consistent with the rest of the app. def self.status_classes_for_bucket(bucket) theme_key = ORG_STATUS_BUCKET_THEMES.fetch(bucket) [ @@ -152,40 +141,33 @@ def self.status_classes_for_bucket(bucket) ].join(" ") end - # Every bucket's label + pill classes, so the edit form's Stimulus controller - # can re-render the status chip live as facilitator rows change without - # hard-coding any theme classes in JS. + # Lets the edit form's Stimulus controller re-render the chip live without + # hard-coding theme classes in JS. def self.status_bucket_styles ORG_STATUS_BUCKET_LABELS.each_key.to_h do |bucket| [ bucket, { label: ORG_STATUS_BUCKET_LABELS.fetch(bucket), classes: status_classes_for_bucket(bucket) } ] end end - # Pill classes for the org-wide status chip, keyed off the program-status bucket. def organization_status_classes self.class.status_classes_for_bucket(organization_status_bucket) end - # Rendered org-wide status chip (Active / Formerly active / Never active). def organization_status_chip(data: {}) h.content_tag(:span, organization_status_label, data: data, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") end - # Index "Program since" chip for admins: the facilitator-period years coloured - # by the org's status (green Active / orange Formerly active / gray Never - # active), falling back to the status label when there are no facilitator years. + # Facilitator years, coloured by the org's status; falls back to the status + # label when there are no facilitator years to show. def program_since_chip(years = program_since_display) h.content_tag(:span, years.presence || organization_status_label, class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") end - - # This org's program status as it stood on a given date, as a - # FacilitatorProgramStatus (verdict + anchor + reasoning for the hover). Reads - # the already-loaded affiliations, so a profile classifies many events without - # an N+1. `date` may be a datetime (event.start_date is one). + # Reads the already-loaded affiliations, so a profile classifies many events + # without an N+1. `date` may be a datetime (event.start_date is one). def facilitator_status_as_of(date) object.facilitator_program_status(as_of: date&.to_date) end diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 4586b2c72d..3351a47f47 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -90,10 +90,8 @@ def affiliated_since_date @affiliated_since_date ||= affiliations.filter_map(&:start_date).min end - # The person form's two live-updating date figures. Both render a single - # "Mon YYYY – Mon YYYY" span, prefixed with a ✗ once the range has closed — - # the server-rendered twin of affiliation_dates_controller#updateDisplay, which - # replaces this content as the affiliation rows are edited. + # The server-rendered twin of affiliation_dates_controller#updateDisplay, which + # replaces this content as the person form's affiliation rows are edited. def affiliated_since_range date_range_display(affiliated_since_date, affiliation_end_date, ended_title: "No active affiliations") end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 37dec3d8bd..641e23adaf 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -2,15 +2,10 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] - // Two live formats. The person form shows a single Mon YYYY – Mon YYYY range for - // both figures. The org form (mergedPeriods) shows merged periods mirroring the - // AffiliationPeriods service so the live value matches the server render (see - // periodsLabel). affiliatedSinceFallback is the org's own start_date, shown when - // no affiliation carries a start date. - // - // Program status (org edit form): derived live from the visible Facilitator rows, - // mirroring OrganizationDecorator#organization_status_bucket. statusBuckets holds - // each bucket's label + pill classes (from DomainTheme). + // The person form shows a single Mon YYYY – Mon YYYY range; the org form + // (mergedPeriods) mirrors the AffiliationPeriods service so the live value + // matches the server render. affiliatedSinceFallback is the org's own start_date, + // and statusBuckets each bucket's label + pill classes from DomainTheme. static values = { mergedPeriods: Boolean, affiliatedSinceFallback: String, @@ -55,8 +50,6 @@ export default class extends Controller { const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - // Affiliated since — the org form shows merged year-based periods, the person - // form a single Mon YYYY range. Both live-update from the visible rows. if (this.hasAffiliatedSinceTarget) { if (this.mergedPeriodsValue) { const label = this.periodsLabel(affiliations, today, "year") || this.affiliatedSinceFallbackValue @@ -75,9 +68,8 @@ export default class extends Controller { } } - // Facilitator rows drive "Art program since". Mirror Affiliation#facilitator?: - // an exact, case-sensitive match on "Facilitator" (trimmed), so the live figure - // matches the server render. + // Mirrors Affiliation#facilitator?: exact, case-sensitive, trimmed — so the + // live figure matches the server render. const facilitatorAffiliations = affiliations.filter(a => a.title.trim() === "Facilitator" ) @@ -100,8 +92,7 @@ export default class extends Controller { } } - // Program status — active when any Facilitator row is still active, formerly - // active when they've all ended, never active when there are none. + // Mirrors OrganizationDecorator#organization_status_bucket. if (this.hasProgramStatusTarget) { let bucket if (facilitatorAffiliations.length === 0) { @@ -141,10 +132,7 @@ export default class extends Controller { return `${months[date.getUTCMonth()]} ${date.getUTCFullYear()}` } - // Merged-period label for the org form, mirroring the AffiliationPeriods service - // (see it for the merge rules). precision "year": a lone ongoing period keeps - // month precision when it began this year, multi-period lists are year-only. - // precision "month": every period carries its month. Returns null when no + // The JS twin of AffiliationPeriods — keep the two in step. Null when no // affiliation has a start date, so the caller falls back to the org's start_date. periodsLabel(affiliations, today, precision) { const intervals = affiliations diff --git a/app/helpers/event_participation_helper.rb b/app/helpers/event_participation_helper.rb index 0abbe4edad..0abd9715cb 100644 --- a/app/helpers/event_participation_helper.rb +++ b/app/helpers/event_participation_helper.rb @@ -1,12 +1,11 @@ module EventParticipationHelper - # Anchor on both organization pages' "Program status" block, so returning from - # the participation report lands on the chips the user clicked. + # On both org pages' "Program status" block, so returning from the participation + # report lands on the chips the user clicked. PROGRAM_STATUS_ANCHOR = "program-status".freeze - # Eyebrow back-link for the participation report as [label, path]. The report is - # reachable from the reports hub, an event dashboard, and the program-status - # chips on an organization's profile or edit form; each origin passes return_to - # (plus the id the path needs) so the user goes back where they came from. + # [label, path] for the participation report's eyebrow. Reachable from the + # reports hub, an event dashboard, and an org's program-status chips, so each + # origin passes return_to plus the id its path needs. def participation_return_link organization_id = params[:return_organization_id] diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 11ee54d2c6..ccb0c96ca1 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -100,18 +100,16 @@ def scholarships_report_return_path anchor: params[:return_anchor].presence) end - # Header for any "Program status" column. A program status is only meaningful - # relative to a date, so the header names the event it was judged at — "Program - # status (TOS205)" — and the caveat below covers the case where there is none. + # A program status is only meaningful relative to a date, so the header names the + # event it was judged at — "Program status (TOS205)". def program_status_column_label(event = nil) return "Program status" if event.blank? "Program status (#{event.decorate.compact_label})" end - # The hover note for a Program status column: which date the verdicts were - # judged on. Cross-event lists have no event date to anchor on, so they read as - # of the start of the current year (see FacilitatorProgramStatus). + # Which date the column's verdicts were judged on. Must match how the data was + # actually anchored — see ADR-0001 D7. def program_status_column_note(event = nil) return "New / Ongoing / Reinstate as of #{event.start_date.strftime('%b %-d, %Y')}, this event's start date." if event&.start_date diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 02a661347d..956ccc5db9 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -428,12 +428,10 @@ def self.scholarship_allocatable_ids(scholarships) else all end } - # Filters by the registrant's organization-LINKING status (not the org's own - # OrganizationStatus): "linked" = at least one organization linked; "unlinked" = - # no organization linked (whether or not an agency name was submitted); "pending" - # = the registrant submitted an agency name on the event's registration form but - # nothing is linked yet (mirrors the Pending chip on the roster). Needs the event - # to resolve its registration form's agency_name field. + # The registrant's organization-LINKING status, not the org's own + # OrganizationStatus: "linked" = at least one org linked; "unlinked" = none; + # "pending" = an agency name was submitted but nothing is linked yet (the Pending + # chip on the roster). Needs the event to resolve its agency_name field. scope :organization_linking_status, ->(value, event) { linked = EventRegistrationOrganization.select(:event_registration_id) case value @@ -914,12 +912,10 @@ def sync_attendance_status_to_days! true end - # Program status(es) for the organizations linked to THIS registration, as of the - # training date (see FacilitatorProgramStatus — the same verdict the dashboard, - # the org profile and the annual report show for this event). Returns the status - # objects so a badge can explain itself on hover. Deduped by verdict, so two - # linked orgs at the same status show one badge; unlike the registrant-wide - # rollup, affiliations to other organizations are ignored. + # The organizations linked to THIS registration, as of the training date — the + # same verdict the dashboard, the org profile and the annual report show. Deduped + # by verdict, and unlike the registrant-wide rollup it ignores affiliations to + # other organizations. def program_statuses reference_date = event&.start_date&.to_date organizations.map { |organization| organization.facilitator_program_status(as_of: reference_date) } diff --git a/app/models/organization.rb b/app/models/organization.rb index f276c9677f..6c5153c1fd 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -32,7 +32,6 @@ class Organization < ApplicationRecord saver: { quality: 80 } end - # The affiliated-people nest every org-level roll-up reads (see #affiliated_people). # List pages must preload people with exactly this, or each row re-queries. PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze @@ -107,9 +106,8 @@ def self.awbw end scope.distinct end - # Program-status filter, off facilitator affiliations only (not the legacy - # organization_status — see ADR-0001 D3): active facilitator => active, only - # ended => formerly active, none => never active. + # Off facilitator affiliations only, never the legacy organization_status, so the + # filter and the status chip can't disagree (ADR-0001 D3). scope :program_status, ->(bucket) { fac_ids = Affiliation.facilitators.select(:organization_id) active_fac_ids = Affiliation.facilitators.active.select(:organization_id) @@ -122,9 +120,8 @@ def self.awbw end } - # Index filters that match a sector / age group tagged directly on the org (any), - # OR as an affiliated person's PRIMARY tag — mirroring the aggregate the - # index/profile columns show. + # Matches a tag on the org itself OR an affiliated person's PRIMARY tag — + # mirroring the aggregate the index/profile columns show. scope :sector_name_including_people, ->(name) { next all if name.blank? term = name.to_s.downcase @@ -172,18 +169,16 @@ def affiliated_workshop_logs direct.or(legacy).distinct end - # Facilitator program statuses in display order — the values - # FacilitatorProgramStatus returns, and the attendees index filters on. + # Display order, and what the attendees index filters on. FACILITATOR_PROGRAM_STATUSES = FacilitatorProgramStatus::STATUSES - # This org's program status (New / Ongoing / Reinstate) as of a date — the - # event's start date, or the start of the current year when no event is in view. - # Returns a FacilitatorProgramStatus (verdict + reasoning). See ADR-0001 D4. + # A FacilitatorProgramStatus (verdict + reasoning) as of a date; nil `as_of` + # anchors on the start of the current year. See ADR-0001 D4. def facilitator_program_status(as_of: nil) FacilitatorProgramStatus.for(self, as_of: as_of) end - # The bare :new / :ongoing / :reinstated symbol, for counting and filtering. + # The bare symbol, for counting and filtering. def facilitator_status_on(reference_date = nil) facilitator_program_status(as_of: reference_date).status end @@ -253,8 +248,8 @@ def organization_locality def published? # needed for my_bookmarks return true if organization_status&.name == "Active" - # Affiliation#active? is the in-memory twin of the `active` scope, so a list - # page that preloaded affiliations doesn't query once per row. + # #active? is the in-memory twin of the `active` scope, so a list page that + # preloaded affiliations doesn't query once per row. return affiliations.any?(&:active?) if affiliations.loaded? affiliations.active.exists? @@ -268,8 +263,7 @@ def direct_sectors sectors end - # Only affiliated people's PRIMARY sector (a person has at most one) — their - # non-primary sectors don't roll up to the org. + # Only affiliated people's PRIMARY sector; their others don't roll up to the org. def affiliated_sectors affiliated_people.flat_map { |person| person.sectorable_items.filter_map { |item| item.sector if item.is_primary? } } end @@ -285,17 +279,14 @@ def all_primary_age_groups collect_age_groups(:primary_age_groups) end - # Additional age groups are the org's OWN only — affiliated people contribute - # just their primary age groups (via all_primary_age_groups), not additional. + # The org's OWN only — affiliated people contribute just their primary age groups. def all_additional_age_groups additional_age_groups - all_primary_age_groups end - # Cache version for the roll-up cells on list pages (#all_sectors and the age - # groups), which aggregate across affiliated people: retagging a person or - # adding an affiliation leaves the organizations row untouched, so `[organization]` - # alone caches those cells stale. Count + latest timestamp over the contributing - # taggings, read from the already-preloaded associations so it costs no queries. + # The roll-up cells aggregate across affiliated people, so retagging a person or + # adding an affiliation leaves the organizations row untouched and `[organization]` + # alone caches them stale. Reads preloaded associations, so it costs no queries. def rollup_cache_version records = affiliations.to_a + sectorable_items.to_a + categorizable_items.to_a + affiliated_people.flat_map { |person| person.sectorable_items.to_a + person.categorizable_items.to_a } @@ -323,11 +314,8 @@ def collect_age_groups(kind) ([ self ] + affiliated_people).flat_map { |record| record.public_send(kind) }.uniq end - # The affiliated people behind every org-level roll-up (sectors and age groups), - # with the taggings those roll-ups read. Memoized, and reused as-is when the - # caller has already preloaded them — list pages preload `people` with the - # PEOPLE_TAGGINGS nest (see OrganizationsController#index) so a 25-row page - # doesn't re-query per org. Preloading a bare `:people` would defeat that. + # Reused as-is when already preloaded — list pages preload `people` with the + # PEOPLE_TAGGINGS nest, and a bare `:people` would defeat that. def affiliated_people @affiliated_people ||= people.loaded? ? people.to_a : people.includes(PEOPLE_TAGGINGS).to_a end diff --git a/app/models/organization_status.rb b/app/models/organization_status.rb index 7bb0edee84..d28fa7cec9 100644 --- a/app/models/organization_status.rb +++ b/app/models/organization_status.rb @@ -1,10 +1,9 @@ class OrganizationStatus < ApplicationRecord ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ] - # The stored values are legacy: nothing derives an organization's program status - # from them any more (see ADR-0001 D3). This mapping survives only so the edit - # form can flag where the stored value contradicts the facilitator affiliations. - # Anything unmapped — including a missing status — reads as :never_active. + # Legacy: nothing derives program status from these any more (ADR-0001 D3). The + # mapping survives only so the edit form can flag where the stored value + # contradicts the affiliations. Anything unmapped reads as :never_active. PROGRAM_STATUS_BUCKETS = { "Active" => :active, "Reinstate" => :active, diff --git a/app/services/affiliation_periods.rb b/app/services/affiliation_periods.rb index fd08fabe0d..d9ea82c0e5 100644 --- a/app/services/affiliation_periods.rb +++ b/app/services/affiliation_periods.rb @@ -1,20 +1,13 @@ -# Formats an affiliation history as merged periods. Each affiliation is a -# [start_date, end_date] interval (a nil end = ongoing); overlapping or touching -# intervals collapse into one period, and a real gap starts a new one. Periods -# join chronologically with ", ". +# An affiliation history as merged periods: overlapping intervals collapse, a real +# gap starts a new period, and the periods join chronologically with ", ". # -# Two precisions, because the two displays want different granularity: -# * :year (default) — "Affiliated since". A lone ongoing period (a fresh org) -# shows "Mon YYYY" when it began this year (e.g. "Jul 2026"), otherwise just -# its start year. In a multi-period list every period is year-only for -# consistency: ongoing is its start year, closed is "YYYY" (same-year) or -# "YYYY-YYYY" — e.g. "2010-2012, 2026". -# * :month — "Art program since", where the exact month a program started or -# lapsed matters. Ongoing is "Mon YYYY", closed is "Mon YYYY – Mon YYYY" — -# e.g. "Aug 2015 – Jun 2018, Feb 2024". +# * :year (default) — "Affiliated since", e.g. "2010-2012, 2026". A lone ongoing +# period keeps its month when it began this year ("Jul 2026"). +# * :month — "Art program since", e.g. "Aug 2015 – Jun 2018, Feb 2024", where the +# month a program started or lapsed is the point. # -# Returns nil when no affiliation carries a start date, so callers can fall back -# to the organization's own start_date. +# Nil when no affiliation carries a start date, so callers can fall back to the +# organization's own start_date. class AffiliationPeriods PRECISIONS = %i[ year month ].freeze @@ -36,8 +29,7 @@ def label return nil if @intervals.empty? periods = merged - # At year precision a single ongoing period is a fresh org — worth the month's - # precision. At month precision every period already carries its month. + # A single ongoing period is a fresh org — worth the month's precision. if @precision == :year && periods.one? && ongoing?(periods.first[1]) return year_or_month(periods.first[0]) end diff --git a/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index 55cc1c6bad..b20125fe46 100644 --- a/app/services/attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -115,10 +115,7 @@ def affiliation_statuses_by_registrant end end - # Distinct program statuses of each person's affiliated organizations. This index - # spans events, so there is no event date to anchor on — FacilitatorProgramStatus - # falls back to the start of the current year and flags itself `year_anchored?`, - # which the column's header caveat spells out. + # Distinct program statuses of each person's affiliated organizations. def program_statuses_by_registrant @program_statuses_by_registrant ||= organization_ids_by_registrant.transform_values do |organization_ids| organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq(&:status) @@ -210,8 +207,8 @@ def affiliation_status(affiliation) affiliation.status_on end - # No event to anchor on here (the index spans them), so the status falls back to - # the start of the current year — see FacilitatorProgramStatus. + # No event to anchor on (the index spans them), so the status falls back to the + # start of the current year and flags itself `year_anchored?` for the caveat. def program_status_by_organization @program_status_by_organization ||= organizations.to_h do |organization| [ organization.id, organization.facilitator_program_status ] diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index e6bc572165..896e605f96 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -507,9 +507,8 @@ def program_status_counts end end - # FacilitatorProgramStatus per represented organization, keyed by organization - # id — the classification behind program_status_counts, carrying the anchor date - # and reasoning each display hovers to explain. + # The classification behind program_status_counts, carrying the anchor date and + # reasoning each display hovers to explain. def program_status_by_organization @program_status_by_organization ||= organizations.to_h { |organization| [ organization.id, program_status_for(organization) ] } end @@ -1016,14 +1015,10 @@ def registrant_ids_by_status end end - # Facilitator status for one represented organization, as the org stood at the - # time of the event (#reference_date) — the shared rule, so this breakdown, the - # onboarding matrix, the org profile chips and the annual report all say the - # same thing about this org at this event (see FacilitatorProgramStatus). + # The event's own start date, not #reference_date's today-fallback: an undated + # event has no anchor, and the annual report reads it year-anchored too — the + # two must not diverge. def program_status_for(organization) - # The event's own start date, not #reference_date's today-fallback: an undated - # event has no anchor, and FacilitatorProgramStatus's year fallback is what the - # annual report uses for the same event — the two must not diverge. organization.facilitator_program_status(as_of: event.start_date&.to_date) end diff --git a/app/services/event_program_status_report.rb b/app/services/event_program_status_report.rb index 20ce93fd02..32101108c6 100644 --- a/app/services/event_program_status_report.rb +++ b/app/services/event_program_status_report.rb @@ -1,28 +1,14 @@ -# Program-status report: how many organizations were New / Ongoing / Reinstated at -# each facilitator training, grouped by calendar year — the figures behind annual -# reporting. The sibling of EventRevenueReport / EventParticipationReport / -# EventScholarshipReport: same year-grouped shape, counting organizations. +# Organizations by program status at each facilitator training, grouped by calendar +# year — the figures behind annual reporting. Sibling of EventRevenueReport / +# EventParticipationReport / EventScholarshipReport. Takes decorated events. # -# Every verdict comes from FacilitatorProgramStatus as of the training's own start -# date (ADR-0001 D4), so a row here says exactly what that event's dashboard, its -# onboarding matrix and the org's profile chip say. -# -# TWO WAYS TO ADD THEM UP, and they answer different questions: -# -# * Org-events (the row and year totals) — one count per organization PER -# training. An org that attended three trainings in a year counts three times. -# This is the "how many program starts did each training represent" figure. -# * Distinct organizations (#distinct_status_counts) — each organization counted -# once for the period, classified at the EARLIEST training it appeared at. This -# is the "how many distinct programs did we touch this year, and what were they -# when we first saw them" figure. -# -# Give it a collection of (decorated) facilitator-training events. +# Counted two ways, because they answer different questions (ADR-0001 D9): +# the row and year totals count an org once PER training, while +# #distinct_status_counts counts it once for the period, at its earliest training. class EventProgramStatusReport STATUSES = FacilitatorProgramStatus::STATUSES - # One training's column: the organizations represented at it, each with its - # status as of that training's start date, keyed by organization id. + # One training's organizations and their statuses, keyed by organization id. Column = Struct.new(:event, :statuses, keyword_init: true) do def new_count = count_of(:new) def ongoing_count = count_of(:ongoing) @@ -46,10 +32,6 @@ module Aggregates define_method(attribute) { columns.sum(&attribute) } end - # Each organization counted ONCE for the period, classified at the earliest - # training it appeared at, keyed by status. Reconciles against the summed row - # above: distinct_organization_count <= organization_count, the difference - # being orgs that attended more than one training in the period. def distinct_status_counts @distinct_status_counts ||= first_status_by_organization .values @@ -61,9 +43,7 @@ def distinct_new_count = distinct_status_counts[:new] def distinct_ongoing_count = distinct_status_counts[:ongoing] def distinct_reinstated_count = distinct_status_counts[:reinstated] - # True when at least one organization appears at more than one training in the - # period, i.e. the two ways of adding up disagree — which is when the view - # needs to say so. + # The two ways of adding up disagree, which is when the view needs to say so. def repeat_organizations? = organization_count != distinct_organization_count private @@ -95,10 +75,8 @@ def initialize(events, current_year: Date.current.year, featured_year: nil) @featured_year_value = featured_year end - # One column per training, each carrying its organizations' statuses. Loads the - # org links for every training at once and the orgs with their affiliations at - # once, then classifies in memory — a fixed number of queries however many - # trainings are in scope. + # Loads every training's org links, then those orgs with their affiliations, and + # classifies in memory — a fixed number of queries however many trainings. def columns @columns ||= begin links = organization_ids_by_event @@ -114,8 +92,7 @@ def columns def any? = columns.any? - # Calendar-year groups, newest first. Trainings without a start date fall under - # a nil year that sorts last; each year's columns read chronologically. + # Newest year first; undated trainings fall under a nil year that sorts last. def years @years ||= columns .group_by(&:year) @@ -123,29 +100,23 @@ def years .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } end - # The group whose figures lead the KPI strip: the filtered/navigated-from year, - # falling back to the most recent year present. When no year is featured - # (all-time), an aggregate of every training so the headline isn't year-scoped. + # The filtered/navigated-from year, or every training when none is featured. def featured_year return all_trainings_group if @featured_year_value.nil? years_by_value[@featured_year_value] || years.first end - # A single group spanning every training, under a nil year so the KPI strip - # reads "All trainings". Used as the all-time headline. def all_trainings_group @all_trainings_group ||= YearGroup.new(year: nil, columns: columns, in_progress: false) end - # The most recent year-group strictly older than the featured one, for a - # year-over-year delta. Nil when there's nothing older to compare against. + # The most recent year older than the featured one, for a year-over-year delta. def prior_year return nil unless featured_year&.year years.find { |group| group.year && group.year < featured_year.year } end - # Stacked-column series by year, oldest to newest — org-events per status, for - # the reports hub card's mini chart. + # Stacked-column series by year, oldest to newest, for the hub card's mini chart. def chart_series ascending = years.reject { |group| group.year.nil? }.reverse { @@ -159,9 +130,8 @@ def chart_series private - # Organization ids represented at each training, keyed by event id. "Represented" - # is the same population the event dashboard counts: organizations linked to an - # active registration. + # "Represented" is the population the event dashboard counts: organizations + # linked to an active registration. def organization_ids_by_event event_ids = @events.map(&:id) return {} if event_ids.empty? @@ -175,8 +145,7 @@ def organization_ids_by_event .transform_values { |rows| rows.map(&:last).uniq } end - # A zeroed year group for a period with no trainings, so the summary card - # renders 0 rather than blank. + # Lets the summary card render 0 rather than blank for a trainingless period. def empty_year_group(year) YearGroup.new(year: year, columns: [], in_progress: false) end diff --git a/app/services/facilitator_program_status.rb b/app/services/facilitator_program_status.rb index 382e1a1e78..7abb8dfa64 100644 --- a/app/services/facilitator_program_status.rb +++ b/app/services/facilitator_program_status.rb @@ -1,34 +1,12 @@ -# The single rule for "was this organization a New / Ongoing / Reinstate art -# program on a given date?" Every surface that shows that word — the org profile -# and edit chips, the onboarding matrix, the event dashboard breakdown, the -# registrant rosters, the annual program-status report — goes through here, so -# they can't disagree (see ADR-0001 D4/D5). -# -# The rule, judged purely on the org's Facilitator affiliations (exactly -# "Facilitator", trimmed and case-sensitive) as of an anchor date: -# -# * :new — no facilitator affiliation STARTED BEFORE the anchor. Strictly -# before: an affiliation starting ON the anchor is the one the -# event itself minted (AffiliationServices::CreateFromRegistration -# dates it to the training date), so a first-time org still reads -# New at its own first training. -# * :ongoing — an earlier facilitator affiliation is still active on the anchor -# (no end date, or it ends on/after it). -# * :reinstated — earlier facilitator affiliation(s) existed but all had ended -# before the anchor — a lapse, now returning. -# -# No affiliation is ever excluded. The question is per-EVENT ("at this event, was -# the org new/ongoing/reinstate?"), not per-registrant. -# -# ANCHOR: the event's start date. With no event in view (a cross-event roster), -# pass nothing and the anchor falls back to January 1 of the current year, so the -# figure reads as "where this program stands this reporting year"; `year_anchored?` -# is true then, for the caveat those views show. +# The one rule for an organization's New / Ongoing / Reinstated program status, +# judged on its Facilitator affiliations as of an anchor date. Every surface that +# shows the word goes through here, so they can't disagree. See ADR-0001 D4–D7 for +# the rule, the strict-`<` boundary and the anchor. class FacilitatorProgramStatus STATUSES = %i[ new ongoing reinstated ].freeze - # Classify an organization. Reads the already-loaded affiliations when the - # caller preloaded them, so a page can classify many orgs without an N+1. + # Reads the already-loaded affiliations, so a page can classify many orgs + # without an N+1. def self.for(organization, as_of: nil) new(organization.affiliations, as_of: as_of) end @@ -41,8 +19,7 @@ def initialize(affiliations, as_of: nil) @facilitators = affiliations.select { |affiliation| affiliation.facilitator? && affiliation.start_date } end - # True when no date was given and the anchor fell back to the start of the - # current year — the views that show one add a caveat saying so. + # Views that show a year-anchored figure add a caveat saying so. def year_anchored? = @year_anchored def status @@ -57,31 +34,25 @@ def status def label = status.to_s.titleize - # The month the program was (or last was) active, which is what makes the - # status what it is: for :ongoing the most recent start still running on the - # anchor, for :reinstated the most recent start of the lapsed history. Nil for - # :new — there is nothing before the anchor. + # For :ongoing the most recent start still running on the anchor; for + # :reinstated the most recent start of the lapsed history. Nil for :new. def active_since @active_since ||= (active_on_anchor.presence || earlier).filter_map(&:start_date).max end - # When a :reinstated program's history ran out — the latest end date among the - # earlier affiliations. Nil for the other statuses. + # When a :reinstated program's history ran out. Nil for the other statuses. def lapsed_on return nil unless status == :reinstated @lapsed_on ||= earlier.filter_map(&:end_date).max end - # The whole facilitator history as merged month-precision periods (e.g. - # "Aug 2015 – Jun 2018, Feb 2024") — "the relevant years" behind the verdict. + # The facilitator history behind the verdict, e.g. "Aug 2015 – Jun 2018, Feb 2024". def periods_label @periods_label ||= AffiliationPeriods.label(@facilitators, today: as_of, precision: :month) end - # Plain-language hover text: what the verdict is, what date it was judged on, - # what made it that, and the facilitator history behind it. One string so every - # display site explains the figure the same way. + # Hover text, so every display site explains the figure the same way. def explanation [ anchor_sentence, reason_sentence, periods_sentence ].compact.join(" ") end @@ -111,8 +82,8 @@ def periods_sentence def month(date) = date&.strftime("%b %Y") - # Only affiliations that began before the anchor can say anything about what the - # org was when it arrived. + # Strictly before: an affiliation starting ON the anchor is the one this event + # minted (ADR-0001 D8), so a first-time org still reads New at its own training. def earlier @earlier ||= @facilitators.select { |affiliation| affiliation.start_date < as_of } end diff --git a/app/views/events/_breakdown_card.html.erb b/app/views/events/_breakdown_card.html.erb index 8cc3a60e83..1c9d5cc50f 100644 --- a/app/views/events/_breakdown_card.html.erb +++ b/app/views/events/_breakdown_card.html.erb @@ -12,8 +12,7 @@ row_paths: optional hash of { label => path }. When a row's label has a path, the whole row links to it (the filtered registrant list). note: optional hover text on an info icon beside the title, for a - figure that needs its basis spelled out (e.g. the date a - program status was judged on). %> + figure whose basis needs spelling out. %> <% chart = local_assigns.fetch(:chart, nil) %> <% empty_message = local_assigns.fetch(:empty_message, nil) %> <%# map_color: optional base hex for the choropleth ramp (e.g. the addresses color). %> diff --git a/app/views/events/_program_status_report.html.erb b/app/views/events/_program_status_report.html.erb index 420ed33fb4..7f9761c6f5 100644 --- a/app/views/events/_program_status_report.html.erb +++ b/app/views/events/_program_status_report.html.erb @@ -1,10 +1,6 @@ -<%# Program-status report card: one row per facilitator training with the - organizations represented there split into New / Ongoing / Reinstated as of that - training's start date, a subtotal per year, and an all-time total. Below the - table, the distinct-organization view of the same period — each org counted - once, at the earliest training it appeared at — because the row totals count an - org once per training it attended. Pass `report:` (an EventProgramStatusReport) - and `all_time:`. Emerald-branded, matching the organizations domain colour. %> +<%# One row per facilitator training, subtotalled by year, then the same period + counted once per organization — because the rows count an org once per training + it attended. Pass `report:` (an EventProgramStatusReport) and `all_time:`. %> <% years = report.years %> <% multi_year = years.size > 1 %>
@@ -71,10 +67,8 @@
- <%# The rows above count an organization once per training it attended. This is - the same period counted once per organization, at the earliest training it - appeared at — the figure to report when "how many programs" means distinct - programs rather than program-events. %> + <%# The figure to report when "how many programs" means distinct programs rather + than program-events. %>

Distinct organizations diff --git a/app/views/events/_program_status_summary.html.erb b/app/views/events/_program_status_summary.html.erb index 6b3dc1a624..709959c94f 100644 --- a/app/views/events/_program_status_summary.html.erb +++ b/app/views/events/_program_status_summary.html.erb @@ -1,8 +1,6 @@ -<%# Compact program-status headline for the reports hub, linking to the full - report. Expects `report` (an EventProgramStatusReport) and `period` (its - resolved PeriodScope: #label, #year and #metrics). The tiles count - organizations once per training; the footnote gives the distinct-organization - figure, which is what "how many programs" usually means. %> +<%# Reports-hub headline. Expects `report` (an EventProgramStatusReport) and + `period` (its resolved PeriodScope). The tiles count organizations once per + training; the footnote gives the distinct-organization figure. %>

Program status

diff --git a/app/views/events/_registrant_roster.html.erb b/app/views/events/_registrant_roster.html.erb index 53a3c06506..03ebc9bb18 100644 --- a/app/views/events/_registrant_roster.html.erb +++ b/app/views/events/_registrant_roster.html.erb @@ -16,8 +16,7 @@ registrants: - override the rows shown, for a page that filters its own table from a breakdown drill-in. Defaults to every registrant the roster holds. program_status_event: - the event the Program status column was judged at, so - the header can name it ("Program status (TOS205)"). Omit on a cross-event - list: the header then says the statuses read as of the start of the year. %> + the header can name it. Omit on a cross-event list. %> <% program_status_event = local_assigns[:program_status_event] %> <% show_event_column = local_assigns.fetch(:show_event_column, false) %> <% show_affiliation_status = local_assigns.fetch(:show_affiliation_status, false) %> @@ -59,7 +58,6 @@ <% if i.positive? %><% end %> <%= render "shared/sortable_header", label: part[:label], index: col[:index], key: part[:key] %> <% end %> - <%# Says which date the column's verdicts were judged on. %> <% if col[:note] %> <% end %> diff --git a/app/views/events/onboarding/_results.html.erb b/app/views/events/onboarding/_results.html.erb index 18e80790d9..daaa3d5590 100644 --- a/app/views/events/onboarding/_results.html.erb +++ b/app/views/events/onboarding/_results.html.erb @@ -57,7 +57,6 @@ <% else %> <%= column[:label] %> <% end %> - <%# Says which date the column's verdicts were judged on. %> <% if column[:note] %> <% end %> diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index 7ef68389ef..ae75127b06 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -22,9 +22,8 @@ <%= form_with url: participation_events_path, method: :get, local: true, class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-8" do %> <%= hidden_field_tag :return_to, params[:return_to] %> - <%# Carries the origin org through a filter change, so the eyebrow survives it. - Deliberately NOT `organization_id`: that is a real attendee filter carried - across the report subnav, and this is only a breadcrumb. %> + <%# Carries the origin org through a filter change so the eyebrow survives it. + Deliberately NOT `organization_id` — that is a real attendee filter. %> <%= hidden_field_tag :return_organization_id, params[:return_organization_id] %>
<%= render "time_period_filter" %> diff --git a/app/views/organizations/_program_status_event_chips.html.erb b/app/views/organizations/_program_status_event_chips.html.erb index 0821c1d6e3..e9bef4a779 100644 --- a/app/views/organizations/_program_status_event_chips.html.erb +++ b/app/views/organizations/_program_status_event_chips.html.erb @@ -1,9 +1,6 @@ -<%# Per-event program status (New / Ongoing / Reinstate as of each event's start - date). Rendered on both the org profile and the org edit form, so return_to - names which of the two the participation report should send the user back to — - the chips open in a new tab, where the back button is no help. Each chip hovers - to explain the verdict: the anchor date, what made the program active, and the - facilitator periods behind it (FacilitatorProgramStatus#explanation). %> +<%# Per-event program status, on both the org profile and the edit form. The chips + open in a new tab, where the back button is no help, so return_to names which of + the two the participation report should return to. %> <% decorated = organization.decorate %> <% events.each do |event| %> <% status = decorated.facilitator_status_as_of(event.start_date) %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index e54bc0b186..6e9e0f1ccf 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -20,9 +20,8 @@ <% @organizations.each do |organization| %> - <%# The status bucket is in the key because it turns on facilitator-affiliation - activity, which never touches the org row; rollup_cache_version covers the - sector/age cells, which aggregate across affiliated people. %> + <%# The status bucket turns on facilitator activity and rollup_cache_version on + affiliated people's tags — neither touches the organizations row. %> <% cache [organization, organization.rollup_cache_version, @program_since_display[organization.id], organization.decorate.organization_status_bucket, organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> <% published = organization.published? %> "> diff --git a/config/features.yml b/config/features.yml index 91ad92c865..da2a5a23ca 100644 --- a/config/features.yml +++ b/config/features.yml @@ -27,6 +27,51 @@ # ── 2026-08 ───────────────────────────────────────────────────────────────── +- name: "Program status report for annual reporting" + area: reporting + display_status: admin_facing + released_on: 2026-08-18 + action_path: "/events/program_statuses" + pr_number: 1993 + summary: >- + A cross-event report counting how many organizations were New, Ongoing or + Reinstated at each facilitator training, with year totals for annual reporting. + pro_tips: + - The table counts an organization once per training it attended; the + distinct-organization table below counts each one once for the period. + - Filter by year or event type, then read the year total row. + +- name: "One program status, explained on hover" + area: people + display_status: admin_facing + released_on: 2026-08-18 + action_path: "/organizations" + pr_number: 1993 + summary: >- + New / Ongoing / Reinstated now means the same thing everywhere and is judged on + the event's own date, so the dashboard, roster, onboarding matrix and + organization pages always agree. Hover any badge to see why it says what it says. + pro_tips: + - The hover names the date the status was judged on and the facilitator periods + behind it. + - An organization's overall status comes from its facilitator affiliations, not + the old status field — the edit form flags where the two disagree. + +- name: "Sector, age group and program-status filters on the organization index" + area: people + display_status: user_facing + released_on: 2026-08-18 + action_path: "/organizations" + pr_number: 1993 + summary: >- + The organization list gains Sectors and Age group columns and an "Art program + since" chip, filterable by sector, age group and program status. + pro_tips: + - Sector and age group match the organization's own tags plus its affiliated + people's primary tags. + - The "Art program since" chip shows each stretch of facilitating separately, + so a lapse and a return read as two periods. + - name: "Edit an affiliation's details and comments" area: people display_status: admin_facing diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index c8c90752cb..6c92d267f6 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -142,12 +142,22 @@ program-status chip — this is what stops attendance-only events from reading The classification anchors on the event's actual `start_date` (not the 1st of its month, not "today"), so revisiting a past event always reports what was true then. -**With no event in view** — the cross-event attendees roster — there is no event -date to anchor on, so the status reads as of **January 1 of the current year**: -where each program stands this reporting year. `FacilitatorProgramStatus` applies -that fallback itself and flags `year_anchored?`, and those views carry a caveat -saying so. Any column showing a status names its anchor: "Program status (TOS205)" -in event context, with a hover giving the exact date. +**With no event in view** — the cross-event attendees roster and its breakdowns — +there is no event date to anchor on, so the status reads as of **January 1 of the +current year**: where each program stands this reporting year. +`FacilitatorProgramStatus` applies that fallback itself and flags `year_anchored?`, +and those views carry a caveat saying so. Any column showing a status names its +anchor: "Program status (TOS205)" in event context, with a hover giving the exact +date. + +**The fallback is for surfaces that genuinely span events, not for every +cross-event class.** `AttendeesBreakdowns` backs both the cross-event attendees +index *and* one event's scholarship-recipients charts, so it takes an `as_of:`: the +recipients frame passes that event's `start_date` and reports the same verdicts its +dashboard does, while the index leaves it nil and gets the year anchor. A +single-event surface reaching for the year fallback is a bug — it makes the same +org read two ways at the same event, and the column's own note then describes a +basis the numbers don't have. ### D8 — Registration mints the facilitator affiliation, dated to the training