From c1bb63c9bb02ac1fea0ba9a1b4f943308a156159 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:06:28 -0400 Subject: [PATCH 1/3] 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 a5c7c1e17d..8c62880a19 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 007a9c03c7..dd1ce8b1a4 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -206,21 +206,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 490e115f1c..7ba6c31679 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -319,18 +319,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 204dcb28eb..664e7de0e5 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 %>
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 75391fd1b2..418cf54696 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -337,31 +337,4 @@ expect(org.program_status(recipient)).to eq("New") 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 end diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index 18c0ead828..b6eac73bfe 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) @@ -176,17 +158,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 c649e1917d3c9cd35777b13ddcadaab6710baff2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 15:34:46 -0400 Subject: [PATCH 2/3] 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/schema.rb | 2 +- db/seeds/dev/organizations.rb | 15 ++-- .../decorators/organization_decorator_spec.rb | 22 +++++ spec/models/affiliation_spec.rb | 24 +++--- 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 +- 19 files changed, 310 insertions(+), 73 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 cb42e92f19..fe2f29f247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ end ### 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) - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, projected CE, chart series) for the CEO revenue page - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 8c62880a19..561679284d 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 97a0c134e6..ed09ee83b1 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -78,6 +78,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 278c8018df..7ace596962 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 b35069b5de..4722f57071 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -128,29 +128,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 7ba6c31679..a8cc657930 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -156,7 +156,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)) %> @@ -274,7 +274,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 664e7de0e5..b0d461aa6c 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 %> @@ -52,8 +52,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 276e272943..62383acc60 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/schema.rb b/db/schema.rb index 39ed76ab04..e00a10e7d1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_15_041845) do +ActiveRecord::Schema[8.1].define(version: 2026_07_15_191749) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false 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 638447c9bf..b39e5f9947 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 c237ba0b38..dfb34da891 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -132,19 +132,19 @@ describe '#sync_organization_status_with_affiliations' do let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") } - let!(:inactive_status) { OrganizationStatus.find_or_create_by!(name: "Inactive") } + let!(:formerly_active_status) { OrganizationStatus.find_or_create_by!(name: "Formerly active") } - it 'sets the organization to Inactive when its last active affiliation goes inactive' do + 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(inactive_status) + expect(org.reload.organization_status).to eq(formerly_active_status) end - it 'sets an Inactive organization back to Active when it regains an active affiliation' do - org = create(:organization, organization_status: inactive_status) + 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) @@ -155,18 +155,16 @@ 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(inactive_status) + expect(org.reload.organization_status).to eq(formerly_active_status) end - %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) + 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) + create(:affiliation, organization: org, inactive: false, end_date: nil) - expect(org.reload.organization_status).to eq(status) - end + expect(org.reload.organization_status).to eq(status) 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 c706dd03a3..b679902174 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 f3e576d434ce955202840c51048cbe3f50063be1 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 15:39:35 -0400 Subject: [PATCH 3/3] 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 | 20 +++++++++---------- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 7ace596962..8df9da425e 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 a8cc657930..bb5e7c4cc4 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 4139c27f85..eee8e7f3d7 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,24 +49,22 @@ 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 and recalculates" do visit_and_wait edit_organization_path(organization, admin: true) affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("May 2019") + expect(affiliated).to have_text("2019") - # Remove the Facilitator affiliation (start May 2019), leaving Volunteer (start Sep 2021) + # Remove the Facilitator affiliation (start 2019), leaving Volunteer (start 2021) facilitator_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| f.find("textarea[name*='title']").value.include?("Facilitator") } facilitator_row.find("a", text: "Remove").click - expect(affiliated).to have_text("Sep 2021", wait: 5) + expect(affiliated).to have_text("2021", wait: 5) end end