From ab3104f94d34f51e1250b13fcdaa5af74816ea68 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 11:23:17 -0400 Subject: [PATCH 01/23] Treat a future-start facilitator affiliation as Upcoming, not Active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An affiliation whose start date is still in the future is not genuinely active yet, so #active? and the `active` scope now exclude it — Active means started, not ended, and not flagged inactive (the same rule as status_on == "Active"). This flows through the person edit form's facilitator status and the people directory, and to org status. The people directory's facilitator-status filter gains an "Upcoming" option (and stops lumping these people under "Inactive"), and the affiliation editor row shows an "Upcoming" badge, live-toggled as the start date is edited. `active_or_pending` is left intact for registration dedup, which still counts scheduled future trainings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/inactive_toggle_controller.js | 20 +++++++++++++-- app/models/affiliation.rb | 22 +++++++++++----- app/models/person.rb | 14 +++++++++-- app/views/affiliations/_fields.html.erb | 9 ++++++- app/views/people/people_results.html.erb | 2 +- config/features.yml | 12 +++++++++ .../decorators/organization_decorator_spec.rb | 6 +++++ spec/models/affiliation_spec.rb | 14 +++++++++++ spec/models/person_spec.rb | 25 +++++++++++++++++++ 9 files changed, 112 insertions(+), 12 deletions(-) diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 3083aeff31..4ee7a5d132 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -4,9 +4,10 @@ import { isFacilitatorTitle } from "../lib/affiliation"; // Live styling for the affiliation editor row as you edit, before saving. Four // states by colour: role is the hue (facilitator = purple, else blue) and status // is the saturation (active = full, inactive = super-light). Inactive rows also -// strike their fields (.aff-ended). +// strike their fields (.aff-ended). A not-yet-started row (future start date, not +// ended) additionally shows an "Upcoming" badge. export default class extends Controller { - static targets = ["endDate", "title", "row", "accentBar", "valueField"] + static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge"] static values = { expired: Boolean } connect() { @@ -36,9 +37,24 @@ export default class extends Controller { this.updateRowBackground(); this.styleTitle(); this.paintFields(); + this.updateUpcoming(); this.rowTarget.classList.toggle("aff-ended", this.isPast()); } + // Show the "Upcoming" badge when the affiliation has a future start date and + // has not ended. + updateUpcoming() { + if (!this.hasUpcomingBadgeTarget) return; + this.upcomingBadgeTarget.classList.toggle("hidden", !this.isUpcoming()); + } + + isUpcoming() { + if (this.isPast()) return false; + const value = this.hasStartDateTarget ? this.startDateTarget.value : ""; + if (!value) return false; + return new Date(value) > new Date(new Date().toDateString()); + } + styleTitle() { if (!this.hasTitleTarget) return; const t = this.titleTarget; diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index adba108fcb..dfe9ee6dda 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -37,13 +37,22 @@ def communications_email # Not flagged inactive and not past its end date. Includes affiliations whose # start_date is still in the future (e.g. a Facilitator affiliation dated to an - # upcoming training) — they are "pending" but counted here. + # upcoming training) — they are "pending" but counted here. Use this for + # "active or not-yet-started" checks (e.g. registration dedup); use `active` for + # genuinely-current rows. scope :active_or_pending, -> { where(inactive: false) .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current) } - scope :active, -> { active_or_pending } + # Genuinely active *now*: not flagged inactive, already started (no future + # start), and not past its end date. The SQL twin of #active? and of + # status_on == "Active" — a future-start row is Upcoming, not Active. + scope :active, -> { + where(inactive: false) + .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", Date.current) + .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current) + } # Affiliations that overlapped a given date, judged purely by their start/end # dates rather than the cached `inactive` flag (which reflects "now"). Use this @@ -100,11 +109,12 @@ def facilitator? title.to_s.strip == FACILITATOR_TITLE end - # Current: not flagged inactive and not past its end date. Mirrors the `active` - # scope so already-loaded affiliations can be filtered in Ruby without another - # query (e.g. on list pages that preload affiliations). + # Genuinely active now — the in-memory twin of the `active` scope and of + # status_on == "Active", so already-loaded affiliations can be filtered in Ruby + # without another query (e.g. on list pages that preload affiliations). A + # future-start row is Upcoming, not Active. def active? - !inactive? && (end_date.nil? || end_date >= Date.current) + status_on == "Active" end # This affiliation's status as of a date: Inactive (flagged or ended), Upcoming diff --git a/app/models/person.rb b/app/models/person.rb index 677cfffd61..a2f1fb1e30 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -211,10 +211,18 @@ class Person < ApplicationRecord # People with at least one currently-active facilitator affiliation. scope :facilitators_active, -> { where(id: Affiliation.facilitators.active.select(:person_id)) } - # People with facilitator affiliation(s) but none currently active. + # People with a facilitator affiliation not yet started (future start, none + # active) — Upcoming, not Active or Inactive. + scope :facilitators_upcoming, -> { + where(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) + .where.not(id: Affiliation.facilitators.active.select(:person_id)) } + # People with facilitator affiliation(s) but none active or upcoming — every + # facilitator term has ended (or is flagged inactive). A future-start-only + # facilitator is Upcoming, so it is excluded here. scope :facilitators_inactive, -> { where(id: Affiliation.facilitators.select(:person_id)) - .where.not(id: Affiliation.facilitators.active.select(:person_id)) } + .where.not(id: Affiliation.facilitators.active.select(:person_id)) + .where.not(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) } # Not currently active, but a past facilitator term genuinely ended (real end # date in the past) — distinguishes "used to facilitate" from merely flagged inactive. scope :facilitators_formerly_active, -> { @@ -238,6 +246,7 @@ class Person < ApplicationRecord scope :by_facilitator_status, ->(status) { case status when "active" then facilitators_active + when "upcoming" then facilitators_upcoming when "inactive" then facilitators_inactive when "boomerang" then boomerang_facilitators when "formerly_active" then facilitators_formerly_active @@ -305,6 +314,7 @@ class Person < ApplicationRecord FACILITATOR_STATUS_FILTER_OPTIONS = [ [ "Active", "active" ], [ "Inactive", "inactive" ], + [ "Upcoming", "upcoming" ], [ "Boomerang (left, then active again)", "boomerang" ], [ "Formerly active", "formerly_active" ] ].freeze diff --git a/app/views/affiliations/_fields.html.erb b/app/views/affiliations/_fields.html.erb index 7d9fd9e594..e56dac7190 100644 --- a/app/views/affiliations/_fields.html.erb +++ b/app/views/affiliations/_fields.html.erb @@ -6,6 +6,9 @@ <% manage_subject = person_side ? Organization : Person %> <% if allowed_to?(:manage?, manage_subject) %> <% expired = f.object.inactive? || (f.object.end_date.present? && f.object.end_date < Date.current) %> + <%# Not yet started: a future start date, and not already ended. Shown as an + "Upcoming" badge; kept live by inactive-toggle as the start date is edited. %> + <% upcoming = !expired && f.object.start_date.present? && f.object.start_date > Date.current %> <%# A blank title displays (and saves) as the "Facilitator" default, so treat it as a facilitator for the row styling too — otherwise the JS (which reads the shown title) tints the row purple while the server-rendered pill stays neutral. %> @@ -75,6 +78,10 @@ label: label, label_html: { class: "block text-sm font-medium text-gray-700 mb-1 xl:hidden" } %> <% end %> + mt-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"> + Upcoming +
@@ -105,7 +112,7 @@ value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), class: "w-full rounded-md border-gray-300! focus:ring-blue-500 focus:border-blue-500 text-sm #{field_bg.call(f.object.start_date.present? || !f.object.persisted?)}", style: "height: 42px;", - data: { inactive_toggle_target: "valueField", action: "change->affiliation-dates#recalculate change->inactive-toggle#paintFields" } + data: { inactive_toggle_target: "valueField startDate", action: "change->affiliation-dates#recalculate change->inactive-toggle#paintFields change->inactive-toggle#updateUpcoming" } } %>
diff --git a/app/views/people/people_results.html.erb b/app/views/people/people_results.html.erb index 162eeb2dba..fc4b1556c1 100644 --- a/app/views/people/people_results.html.erb +++ b/app/views/people/people_results.html.erb @@ -68,7 +68,7 @@ - <% affiliations = person.affiliations.select { |a| a.organization.present? && !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) } %> + <% affiliations = person.affiliations.select { |a| a.organization.present? && a.active? } %> <% if affiliations.any? %> <%# Org names are long and multi-word, so a stacked list of truncated links reads better than chips, which squeeze the diff --git a/config/features.yml b/config/features.yml index aa081aee3b..1eb23d426b 100644 --- a/config/features.yml +++ b/config/features.yml @@ -160,6 +160,18 @@ pro_tips: - "An award with no registration behind it falls back to the start of the current year — the hover text says so." +- name: "Upcoming facilitators read as their own status, not Active" + area: people + display_status: admin_facing + released_on: 2026-08-22 + action_path: "/people" + summary: >- + A facilitator affiliation with a future start date now reads as "Upcoming" + rather than Active — on the person edit form and the people directory, whose + facilitator-status filter gains an "Upcoming" option. + pro_tips: + - "Active means started and not ended; a future start date reads as Upcoming until that date arrives, then no longer shows under Inactive." + - name: "Activity log: readable details column" area: reporting display_status: admin_facing diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index e9e4c41b10..1d2c9396c4 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -97,6 +97,12 @@ 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 + + it "is not :active when its only facilitator affiliation has not started yet (future start)" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).not_to eq(:active) + end end describe "#legacy_status_mismatch?" do diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index ed1e882f80..dfcd11a94a 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -87,6 +87,15 @@ 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 + + it 'is false when the start date is in the future (Upcoming, not Active)' do + expect(build(:affiliation, inactive: false, start_date: 1.month.from_now, end_date: nil).active?).to be false + end + + it 'is true when the start date is today or in the past and there is no end date' do + expect(build(:affiliation, inactive: false, start_date: Date.current, end_date: nil).active?).to be true + expect(build(:affiliation, inactive: false, start_date: 1.year.ago, end_date: nil).active?).to be true + end end describe '.active' do @@ -94,6 +103,7 @@ 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) } + let!(:upcoming_future_start) { create(:affiliation, inactive: false, start_date: 1.month.from_now, end_date: nil) } it 'includes records with inactive: false and no end date' do expect(described_class.active).to include(active_op) @@ -111,6 +121,10 @@ expect(described_class.active).not_to include(inactive_by_end_date) end + it 'excludes records whose start date is in the future (Upcoming, not Active)' do + expect(described_class.active).not_to include(upcoming_future_start) + end + it 'qualifies end_date when joined with organizations (which also has end_date)' do expect { described_class.active.joins(:organization).to_a diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 03926c5604..37e9414cf0 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -658,6 +658,23 @@ def phone_numbers expect(results).to include(person_alice, person_bob) end + it "active: excludes people whose only facilitator affiliation starts in the future" do + upcoming = create(:person, first_name: "Upcoming", last_name: "Fac") + create(:affiliation, person: upcoming, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + results = Person.search_by_params(facilitator_status: "active") + expect(results).not_to include(upcoming) + end + + it "upcoming: includes people whose facilitator affiliation has not yet started" do + upcoming = create(:person, first_name: "Upcoming", last_name: "Fac") + create(:affiliation, person: upcoming, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + results = Person.search_by_params(facilitator_status: "upcoming") + expect(results).to include(upcoming) + expect(results).not_to include(person_alice) + end + it "inactive: includes people whose facilitator affiliations are all inactive" do lapsed = create(:person, first_name: "Lapsed", last_name: "Fac") create(:affiliation, person: lapsed, title: "Facilitator", end_date: 1.year.ago) @@ -667,6 +684,14 @@ def phone_numbers expect(results).not_to include(person_alice) end + it "inactive: excludes people whose facilitator affiliation is upcoming (not yet started)" do + upcoming = create(:person, first_name: "Upcoming", last_name: "Fac") + create(:affiliation, person: upcoming, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + results = Person.search_by_params(facilitator_status: "inactive") + expect(results).not_to include(upcoming) + end + it "boomerang: includes people whose active term began after an earlier term ended" do returnee = create(:person, first_name: "Returnee", last_name: "Fac") create(:affiliation, person: returnee, title: "Facilitator", From d719987f01cc8c25393a777c680f9bc1e5c7d101 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sat, 22 Aug 2026 11:23:57 -0400 Subject: [PATCH 02/23] Link features entry to PR #2336 Co-Authored-By: Claude Opus 4.8 (1M context) --- config/features.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/features.yml b/config/features.yml index 1eb23d426b..9c4abc45a6 100644 --- a/config/features.yml +++ b/config/features.yml @@ -165,6 +165,7 @@ display_status: admin_facing released_on: 2026-08-22 action_path: "/people" + pr_number: 2336 summary: >- A facilitator affiliation with a future start date now reads as "Upcoming" rather than Active — on the person edit form and the people directory, whose From 24e78e187f84d24cf17ba9bbaf387dc91b2724df Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 12:05:28 -0400 Subject: [PATCH 03/23] Give organizations a distinct "Upcoming" facilitator program status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org whose only facilitator affiliation is scheduled for a future start was landing in "Formerly active", which is wrong for one that was never active. It now reads as "Upcoming" — a distinct bucket (precedence: active > upcoming > formerly_active > never_active) with its own amber badge, an "Upcoming" option on the organization index filter, and a live chip on the edit form. Upcoming orgs still appear under the "Inactive" (not-active) filter umbrella too. Adds Affiliation#upcoming? and mirrors the bucket logic in the affiliation-dates Stimulus controller. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/organization_decorator.rb | 12 ++++++----- .../affiliation_dates_controller.js | 8 ++++++- app/models/affiliation.rb | 6 ++++++ app/models/organization.rb | 4 +++- .../organizations/_search_boxes.html.erb | 1 + config/features.yml | 6 +++--- lib/domain_theme.rb | 4 +++- .../decorators/organization_decorator_spec.rb | 21 +++++++++++++++++-- spec/models/affiliation_spec.rb | 15 +++++++++++++ spec/models/organization_spec.rb | 11 +++++++--- 10 files changed, 72 insertions(+), 16 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 6143c7e061..e7292893e4 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -117,19 +117,21 @@ def affiliated_since_note end ORG_STATUS_BUCKET_LABELS = { - active: "Active", formerly_active: "Formerly active", never_active: "Never active" + active: "Active", upcoming: "Upcoming", 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 + active: :org_active, upcoming: :org_upcoming, formerly_active: :org_formerly_active, never_active: :org_never_active }.freeze # Derived purely from facilitator affiliations; the stored organization_status - # never feeds into this (ADR-0001 D3). + # never feeds into this (ADR-0001 D3). Upcoming (a facilitator scheduled but not + # started) is distinct from Active and from a lapsed Formerly active. def organization_status_bucket facilitators = object.affiliations.select(&:facilitator?) return :never_active if facilitators.none? - - facilitators.any?(&:active?) ? :active : :formerly_active + return :active if facilitators.any?(&:active?) + return :upcoming if facilitators.any?(&:upcoming?) + :formerly_active end # Only used to flag where the legacy column disagrees with the affiliations. diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 7c4731f5f6..188735c563 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -88,11 +88,17 @@ export default class extends Controller { // Mirrors OrganizationDecorator#organization_status_bucket. if (this.hasProgramStatusTarget) { + const started = a => !a.startDate || new Date(a.startDate) <= today + const notEnded = a => !a.endDate || new Date(a.endDate) >= today let bucket if (facilitatorAffiliations.length === 0) { bucket = "never_active" + } else if (facilitatorAffiliations.some(a => started(a) && notEnded(a))) { + bucket = "active" + } else if (facilitatorAffiliations.some(a => !started(a) && notEnded(a))) { + bucket = "upcoming" } else { - bucket = allFacInactive ? "formerly_active" : "active" + bucket = "formerly_active" } this.updateProgramStatus(bucket) } diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index dfe9ee6dda..076b788973 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -117,6 +117,12 @@ def active? status_on == "Active" end + # Not yet started: a future start date, not flagged inactive and not ended. + # The in-memory twin of status_on == "Upcoming". + def upcoming? + status_on == "Upcoming" + end + # This affiliation's status as of a date: Inactive (flagged or ended), Upcoming # (future start), otherwise Active. The in-memory twin of the .with_status scope. def status_on(date = Date.current) diff --git a/app/models/organization.rb b/app/models/organization.rb index 3c5ec6cd53..60b0e74f30 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -120,9 +120,11 @@ def self.awbw scope :program_status, ->(bucket) { fac_ids = Affiliation.facilitators.select(:organization_id) active_fac_ids = Affiliation.facilitators.active.select(:organization_id) + upcoming_fac_ids = Affiliation.facilitators.with_status("Upcoming").select(:organization_id) case bucket.to_s when "active" then where(id: active_fac_ids) - when "formerly_active" then where(id: fac_ids).where.not(id: active_fac_ids) + when "upcoming" then where(id: upcoming_fac_ids).where.not(id: active_fac_ids) + when "formerly_active" then where(id: fac_ids).where.not(id: active_fac_ids).where.not(id: upcoming_fac_ids) when "never_active" then where.not(id: fac_ids) when "formerly_or_never" then where.not(id: active_fac_ids) else all diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index ed9db2b94d..de79217bb7 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -36,6 +36,7 @@ <%= select_tag :program_status, options_for_select([ [ "Active", "active" ], + [ "Upcoming", "upcoming" ], [ "Inactive", "formerly_or_never" ], [ "Formerly active", "formerly_active" ], [ "Never active", "never_active" ] diff --git a/config/features.yml b/config/features.yml index 9c4abc45a6..916e87e96d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -168,10 +168,10 @@ pr_number: 2336 summary: >- A facilitator affiliation with a future start date now reads as "Upcoming" - rather than Active — on the person edit form and the people directory, whose - facilitator-status filter gains an "Upcoming" option. + rather than Active — for people and organizations. The people and organization + indexes each gain an "Upcoming" status filter, and orgs show an Upcoming badge. pro_tips: - - "Active means started and not ended; a future start date reads as Upcoming until that date arrives, then no longer shows under Inactive." + - "Active means started and not ended; a future start reads as Upcoming until that date arrives. Upcoming orgs also still appear under the Inactive filter." - name: "Activity log: readable details column" area: reporting diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index 42dc090763..75fa668dd9 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -56,8 +56,10 @@ module DomainTheme 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. + # positive current state, Upcoming a not-yet-started one, Formerly active a + # lapsed one, Never active neutral. org_active: :green, + org_upcoming: :amber, org_formerly_active: :orange, org_never_active: :gray, diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 1d2c9396c4..07df0b7947 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -98,10 +98,27 @@ expect(org.reload.decorate.organization_status_bucket).to eq(:never_active) end - it "is not :active when its only facilitator affiliation has not started yet (future start)" do + it "is :upcoming when its only facilitator affiliation has not started yet (future start)" do org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) - expect(org.reload.decorate.organization_status_bucket).not_to eq(:active) + expect(org.reload.decorate.organization_status_bucket).to eq(:upcoming) + expect(org.reload.decorate.organization_status_label).to eq("Upcoming") + end + + it "prefers :active over :upcoming when a facilitator is active and another is upcoming" 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) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:active) + end + + it "prefers :upcoming over :formerly_active when a lapsed facilitator has a new upcoming term" 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: 3.years.ago, end_date: 1.year.ago) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:upcoming) end end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index dfcd11a94a..e55e9bf41c 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -98,6 +98,21 @@ end end + describe '#upcoming?' do + it 'is true for a future start date that has not ended' do + expect(build(:affiliation, inactive: false, start_date: 1.month.from_now, end_date: nil).upcoming?).to be true + end + + it 'is false for a past or absent start date' do + expect(build(:affiliation, inactive: false, start_date: 1.year.ago, end_date: nil).upcoming?).to be false + expect(build(:affiliation, inactive: false, start_date: nil, end_date: nil).upcoming?).to be false + end + + it 'is false when flagged inactive, even with a future start' do + expect(build(:affiliation, inactive: true, start_date: 1.month.from_now, end_date: nil).upcoming?).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) } diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 67ecf9948d..51cc9dadbd 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -248,6 +248,7 @@ def org_with(status_name, **affiliation_attrs) end let!(:active_fac) { org_with("Suspended", start_date: 1.year.ago, end_date: nil) } + let!(:upcoming_fac) { org_with("Active", start_date: 1.month.from_now, 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 @@ -260,7 +261,11 @@ def org_with(status_name, **affiliation_attrs) expect(Organization.program_status("active")).to contain_exactly(active_fac) end - it "buckets only-lapsed facilitator affiliations as formerly_active" do + it "buckets a not-yet-started facilitator affiliation as upcoming" do + expect(Organization.program_status("upcoming")).to contain_exactly(upcoming_fac) + end + + it "buckets only-lapsed facilitator affiliations as formerly_active (excludes upcoming)" do expect(Organization.program_status("formerly_active")).to contain_exactly(lapsed_fac) end @@ -268,8 +273,8 @@ def org_with(status_name, **affiliation_attrs) expect(Organization.program_status("never_active")).to contain_exactly(no_fac, non_fac_only) end - it "combines formerly + never" do - expect(Organization.program_status("formerly_or_never")).to contain_exactly(lapsed_fac, no_fac, non_fac_only) + it "combines formerly + never + upcoming under the Inactive (not-active) umbrella" do + expect(Organization.program_status("formerly_or_never")).to contain_exactly(lapsed_fac, no_fac, non_fac_only, upcoming_fac) end end From 35f380444b1e72ac6e6b4c37be1b4eea2a86cb56 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:17:54 -0400 Subject: [PATCH 04/23] Include upcoming facilitators in the "Inactive" filter; document in ADR-0001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The people directory's "Inactive" facilitator filter is a not-active umbrella, so it now returns upcoming (future-start) facilitators too — matching the org index's "Inactive" (formerly_or_never) option. Upcoming remains its own filter option and status. ADR-0001 D3 now lists the Upcoming bucket and spells out that "Inactive" includes it, for both people and orgs. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/person.rb | 11 ++--- ...nization-affiliation-and-program-status.md | 41 +++++++++++++++---- spec/models/person_spec.rb | 4 +- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/models/person.rb b/app/models/person.rb index a2f1fb1e30..6f729972fd 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -216,13 +216,14 @@ class Person < ApplicationRecord scope :facilitators_upcoming, -> { where(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) .where.not(id: Affiliation.facilitators.active.select(:person_id)) } - # People with facilitator affiliation(s) but none active or upcoming — every - # facilitator term has ended (or is flagged inactive). A future-start-only - # facilitator is Upcoming, so it is excluded here. + # People with facilitator affiliation(s) but none currently active — the + # not-active umbrella. Upcoming (future-start) facilitators are included here + # since they aren't active now, mirroring the org index's "Inactive" + # (formerly_or_never) filter, even though Upcoming is also its own filter option + # and status (ADR-0001 D3). scope :facilitators_inactive, -> { where(id: Affiliation.facilitators.select(:person_id)) - .where.not(id: Affiliation.facilitators.active.select(:person_id)) - .where.not(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) } + .where.not(id: Affiliation.facilitators.active.select(:person_id)) } # Not currently active, but a past facilitator term genuinely ended (real end # date in the past) — distinguishes "used to facilitate" from merely flagged inactive. scope :facilitators_formerly_active, -> { diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index ef4719a9c0..74289109aa 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -10,7 +10,7 @@ that are easy to confuse with one another: - **Affiliated since** - **Facilitators since** -- an org-wide **status chip** (Active / Formerly active / Never active) +- an org-wide **status chip** (Active / Upcoming / Formerly active / Never active) - per-event **program-status chips** (New / Ongoing / Reinstate) Several code paths compute overlapping-but-distinct classifications with subtle @@ -28,10 +28,17 @@ decisions that resolve the ambiguities so they're written down once. `"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**. +- **Active affiliation** — `inactive == false`, **started** (`start_date` is null + or `<= today`), **and** not ended (`end_date` is null or `>= today`). A row whose + `start_date` is still in the future is **Upcoming**, not Active. `inactive` is a + cached column derived from the end date on save (`set_inactive_from_dates`: + `inactive = end_date.present? && end_date < today`). See `Affiliation#active?` / + `#status_on` and the `.active` scope. +- **Upcoming affiliation** — `inactive == false` and `start_date > today` (a + facilitator scheduled but not yet started, e.g. dated to a future training). See + `Affiliation#upcoming?`. The looser `.active_or_pending` scope counts Active + **and** Upcoming together (used for registration dedup); `.active` counts Active + only. - **Facilitator-training event** — `events.facilitator_training == true`. The only events for which per-event program status is meaningful. @@ -62,19 +69,35 @@ 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 +### D3 — Org-wide status chip: Active / Upcoming / Formerly active / Never active -Three display buckets (`OrganizationDecorator#organization_status_bucket`), **not -event-relative**, derived from **facilitator affiliations only**: +Four display buckets (`OrganizationDecorator#organization_status_bucket`), **not +event-relative**, derived from **facilitator affiliations only** and checked in +precedence order: - Any **active** facilitator affiliation → **Active** +- Otherwise any **upcoming** (future-start, not yet started) facilitator + affiliation → **Upcoming** - Facilitator affiliation(s), but **all ended** → **Formerly active** - **No** facilitator affiliation → **Never active** +Upcoming is distinct from Formerly active: an org whose only facilitator is +scheduled for a future training was never active, so it must not read as +"Formerly 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. +filter (`Organization.program_status`), so the filter and the chip agree. + +**The "Inactive" filter is a not-active umbrella.** On both the organization +index and the people directory, the facilitator-status filter offers **Upcoming** +as its own option, **and** its **Inactive** option returns every +not-currently-active facilitator — Formerly active, Never active, **and +Upcoming** — because none of them are active right now +(`Organization.program_status("formerly_or_never")`, `Person.facilitators_inactive`). +So an upcoming org/person shows an *Upcoming* chip but is still found when you +filter by Inactive. On the edit form this chip **live-updates** from the visible facilitator rows. diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 37e9414cf0..4d22c8b199 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -684,12 +684,12 @@ def phone_numbers expect(results).not_to include(person_alice) end - it "inactive: excludes people whose facilitator affiliation is upcoming (not yet started)" do + it "inactive: includes an upcoming (not-yet-started) facilitator — the not-active umbrella" do upcoming = create(:person, first_name: "Upcoming", last_name: "Fac") create(:affiliation, person: upcoming, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) results = Person.search_by_params(facilitator_status: "inactive") - expect(results).not_to include(upcoming) + expect(results).to include(upcoming) end it "boomerang: includes people whose active term began after an earlier term ended" do From 34b14df99feba535dbd7ff223b560143456b5eab Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:17:54 -0400 Subject: [PATCH 05/23] Make the Upcoming badge date comparison timezone-proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isUpcoming/isPast parsed the date inputs as UTC midnight but compared against a local-midnight "today", so a start/end equal to today could be misjudged in timezones ahead of UTC (badging a today-start affiliation as Upcoming). Compare the inputs' own YYYY-MM-DD strings against a local YYYY-MM-DD "today" instead — matching the server's strict `> today` / `< today`. Adds a headless system spec covering past/ended/today/tomorrow/future. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/inactive_toggle_controller.js | 12 ++++- spec/system/upcoming_badge_spec.rb | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 spec/system/upcoming_badge_spec.rb diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 4ee7a5d132..fb22a2ffa4 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -52,7 +52,15 @@ export default class extends Controller { if (this.isPast()) return false; const value = this.hasStartDateTarget ? this.startDateTarget.value : ""; if (!value) return false; - return new Date(value) > new Date(new Date().toDateString()); + return value > this.todayISO(); + } + + // Local "today" as YYYY-MM-DD, compared against the date inputs' own + // YYYY-MM-DD values as strings — no cross-timezone Date parsing (a UTC-parsed + // date input vs a local "today" would misjudge a start/end that equals today). + todayISO() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } styleTitle() { @@ -105,7 +113,7 @@ export default class extends Controller { // server's inactive flag, so trust the server-rendered `expired` value. isPast() { const value = this.hasEndDateTarget ? this.endDateTarget.value : ""; - if (value) return new Date(value) < new Date(new Date().toDateString()); + if (value) return value < this.todayISO(); return this.expiredValue; } diff --git a/spec/system/upcoming_badge_spec.rb b/spec/system/upcoming_badge_spec.rb new file mode 100644 index 0000000000..914a37cd0b --- /dev/null +++ b/spec/system/upcoming_badge_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "Upcoming affiliation badge", type: :system do + let(:admin) { create(:user, :admin) } + let!(:person) { create(:person, user: admin) } + let!(:org) { create(:organization, name: "Zeta Test Center") } + + before do + driven_by(:selenium_chrome_headless) + sign_in admin + end + + # Maps each row's title to whether its Upcoming badge is hidden, read straight + # from the live DOM after the inactive-toggle controller has run. + def badge_hidden_by_title + page.evaluate_script(<<~JS).to_h { |r| [ r["title"], r["hidden"] ] } + Array.from(document.querySelectorAll('.nested-fields')).map(function(row){ + var title = row.querySelector("input[name*='title']"); + var badge = row.querySelector("[data-inactive-toggle-target='upcomingBadge']"); + return { title: title ? title.value : null, hidden: badge ? badge.classList.contains('hidden') : null }; + }); + JS + end + + it "badges Upcoming only for a future start that has not ended" do + create(:affiliation, person: person, organization: org, title: "PastActive", + start_date: 1.year.ago.to_date, end_date: nil) + create(:affiliation, person: person, organization: org, title: "Ended", + start_date: 2.years.ago.to_date, end_date: 1.month.ago.to_date) + create(:affiliation, person: person, organization: org, title: "FutureUp", + start_date: 1.month.from_now.to_date, end_date: nil) + create(:affiliation, person: person, organization: org, title: "StartsToday", + start_date: Date.current, end_date: nil) + create(:affiliation, person: person, organization: org, title: "StartsTomorrow", + start_date: Date.current + 1.day, end_date: nil) + + visit edit_person_path(person) + expect(page).to have_css(".nested-fields", minimum: 5, wait: 10) + + hidden = badge_hidden_by_title + expect(hidden["PastActive"]).to be(true) + expect(hidden["Ended"]).to be(true) + expect(hidden["StartsToday"]).to be(true) + expect(hidden["FutureUp"]).to be(false) + expect(hidden["StartsTomorrow"]).to be(false) + end +end From 2047649c63cae228c50fbb720d597ae4d9b60d5b Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:22:53 -0400 Subject: [PATCH 06/23] Seed a demo facilitator with a future (Upcoming) affiliation The affiliation-status demo had no upcoming facilitator, so the Upcoming badge, status, and filter couldn't be seen in dev. Adds "A7 Upcoming facilitator": an active Counselor role plus a Facilitator affiliation dated one month out, so the person reads Upcoming (and appears under both the Upcoming and Inactive filters). add_affiliation now takes an optional start_date. Co-Authored-By: Claude Opus 4.8 (1M context) --- db/seeds/dev/events_management.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index cfd809d2bd..9f2559843c 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -1619,9 +1619,9 @@ answer.update!(submitted_answer: value.to_s, question_name_when_answered: field.name) end end - add_affiliation = ->(person, organization, title:, end_date: nil) do + add_affiliation = ->(person, organization, title:, start_date: Date.current - 1.year, end_date: nil) do Affiliation.find_or_create_by!(person: person, organization: organization, title: title) do |aff| - aff.start_date = Date.current - 1.year + aff.start_date = start_date aff.end_date = end_date end end @@ -1683,6 +1683,20 @@ link_org.call(registration, org) end end + + # A7: a facilitator dated to a future training — not started yet, so their + # facilitator status reads "Upcoming" (person and org), distinct from Active + # and from a lapsed Formerly active. The Counselor role is already active, so + # the row shows an active job alongside an Upcoming Facilitator affiliation. + if aff_org + person = Person.create!(email: "affdemo.7@seed.example.com", first_name: "Demo Affiliation", last_name: "A7 Upcoming facilitator") + registration = EventRegistration.find_or_create_by!(event: facilitator_training, registrant: person) { |reg| reg.status = "registered" } + registration.event_registration_organizations.find_or_create_by!(organization: aff_org) + add_affiliation.call(person, aff_org, title: "Counselor") + add_affiliation.call(person, aff_org, title: "Facilitator", start_date: Date.current + 1.month) + submit_field.call(registration, agency_field, aff_org.name) + submit_field.call(registration, position_field, "Counselor") + end end # Spread each registration's "registered on" date (created_at) around its event's From 400fc8f8d2e62469eaf78bfc25889a067078d6f9 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:29:02 -0400 Subject: [PATCH 07/23] Seed an Upcoming-status org in the facilitator demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point A7's future-dated Facilitator affiliation at a dedicated org whose only facilitator is that upcoming one, so the org's program-status chip reads "Upcoming" and it appears under the org index's Upcoming (and Inactive) filters — letting the org-level Upcoming state be seen in dev, not just the person-level. Co-Authored-By: Claude Opus 4.8 (1M context) --- db/seeds/dev/events_management.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 9f2559843c..6682055ea8 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -1685,16 +1685,21 @@ end # A7: a facilitator dated to a future training — not started yet, so their - # facilitator status reads "Upcoming" (person and org), distinct from Active - # and from a lapsed Formerly active. The Counselor role is already active, so - # the row shows an active job alongside an Upcoming Facilitator affiliation. + # facilitator status reads "Upcoming" (distinct from Active and from a lapsed + # Formerly active). The upcoming Facilitator role sits on a dedicated org whose + # ONLY facilitator is this one, so that org's program-status chip and the + # "Upcoming" index filter read Upcoming too; an active Counselor role at aff_org + # shows an active job alongside it. if aff_org + upcoming_org = Organization.find_or_create_by!(name: "Windows Program (starting soon)") do |o| + o.organization_status = OrganizationStatus.find_by(name: "Pending") || active_status + end person = Person.create!(email: "affdemo.7@seed.example.com", first_name: "Demo Affiliation", last_name: "A7 Upcoming facilitator") registration = EventRegistration.find_or_create_by!(event: facilitator_training, registrant: person) { |reg| reg.status = "registered" } - registration.event_registration_organizations.find_or_create_by!(organization: aff_org) + registration.event_registration_organizations.find_or_create_by!(organization: upcoming_org) add_affiliation.call(person, aff_org, title: "Counselor") - add_affiliation.call(person, aff_org, title: "Facilitator", start_date: Date.current + 1.month) - submit_field.call(registration, agency_field, aff_org.name) + add_affiliation.call(person, upcoming_org, title: "Facilitator", start_date: Date.current + 1.month) + submit_field.call(registration, agency_field, upcoming_org.name) submit_field.call(registration, position_field, "Counselor") end end From b9c041897c8dd03716214b32aee81a6e87adf4ac Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:41:39 -0400 Subject: [PATCH 08/23] Color the Upcoming badge/chip blue, not amber Upcoming is a benign not-yet-active state that falls under the "Inactive" (not-active) umbrella, so amber (a warning hue) overstated it. Use blue for the affiliation-row Upcoming badge and the org program-status chip (org_upcoming). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/affiliations/_fields.html.erb | 2 +- lib/domain_theme.rb | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/affiliations/_fields.html.erb b/app/views/affiliations/_fields.html.erb index e56dac7190..bd6050ee8e 100644 --- a/app/views/affiliations/_fields.html.erb +++ b/app/views/affiliations/_fields.html.erb @@ -79,7 +79,7 @@ label_html: { class: "block text-sm font-medium text-gray-700 mb-1 xl:hidden" } %> <% end %> mt-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"> + class="<%= "hidden" unless upcoming %> mt-1 inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"> Upcoming diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index 75fa668dd9..b49bc25d63 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -56,10 +56,11 @@ module DomainTheme program_reinstated: :purple, # Org-wide program status (the stored organization_status): Active is the - # positive current state, Upcoming a not-yet-started one, Formerly active a - # lapsed one, Never active neutral. + # positive current state, Upcoming a not-yet-started one (blue — a benign + # not-active state, not an amber warning), Formerly active a lapsed one, Never + # active neutral. org_active: :green, - org_upcoming: :amber, + org_upcoming: :blue, org_formerly_active: :orange, org_never_active: :gray, From da80cf8d7facf9f1626c68274ccc65891e6207f8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:41:39 -0400 Subject: [PATCH 09/23] People index: "Facilitator since" column with a facilitator-status label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the people-index "Affiliated since" column to "Facilitator since" and show the facilitator-since year (matching the edit form and org index) instead of the earliest any-role affiliation. Under the year, show a blue "Upcoming"/"Inactive" label for non-active facilitators (nothing for active or non-facilitators). Both new decorator methods compute from the eager-loaded affiliations — no per-row query. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/person_decorator.rb | 19 ++++++++++++ app/views/people/people_results.html.erb | 9 ++++-- spec/decorators/person_decorator_spec.rb | 39 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 852b48170f..1ed2ca2f7e 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -81,6 +81,25 @@ def affiliated_since_date @affiliated_since_date ||= affiliations.filter_map(&:start_date).min end + # Facilitator-since year for list pages — the Ruby (no per-row query) twin of + # facilitator_since_date, falling back to the legacy member_since like the edit + # form. Computed from the eager-loaded affiliations. + def facilitator_since_year + earliest = affiliations.select(&:facilitator?).filter_map(&:start_date).min + (earliest || member_since)&.year + end + + # The person's facilitator standing for list display: Active if any facilitator + # affiliation is current, Upcoming if one is scheduled but none active, Inactive + # if every facilitator term has ended, nil if they were never a facilitator. + def facilitator_status_label + facilitator_affiliations = affiliations.select(&:facilitator?) + return nil if facilitator_affiliations.none? + return "Active" if facilitator_affiliations.any?(&:active?) + return "Upcoming" if facilitator_affiliations.any?(&:upcoming?) + "Inactive" + end + def facilitator_since_range date_range_display(facilitator_since_date, facilitation_end_date, ended_title: "No active facilitator affiliations") end diff --git a/app/views/people/people_results.html.erb b/app/views/people/people_results.html.erb index fc4b1556c1..d342b76a7b 100644 --- a/app/views/people/people_results.html.erb +++ b/app/views/people/people_results.html.erb @@ -8,7 +8,7 @@ Name - Affiliated since + Facilitator since Primary sector Primary age range Affiliation(s) @@ -34,7 +34,12 @@ <% end %> - <%= (person.decorate.affiliated_since_date || person.member_since)&.year || "--" %> + <% decorated = person.decorate %> + <%= decorated.facilitator_since_year || "--" %> + <% status = decorated.facilitator_status_label %> + <% if status && status != "Active" %> +
<%= status %>
+ <% end %> diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index a166693648..514d9d933f 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -72,6 +72,45 @@ end end + describe "#facilitator_since_year" do + let(:person) { create(:person) } + + it "returns the earliest facilitator affiliation year, ignoring other roles" do + create(:affiliation, person: person, title: "Volunteer", start_date: Date.new(2015, 1, 1)) + create(:affiliation, person: person, title: "Facilitator", start_date: Date.new(2020, 6, 1)) + expect(person.decorate.facilitator_since_year).to eq(2020) + end + + it "falls back to member_since when there is no facilitator start date" do + person.update!(member_since: Date.new(2018, 3, 1)) + expect(person.decorate.facilitator_since_year).to eq(2018) + end + end + + describe "#facilitator_status_label" do + let(:person) { create(:person) } + + it "is nil when the person has never been a facilitator" do + create(:affiliation, person: person, title: "Volunteer", start_date: 1.year.ago) + expect(person.decorate.facilitator_status_label).to be_nil + end + + it "is Active with a current facilitator affiliation" do + create(:affiliation, person: person, title: "Facilitator", start_date: 1.year.ago, end_date: nil) + expect(person.decorate.facilitator_status_label).to eq("Active") + end + + it "is Upcoming when a facilitator affiliation is scheduled but none is active" do + create(:affiliation, person: person, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(person.decorate.facilitator_status_label).to eq("Upcoming") + end + + it "is Inactive when every facilitator term has ended" do + create(:affiliation, person: person, title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + expect(person.decorate.facilitator_status_label).to eq("Inactive") + end + end + describe "#affiliated_since_note" do let(:person) { create(:person) } From a715d7ae3195f197c43f802ba8f1f28ac621d04e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:53:39 -0400 Subject: [PATCH 10/23] Affiliation rows: show an Inactive badge, plus Upcoming for future starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An affiliation that isn't currently active now shows a blue "Inactive" badge (ended, flagged, or not-yet-started); a future-start one additionally shows "Upcoming". So an ended row reads Inactive only, while an upcoming row reads Inactive + Upcoming — Inactive is the not-active state, Upcoming the "starting soon" qualifier. Both toggle live in the inactive-toggle controller. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/inactive_toggle_controller.js | 16 ++++---- app/views/affiliations/_fields.html.erb | 24 ++++++++---- spec/system/upcoming_badge_spec.rb | 39 +++++++++++-------- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index fb22a2ffa4..c395dc5439 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -7,7 +7,7 @@ import { isFacilitatorTitle } from "../lib/affiliation"; // strike their fields (.aff-ended). A not-yet-started row (future start date, not // ended) additionally shows an "Upcoming" badge. export default class extends Controller { - static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge"] + static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge", "inactiveBadge"] static values = { expired: Boolean } connect() { @@ -37,15 +37,17 @@ export default class extends Controller { this.updateRowBackground(); this.styleTitle(); this.paintFields(); - this.updateUpcoming(); + this.updateBadges(); this.rowTarget.classList.toggle("aff-ended", this.isPast()); } - // Show the "Upcoming" badge when the affiliation has a future start date and - // has not ended. - updateUpcoming() { - if (!this.hasUpcomingBadgeTarget) return; - this.upcomingBadgeTarget.classList.toggle("hidden", !this.isUpcoming()); + // "Inactive" whenever the affiliation isn't currently active (ended, flagged, + // or not-yet-started); "Upcoming" additionally for a future start — so an + // upcoming row shows both and an ended one shows only Inactive. + updateBadges() { + const notActive = this.isPast() || this.isUpcoming(); + if (this.hasInactiveBadgeTarget) this.inactiveBadgeTarget.classList.toggle("hidden", !notActive); + if (this.hasUpcomingBadgeTarget) this.upcomingBadgeTarget.classList.toggle("hidden", !this.isUpcoming()); } isUpcoming() { diff --git a/app/views/affiliations/_fields.html.erb b/app/views/affiliations/_fields.html.erb index bd6050ee8e..509628156a 100644 --- a/app/views/affiliations/_fields.html.erb +++ b/app/views/affiliations/_fields.html.erb @@ -6,9 +6,13 @@ <% manage_subject = person_side ? Organization : Person %> <% if allowed_to?(:manage?, manage_subject) %> <% expired = f.object.inactive? || (f.object.end_date.present? && f.object.end_date < Date.current) %> - <%# Not yet started: a future start date, and not already ended. Shown as an - "Upcoming" badge; kept live by inactive-toggle as the start date is edited. %> + <%# Not yet started: a future start date, and not already ended. %> <% upcoming = !expired && f.object.start_date.present? && f.object.start_date > Date.current %> + <%# Status badges (kept live by inactive-toggle as the dates are edited): + "Inactive" whenever the affiliation isn't currently active (ended, flagged, + or not-yet-started), plus "Upcoming" for a future start — so an upcoming row + shows both, while an ended one shows only Inactive. %> + <% not_active = expired || upcoming %> <%# A blank title displays (and saves) as the "Facilitator" default, so treat it as a facilitator for the row styling too — otherwise the JS (which reads the shown title) tints the row purple while the server-rendered pill stays neutral. %> @@ -78,10 +82,16 @@ label: label, label_html: { class: "block text-sm font-medium text-gray-700 mb-1 xl:hidden" } %> <% end %> - mt-1 inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"> - Upcoming - +
+ inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"> + Inactive + + inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"> + Upcoming + +
@@ -112,7 +122,7 @@ value: (f.object.start_date || (Date.current unless f.object.persisted?))&.strftime("%Y-%m-%d"), class: "w-full rounded-md border-gray-300! focus:ring-blue-500 focus:border-blue-500 text-sm #{field_bg.call(f.object.start_date.present? || !f.object.persisted?)}", style: "height: 42px;", - data: { inactive_toggle_target: "valueField startDate", action: "change->affiliation-dates#recalculate change->inactive-toggle#paintFields change->inactive-toggle#updateUpcoming" } + data: { inactive_toggle_target: "valueField startDate", action: "change->affiliation-dates#recalculate change->inactive-toggle#paintFields change->inactive-toggle#updateBadges" } } %>
diff --git a/spec/system/upcoming_badge_spec.rb b/spec/system/upcoming_badge_spec.rb index 914a37cd0b..e77f64ad98 100644 --- a/spec/system/upcoming_badge_spec.rb +++ b/spec/system/upcoming_badge_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe "Upcoming affiliation badge", type: :system do +RSpec.describe "Affiliation status badges", type: :system do let(:admin) { create(:user, :admin) } let!(:person) { create(:person, user: admin) } let!(:org) { create(:organization, name: "Zeta Test Center") } @@ -10,19 +10,24 @@ sign_in admin end - # Maps each row's title to whether its Upcoming badge is hidden, read straight - # from the live DOM after the inactive-toggle controller has run. - def badge_hidden_by_title - page.evaluate_script(<<~JS).to_h { |r| [ r["title"], r["hidden"] ] } + # Maps each row's title to whether its Inactive/Upcoming badges are hidden, + # read straight from the live DOM after the inactive-toggle controller has run. + def badges_by_title + page.evaluate_script(<<~JS).to_h { |r| [ r["title"], r ] } Array.from(document.querySelectorAll('.nested-fields')).map(function(row){ var title = row.querySelector("input[name*='title']"); - var badge = row.querySelector("[data-inactive-toggle-target='upcomingBadge']"); - return { title: title ? title.value : null, hidden: badge ? badge.classList.contains('hidden') : null }; + var inactive = row.querySelector("[data-inactive-toggle-target='inactiveBadge']"); + var upcoming = row.querySelector("[data-inactive-toggle-target='upcomingBadge']"); + return { + title: title ? title.value : null, + inactiveHidden: inactive ? inactive.classList.contains('hidden') : null, + upcomingHidden: upcoming ? upcoming.classList.contains('hidden') : null + }; }); JS end - it "badges Upcoming only for a future start that has not ended" do + it "shows Inactive for not-active rows and adds Upcoming for future starts" do create(:affiliation, person: person, organization: org, title: "PastActive", start_date: 1.year.ago.to_date, end_date: nil) create(:affiliation, person: person, organization: org, title: "Ended", @@ -31,17 +36,17 @@ def badge_hidden_by_title start_date: 1.month.from_now.to_date, end_date: nil) create(:affiliation, person: person, organization: org, title: "StartsToday", start_date: Date.current, end_date: nil) - create(:affiliation, person: person, organization: org, title: "StartsTomorrow", - start_date: Date.current + 1.day, end_date: nil) visit edit_person_path(person) - expect(page).to have_css(".nested-fields", minimum: 5, wait: 10) + expect(page).to have_css(".nested-fields", minimum: 4, wait: 10) + rows = badges_by_title - hidden = badge_hidden_by_title - expect(hidden["PastActive"]).to be(true) - expect(hidden["Ended"]).to be(true) - expect(hidden["StartsToday"]).to be(true) - expect(hidden["FutureUp"]).to be(false) - expect(hidden["StartsTomorrow"]).to be(false) + # Active now → no badges. + expect(rows["PastActive"]).to include("inactiveHidden" => true, "upcomingHidden" => true) + expect(rows["StartsToday"]).to include("inactiveHidden" => true, "upcomingHidden" => true) + # Ended → Inactive only. + expect(rows["Ended"]).to include("inactiveHidden" => false, "upcomingHidden" => true) + # Future start → Inactive AND Upcoming. + expect(rows["FutureUp"]).to include("inactiveHidden" => false, "upcomingHidden" => false) end end From 7c284420db5346e97e01ee41c3054603f6b1d571 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 17:53:39 -0400 Subject: [PATCH 11/23] People directory: only active facilitators are publicly visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public people directory is a directory of active facilitators, so a person is "published" (visible to non-admins, white row) only with a currently-active Facilitator affiliation — a non-facilitator role or a lapsed/upcoming facilitator is admin-only (blue row) and excluded from the non-admin search scope. Renames with_active_affiliations → with_active_facilitator_affiliations and updates #published? to match; both now key off Affiliation.facilitators.active. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/person.rb | 16 ++++++++++++---- app/policies/person_policy.rb | 2 +- spec/models/person_spec.rb | 28 +++++++++++++++++++++------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/models/person.rb b/app/models/person.rb index 6f729972fd..6afb340258 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -168,11 +168,15 @@ class Person < ApplicationRecord scope :organization_ids, ->(organization_ids) { joins(:affiliations) .where(affiliations: { organization_id: organization_ids }) } - scope :published, -> { searchable.with_active_affiliations } + scope :published, -> { searchable.with_active_facilitator_affiliations } scope :searchable, ->(searchable = nil) { searchable ? where(profile_is_searchable: searchable) : where(profile_is_searchable: true) } - scope :with_active_affiliations, -> { + # The public people directory is a directory of *active facilitators*, so only + # a currently-active Facilitator affiliation makes a person publicly visible — + # a non-facilitator role (Counselor, Board Member) or a lapsed/upcoming + # facilitator does not. Non-admins see only these; everyone else is admin-only. + scope :with_active_facilitator_affiliations, -> { joins(:affiliations) - .merge(Affiliation.active) + .merge(Affiliation.facilitators.active) .distinct } scope :where_user_not_locked, -> { @@ -337,8 +341,12 @@ def self.search_by_params(params) results end + # Publicly visible = searchable and a currently-active *facilitator*. The + # in-memory twin of the `published` scope; computed from the (eager-loaded) + # affiliations so the people index pays no per-row query. A non-facilitator + # role or a lapsed/upcoming facilitator is not published (admin-only). def published? - profile_is_searchable? && affiliations.active.exists? + profile_is_searchable? && affiliations.any? { |a| a.facilitator? && a.active? } end def membership_current?(as_of: Date.current) diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 72c95c2d2a..03c59281fc 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -59,7 +59,7 @@ def send_form_link? relation_scope do |relation| next relation if admin? - relation.searchable.with_active_affiliations.where_user_not_locked + relation.searchable.with_active_facilitator_affiliations.where_user_not_locked end private diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 4d22c8b199..fffedb506b 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -341,28 +341,42 @@ def term(cost_cents:, start_date: Date.current, subscription: nil) expect(person.published?).to be false end end + + context "when the only active affiliation is a non-facilitator role" do + before { create(:affiliation, person: person, title: "Counselor", inactive: false, end_date: nil) } + + it "returns false — only active facilitators are published" do + expect(person.published?).to be false + end + end end - describe ".with_active_affiliations" do + describe ".with_active_facilitator_affiliations" do let!(:person_with_active) { create(:person) } let!(:person_with_inactive) { create(:person) } + let!(:person_non_facilitator) { create(:person) } let!(:person_without) { create(:person) } before do create(:affiliation, person: person_with_active, inactive: false, end_date: nil) create(:affiliation, person: person_with_inactive, inactive: true, end_date: nil) + create(:affiliation, person: person_non_facilitator, title: "Counselor", inactive: false, end_date: nil) end - it "includes people with active affiliations" do - expect(Person.with_active_affiliations).to include(person_with_active) + it "includes people with an active facilitator affiliation" do + expect(Person.with_active_facilitator_affiliations).to include(person_with_active) end it "excludes people with only inactive affiliations" do - expect(Person.with_active_affiliations).not_to include(person_with_inactive) + expect(Person.with_active_facilitator_affiliations).not_to include(person_with_inactive) + end + + it "excludes people whose only active affiliation is a non-facilitator role" do + expect(Person.with_active_facilitator_affiliations).not_to include(person_non_facilitator) end it "excludes people with no affiliations" do - expect(Person.with_active_affiliations).not_to include(person_without) + expect(Person.with_active_facilitator_affiliations).not_to include(person_without) end end @@ -498,9 +512,9 @@ def phone_numbers expect(results).not_to include(person_bob) end - it 'chains with_active_affiliations and organization_name without an ambiguous end_date error' do + it 'chains with_active_facilitator_affiliations and organization_name without an ambiguous end_date error' do expect { - Person.with_active_affiliations.search_by_params(organization_name: 'Alpha').to_a + Person.with_active_facilitator_affiliations.search_by_params(organization_name: 'Alpha').to_a }.not_to raise_error end From 0a1ede18878601d037d660c67c65267af54ba557 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 18:05:33 -0400 Subject: [PATCH 12/23] "Inactive" facilitator filter = everyone not a currently-active facilitator Broaden facilitators_inactive to the full not-active umbrella: people with an ended/flagged or upcoming facilitator affiliation AND people with no facilitator affiliation at all (never active). facilitator_status_label likewise reads "Inactive" for a never-facilitator. Mirrors the org index's Inactive (formerly_or_never), which already includes Never active. ADR-0001 D3 sharpened. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/person_decorator.rb | 6 +++--- app/models/person.rb | 13 ++++++------- ...1-organization-affiliation-and-program-status.md | 7 ++++--- spec/decorators/person_decorator_spec.rb | 4 ++-- spec/models/person_spec.rb | 10 ++++++++++ 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 1ed2ca2f7e..c646c9c1be 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -90,11 +90,11 @@ def facilitator_since_year end # The person's facilitator standing for list display: Active if any facilitator - # affiliation is current, Upcoming if one is scheduled but none active, Inactive - # if every facilitator term has ended, nil if they were never a facilitator. + # affiliation is current, Upcoming if one is scheduled but none active, otherwise + # Inactive — which covers both a lapsed facilitator and someone who was never a + # facilitator (neither is a currently-active facilitator). def facilitator_status_label facilitator_affiliations = affiliations.select(&:facilitator?) - return nil if facilitator_affiliations.none? return "Active" if facilitator_affiliations.any?(&:active?) return "Upcoming" if facilitator_affiliations.any?(&:upcoming?) "Inactive" diff --git a/app/models/person.rb b/app/models/person.rb index 6afb340258..831c4462e2 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -220,14 +220,13 @@ class Person < ApplicationRecord scope :facilitators_upcoming, -> { where(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) .where.not(id: Affiliation.facilitators.active.select(:person_id)) } - # People with facilitator affiliation(s) but none currently active — the - # not-active umbrella. Upcoming (future-start) facilitators are included here - # since they aren't active now, mirroring the org index's "Inactive" - # (formerly_or_never) filter, even though Upcoming is also its own filter option - # and status (ADR-0001 D3). + # Everyone who is not a currently-active facilitator — the not-active umbrella: + # people whose facilitator affiliation has ended/is flagged or is upcoming, AND + # people with no facilitator affiliation at all. Mirrors the org index's + # "Inactive" (formerly_or_never) filter; Upcoming and Formerly active are + # narrower options within this set (ADR-0001 D3). scope :facilitators_inactive, -> { - where(id: Affiliation.facilitators.select(:person_id)) - .where.not(id: Affiliation.facilitators.active.select(:person_id)) } + where.not(id: Affiliation.facilitators.active.select(:person_id)) } # Not currently active, but a past facilitator term genuinely ended (real end # date in the past) — distinguishes "used to facilitate" from merely flagged inactive. scope :facilitators_formerly_active, -> { diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index 74289109aa..b25945c43e 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -92,9 +92,10 @@ filter (`Organization.program_status`), so the filter and the chip agree. **The "Inactive" filter is a not-active umbrella.** On both the organization index and the people directory, the facilitator-status filter offers **Upcoming** -as its own option, **and** its **Inactive** option returns every -not-currently-active facilitator — Formerly active, Never active, **and -Upcoming** — because none of them are active right now +as its own option, **and** its **Inactive** option returns everyone/every org +that is not a currently-active facilitator — Formerly active, Never active (no +facilitator affiliation at all), **and Upcoming** — because none of them are +active right now (`Organization.program_status("formerly_or_never")`, `Person.facilitators_inactive`). So an upcoming org/person shows an *Upcoming* chip but is still found when you filter by Inactive. diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index 514d9d933f..4c527c01dc 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -90,9 +90,9 @@ describe "#facilitator_status_label" do let(:person) { create(:person) } - it "is nil when the person has never been a facilitator" do + it "is Inactive when the person has never been a facilitator" do create(:affiliation, person: person, title: "Volunteer", start_date: 1.year.ago) - expect(person.decorate.facilitator_status_label).to be_nil + expect(person.decorate.facilitator_status_label).to eq("Inactive") end it "is Active with a current facilitator affiliation" do diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index fffedb506b..a22ade291b 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -698,6 +698,16 @@ def phone_numbers expect(results).not_to include(person_alice) end + it "inactive: includes people with no facilitator affiliation (never active)" do + member = create(:person, first_name: "Member", last_name: "Only") + create(:affiliation, person: member, title: "Counselor", end_date: nil) + never_affiliated = create(:person, first_name: "Never", last_name: "Affiliated") + + results = Person.search_by_params(facilitator_status: "inactive") + expect(results).to include(member, never_affiliated) + expect(results).not_to include(person_alice) + end + it "inactive: includes an upcoming (not-yet-started) facilitator — the not-active umbrella" do upcoming = create(:person, first_name: "Upcoming", last_name: "Fac") create(:affiliation, person: upcoming, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) From 14eb2d9226dc4718fc16a5a775906d491dc8ade0 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 18:27:41 -0400 Subject: [PATCH 13/23] =?UTF-8?q?Upcoming=20filter=20is=20independent=20of?= =?UTF-8?q?=20Active=20=E2=80=94=20both=20can=20match=20one=20person?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the not-also-active exclusion from facilitators_upcoming: a person who is an active facilitator at one org and has an upcoming facilitator affiliation at another is now returned by both the Active and Upcoming filters, since each is a membership test on that person's affiliations. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/person.rb | 8 ++++---- spec/models/person_spec.rb | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/models/person.rb b/app/models/person.rb index 831c4462e2..77b7a6ae87 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -215,11 +215,11 @@ class Person < ApplicationRecord # People with at least one currently-active facilitator affiliation. scope :facilitators_active, -> { where(id: Affiliation.facilitators.active.select(:person_id)) } - # People with a facilitator affiliation not yet started (future start, none - # active) — Upcoming, not Active or Inactive. + # People with a facilitator affiliation that hasn't started yet (future start, + # not ended). Independent of the Active filter — someone active at one org and + # scheduled at another is returned by both. scope :facilitators_upcoming, -> { - where(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) - .where.not(id: Affiliation.facilitators.active.select(:person_id)) } + where(id: Affiliation.facilitators.with_status("Upcoming").select(:person_id)) } # Everyone who is not a currently-active facilitator — the not-active umbrella: # people whose facilitator affiliation has ended/is flagged or is upcoming, AND # people with no facilitator affiliation at all. Mirrors the org index's diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index a22ade291b..ac6644d8eb 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -689,6 +689,15 @@ def phone_numbers expect(results).not_to include(person_alice) end + it "upcoming and active both include someone active at one org and upcoming at another" do + both = create(:person, first_name: "Both", last_name: "Fac") + create(:affiliation, person: both, title: "Facilitator", start_date: 1.year.ago, end_date: nil) + create(:affiliation, person: both, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + expect(Person.search_by_params(facilitator_status: "upcoming")).to include(both) + expect(Person.search_by_params(facilitator_status: "active")).to include(both) + end + it "inactive: includes people whose facilitator affiliations are all inactive" do lapsed = create(:person, first_name: "Lapsed", last_name: "Fac") create(:affiliation, person: lapsed, title: "Facilitator", end_date: 1.year.ago) From 29d2282c2f627d5a25cf014379a242772596aa1d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 18:59:41 -0400 Subject: [PATCH 14/23] Org status filter reads like the people one The two indexes offer the same facilitator-status taxonomy, so a reader shouldn't have to re-learn the order between them: Inactive is the not-active umbrella and the narrower buckets it contains follow it. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/organization.rb | 12 ++++++++++++ app/views/organizations/_search_boxes.html.erb | 10 ++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/models/organization.rb b/app/models/organization.rb index 60b0e74f30..f687ada8ef 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -131,6 +131,18 @@ def self.awbw end } + # The index's Program status dropdown, in display order. "Inactive" is the + # not-active umbrella (formerly + never + upcoming) with the two narrower + # buckets after it, mirroring Person::FACILITATOR_STATUS_FILTER_OPTIONS so the + # two indexes read the same way. + PROGRAM_STATUS_FILTER_OPTIONS = [ + [ "Active", "active" ], + [ "Inactive", "formerly_or_never" ], + [ "Upcoming", "upcoming" ], + [ "Formerly active", "formerly_active" ], + [ "Never active", "never_active" ] + ].freeze + # 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) { diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index de79217bb7..18ace16064 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -34,14 +34,8 @@
<%= 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" ], - [ "Upcoming", "upcoming" ], - [ "Inactive", "formerly_or_never" ], - [ "Formerly active", "formerly_active" ], - [ "Never active", "never_active" ] - ], params[:program_status]), - include_blank: "All statuses", + options_for_select(Organization::PROGRAM_STATUS_FILTER_OPTIONS, params[:program_status]), + include_blank: "Any status", 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" %>
From cd33879a624fff33083a123c035501b1217c97e7 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 19:14:57 -0400 Subject: [PATCH 15/23] Upcoming orgs aren't legacy-status drift, and one home for the Active SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy column predates Upcoming, so a program whose facilitator is scheduled but hasn't started has no "right" value to store — Pending is a fair record of it, and flagging every such org as drift is noise the warning was never meant to carry. The active scope had grown a verbatim copy of with_status("Active"), which is the duplication #active? already avoids by going through #status_on. Co-Authored-By: Claude Opus 5 (1M context) --- app/decorators/organization_decorator.rb | 4 ++++ app/models/affiliation.rb | 11 ++++------- ...01-organization-affiliation-and-program-status.md | 5 +++++ spec/decorators/organization_decorator_spec.rb | 12 ++++++++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index e7292893e4..2cc78b8649 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -140,7 +140,11 @@ def stored_status_bucket end # E.g. a stored "Active" on an org that never had a facilitator affiliation. + # The legacy column has no Upcoming of its own, so a scheduled-but-not-started + # program is fairly recorded as Pending (or blank/Unknown, which bucket with it). def legacy_status_mismatch? + return stored_status_bucket != :never_active if organization_status_bucket == :upcoming + organization_status_bucket != stored_status_bucket end diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 076b788973..8ba32f7e8e 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -46,13 +46,10 @@ def communications_email } # Genuinely active *now*: not flagged inactive, already started (no future - # start), and not past its end date. The SQL twin of #active? and of - # status_on == "Active" — a future-start row is Upcoming, not Active. - scope :active, -> { - where(inactive: false) - .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", Date.current) - .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current) - } + # start), and not past its end date. A future-start row is Upcoming, not Active. + # Delegates to with_status so the SQL lives in one place, the way #active? + # delegates to #status_on. + scope :active, -> { with_status("Active") } # Affiliations that overlapped a given date, judged purely by their start/end # dates rather than the cached `inactive` flag (which reflects "now"). Use this diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index b25945c43e..011e8c555f 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -111,6 +111,11 @@ 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. +The column has no **Upcoming** of its own, so a derived `:upcoming` is compared +as `:never_active`: a scheduled-but-not-started program is fairly recorded as +`Pending` (or blank/`Unknown`, which bucket with it), and only a stored +`Active`/`Reinstate` or `Inactive`/`Suspended` counts as drift. + ### D4 — Program status: New / Ongoing / Reinstate, judged on one anchor date **One value per (organization, anchor date)**, computed over **all** of the org's diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 07df0b7947..7abe18473a 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -144,6 +144,18 @@ org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) expect(org.decorate).not_to be_legacy_status_mismatch end + + it "is false for a stored 'Pending' org whose only facilitator is upcoming" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(org.reload.decorate).not_to be_legacy_status_mismatch + end + + it "is true for a stored 'Active' org whose only facilitator is upcoming" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + expect(org.reload.decorate).to be_legacy_status_mismatch + end end describe "#organization_status_chip" do From 4b255b6fcc930dba88ca99bf669e0acb5140bb81 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:13:54 -0400 Subject: [PATCH 16/23] An upcoming-only program doesn't move the org's stored status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing `.active` to exclude future starts also reached the write path: sync_organization_status_with_affiliations stamped an org "Inactive" the moment its only facilitator was dated to a future training, and nothing re-runs the callback when that start date arrives. It also defeated the new :upcoming exemption in legacy_status_mismatch?, since a forced "Inactive" buckets as :formerly_active rather than :never_active. Leaving it at active_or_pending would only mirror the problem — an Inactive org gaining an upcoming facilitator would read "Active" before anyone had facilitated. So an upcoming-only program is now a no-op in both directions. The two mismatch specs passed vacuously: without an "Inactive" status row the callback early-returns, so they never exercised it. They seed it now. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/affiliation.rb | 9 +++++++-- spec/decorators/organization_decorator_spec.rb | 7 ++++++- spec/models/affiliation_spec.rb | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 8ba32f7e8e..b6cb1941f3 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -212,10 +212,15 @@ def sync_organization_affiliation_dates # Org status tracks active *Facilitator* affiliations specifically (mirroring the # form's status indicator) — a non-facilitator affiliation does not keep an org active. + # An upcoming-only program is left alone in both directions: a facilitator dated + # to a future training must not stamp the org Inactive (nothing re-runs this when + # the start date arrives), but it hasn't started, so it must not stamp it Active + # either — either way the stored value would read as drift on the edit form. def sync_organization_status_with_affiliations - if organization.affiliations.facilitators.active.exists? + facilitators = organization.affiliations.facilitators + if facilitators.active.exists? reactivate_organization_if_inactive - else + elsif facilitators.with_status("Upcoming").none? deactivate_organization_if_no_active_people end end diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index 7abe18473a..a5a9735120 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -145,13 +145,18 @@ expect(org.decorate).not_to be_legacy_status_mismatch end + # The affiliation save callback only reaches the stored status when the + # "Inactive" status row exists, so seed it or these pass vacuously. it "is false for a stored 'Pending' org whose only facilitator is upcoming" do + OrganizationStatus.find_or_create_by!(name: "Inactive") org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) - expect(org.reload.decorate).not_to be_legacy_status_mismatch + expect(org.reload.organization_status.name).to eq("Pending") + expect(org.decorate).not_to be_legacy_status_mismatch end it "is true for a stored 'Active' org whose only facilitator is upcoming" do + OrganizationStatus.find_or_create_by!(name: "Inactive") org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) expect(org.reload.decorate).to be_legacy_status_mismatch diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index e55e9bf41c..4086fb8e45 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -231,6 +231,22 @@ expect(org.reload.organization_status).to eq(inactive_status) end + it 'leaves an organization alone when its only facilitator is dated to a future training' do + org = create(:organization, organization_status: active_status) + + create(:affiliation, organization: org, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + expect(org.reload.organization_status).to eq(active_status) + end + + it 'leaves an Inactive organization alone when its new facilitator has not started yet' do + org = create(:organization, organization_status: inactive_status) + + create(:affiliation, organization: org, title: "Facilitator", start_date: 1.month.from_now, end_date: nil) + + expect(org.reload.organization_status).to eq(inactive_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) From 01c624961f237781d1481767881627603d37954a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:14:02 -0400 Subject: [PATCH 17/23] Keep upcoming affiliations visible where the row says "Upcoming" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The people index labelled someone "Upcoming" and then rendered an empty Affiliation(s) cell beside it, because the column had moved to the narrowed active? — the org they're about to facilitate for was the one thing the label was about. The profile's affiliations tab dropped them the same way. Co-Authored-By: Claude Opus 5 (1M context) --- app/controllers/people_controller.rb | 2 +- app/views/people/people_results.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 6c524f5a54..8c84b93f06 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -104,7 +104,7 @@ def show @workshop_variation_ideas = WorkshopVariationIdea.credited_to_person(@person).order(created_at: :desc).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/workshop_variation_ideas", locals: { person: @person, workshop_variation_ideas: @workshop_variation_ideas } when "affiliations" - @affiliations = @person.affiliations.active.includes(organization: :logo_attachment).paginate(page: params[:page], per_page: per_page) + @affiliations = @person.affiliations.active_or_pending.includes(organization: :logo_attachment).paginate(page: params[:page], per_page: per_page) render partial: "people/sections/affiliations", locals: { person: @person, affiliations: @affiliations } end end diff --git a/app/views/people/people_results.html.erb b/app/views/people/people_results.html.erb index d342b76a7b..1f94f859e2 100644 --- a/app/views/people/people_results.html.erb +++ b/app/views/people/people_results.html.erb @@ -73,7 +73,7 @@ - <% affiliations = person.affiliations.select { |a| a.organization.present? && a.active? } %> + <% affiliations = person.affiliations.select { |a| a.organization.present? && (a.active? || a.upcoming?) } %> <% if affiliations.any? %> <%# Org names are long and multi-word, so a stacked list of truncated links reads better than chips, which squeeze the From 2b46f83759e980952a18ea8e832638d54ef69e67 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:14:12 -0400 Subject: [PATCH 18/23] "Facilitator since" is blank for someone who never facilitated The column kept the member_since fallback it had as "Affiliated since", so a Counselor with no facilitator role showed a membership year under a heading claiming they facilitated then. The fallback still applies to a real facilitator whose rows carry no start date. Co-Authored-By: Claude Opus 5 (1M context) --- app/decorators/person_decorator.rb | 12 ++++++++---- spec/decorators/person_decorator_spec.rb | 14 +++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index c646c9c1be..e42806d7f8 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -82,11 +82,15 @@ def affiliated_since_date end # Facilitator-since year for list pages — the Ruby (no per-row query) twin of - # facilitator_since_date, falling back to the legacy member_since like the edit - # form. Computed from the eager-loaded affiliations. + # facilitator_since_date. Nil for someone who has never held a facilitator + # affiliation, so the column can't show an unrelated affiliation/membership year + # under a "Facilitator since" heading. For an actual facilitator whose rows carry + # no start date, falls back to the legacy member_since like the edit form. def facilitator_since_year - earliest = affiliations.select(&:facilitator?).filter_map(&:start_date).min - (earliest || member_since)&.year + facilitator_affiliations = affiliations.select(&:facilitator?) + return nil if facilitator_affiliations.none? + + (facilitator_affiliations.filter_map(&:start_date).min || member_since)&.year end # The person's facilitator standing for list display: Active if any facilitator diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index 4c527c01dc..1f260db697 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -81,10 +81,22 @@ expect(person.decorate.facilitator_since_year).to eq(2020) end - it "falls back to member_since when there is no facilitator start date" do + it "falls back to member_since for a facilitator affiliation with no start date" do person.update!(member_since: Date.new(2018, 3, 1)) + create(:affiliation, person: person, title: "Facilitator", start_date: nil) expect(person.decorate.facilitator_since_year).to eq(2018) end + + it "is nil when the person has never held a facilitator affiliation" do + person.update!(member_since: Date.new(2018, 3, 1)) + create(:affiliation, person: person, title: "Counselor", start_date: Date.new(2015, 1, 1)) + expect(person.decorate.facilitator_since_year).to be_nil + end + + it "is nil when the person has no affiliations at all" do + person.update!(member_since: Date.new(2018, 3, 1)) + expect(person.decorate.facilitator_since_year).to be_nil + end end describe "#facilitator_status_label" do From d90f453718594e23c25cceb17312ae779434d389 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:14:12 -0400 Subject: [PATCH 19/23] Judge the affiliation editor's "today" in the server's zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todayISO() read the browser's local date while the ERB badges render against the request's Date.current — and requests run in the signed-in user's zone (ApplicationController#set_time_zone_from_user), not the machine's. Anyone whose OS zone sits ahead of their profile zone saw a row starting today badged "Upcoming" while the server disagreed. The server passes its own today in as a value, so the two can't drift. The system spec pins the user's zone for the same reason: it was building "starts today" in a different zone than the page renders in, and failed for real once the clock crossed midnight UTC. Co-Authored-By: Claude Opus 5 (1M context) --- .../controllers/inactive_toggle_controller.js | 14 ++++++++++---- app/views/affiliations/_fields.html.erb | 3 ++- app/views/affiliations/edit.html.erb | 3 ++- spec/system/upcoming_badge_spec.rb | 5 ++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index c395dc5439..6431dce205 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -8,7 +8,7 @@ import { isFacilitatorTitle } from "../lib/affiliation"; // ended) additionally shows an "Upcoming" badge. export default class extends Controller { static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge", "inactiveBadge"] - static values = { expired: Boolean } + static values = { expired: Boolean, today: String } connect() { if (this.hasTitleTarget) this.updateBorder(); @@ -57,10 +57,16 @@ export default class extends Controller { return value > this.todayISO(); } - // Local "today" as YYYY-MM-DD, compared against the date inputs' own - // YYYY-MM-DD values as strings — no cross-timezone Date parsing (a UTC-parsed - // date input vs a local "today" would misjudge a start/end that equals today). + // "Today" as YYYY-MM-DD, compared against the date inputs' own YYYY-MM-DD values + // as strings — no cross-timezone Date parsing. It comes from the server, because + // the ERB badges are rendered against the app's Date.current: a browser whose + // local date is a day behind (e.g. US evening under a UTC app zone) would + // otherwise call a row starting today "Upcoming" while the server didn't. todayISO() { + return this.todayValue || this.browserToday(); + } + + browserToday() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } diff --git a/app/views/affiliations/_fields.html.erb b/app/views/affiliations/_fields.html.erb index 509628156a..37dd779882 100644 --- a/app/views/affiliations/_fields.html.erb +++ b/app/views/affiliations/_fields.html.erb @@ -37,7 +37,8 @@ end %> <% record = person_side ? f.object.person : f.object.organization %> <% label = person_side ? "Person" : "Organization" %> -
+
<%# Left accent rendered outside the row so it keeps full color regardless of the row's border. %> diff --git a/app/views/affiliations/edit.html.erb b/app/views/affiliations/edit.html.erb index 3dcf64b42a..0a0f2e34ca 100644 --- a/app/views/affiliations/edit.html.erb +++ b/app/views/affiliations/edit.html.erb @@ -140,7 +140,8 @@
<% end %> -
+
diff --git a/spec/system/upcoming_badge_spec.rb b/spec/system/upcoming_badge_spec.rb index e77f64ad98..0ae2fd66a9 100644 --- a/spec/system/upcoming_badge_spec.rb +++ b/spec/system/upcoming_badge_spec.rb @@ -1,7 +1,10 @@ require "rails_helper" RSpec.describe "Affiliation status badges", type: :system do - let(:admin) { create(:user, :admin) } + # Requests render in the signed-in user's zone (ApplicationController + # #set_time_zone_from_user), so pin it to the spec process's own zone — + # otherwise "starts today" straddles a date boundary depending on the hour. + let(:admin) { create(:user, :admin, time_zone: Time.zone.name) } let!(:person) { create(:person, user: admin) } let!(:org) { create(:organization, name: "Zeta Test Center") } From efab80401e8c391e7a1449481c957064698ed86e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 23 Aug 2026 20:14:12 -0400 Subject: [PATCH 20/23] ADR-0001: the chip has four values, the filter has five D3 named the chip's four buckets and then described the filter's Inactive umbrella in the same breath, which reads as though Inactive were a fifth chip. Spell out that Inactive is a filter option only, and list the dropdown's five options in display order. Co-Authored-By: Claude Opus 5 (1M context) --- ...ganization-affiliation-and-program-status.md | 17 +++++++++++------ 1 file changed, 11 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 011e8c555f..0a5c1f638f 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -90,15 +90,20 @@ 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 agree. -**The "Inactive" filter is a not-active umbrella.** On both the organization -index and the people directory, the facilitator-status filter offers **Upcoming** -as its own option, **and** its **Inactive** option returns everyone/every org -that is not a currently-active facilitator — Formerly active, Never active (no +**The filter dropdown has one more option than the chip.** The chip is only ever +one of the four buckets above; **Inactive is a filter option, never a chip**. Both +the organization index and the people directory offer five, in this order — +**Active / Inactive / Upcoming / Formerly active / Never active** +(`Organization::PROGRAM_STATUS_FILTER_OPTIONS`, +`Person::FACILITATOR_STATUS_FILTER_OPTIONS`). + +**"Inactive" is the not-active umbrella.** It returns everyone/every org that is +not a currently-active facilitator — Formerly active, Never active (no facilitator affiliation at all), **and Upcoming** — because none of them are active right now (`Organization.program_status("formerly_or_never")`, `Person.facilitators_inactive`). -So an upcoming org/person shows an *Upcoming* chip but is still found when you -filter by Inactive. +Upcoming and Formerly active are the narrower options inside it. So an upcoming +org/person shows an *Upcoming* chip but is still found when you filter by Inactive. On the edit form this chip **live-updates** from the visible facilitator rows. From 46be9c10707efa0832965282cfbc17da9386ee20 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 24 Aug 2026 07:35:21 -0400 Subject: [PATCH 21/23] Shout-outs judge an affiliation by its dates, not just the cached flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inactive` is only re-derived when an affiliation is saved (set_inactive_from_dates), so a term that simply lapsed still reads inactive: false until something touches the row. Filtering on the flag alone credited a registrant's shout-out to an organization they had already left. Judge it the way every other display does — flagged OR ended — via a new Affiliation#inactive_on?, the in-memory twin of status_on == "Inactive". That keeps a not-yet-started affiliation, which is what we want here: a registrant at a training to be trained is credited to the org they're about to facilitate for. Also pins the program-status anchor with a test: an org whose only facilitator affiliation is minted at a training reads New at that training on every day of a multi-day event and looking back years later, because the anchor is the event's start date and never "today". Co-Authored-By: Claude Opus 5 (1M context) --- app/models/affiliation.rb | 8 +++++++ app/services/event_dashboard.rb | 9 ++++--- spec/services/event_dashboard_spec.rb | 24 +++++++++++++++++++ .../facilitator_program_status_spec.rb | 21 ++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index b6cb1941f3..cf4156ad47 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -120,6 +120,14 @@ def upcoming? status_on == "Upcoming" end + # Ended or flagged, as of a date — the in-memory twin of status_on == "Inactive". + # Prefer this to the raw `inactive` column when judging a row for display: the + # column is only re-derived on save (set_inactive_from_dates), so a term that + # simply lapsed still reads `inactive: false` until something touches it. + def inactive_on?(date = Date.current) + status_on(date) == "Inactive" + end + # This affiliation's status as of a date: Inactive (flagged or ended), Upcoming # (future start), otherwise Active. The in-memory twin of the .with_status scope. def status_on(date = Date.current) diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 1d31598567..43dddb17ae 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -261,14 +261,17 @@ def header_answer_key(identifier) # Shout outs for the recipients page: each active registrant the admin flagged # for a shout-out who also has shout-out text on their profile, paired with that - # text, their first active affiliated organization (if any), and their primary - # sector / age group (from their profile) for the parenthetical after their name. + # text, their first still-standing affiliated organization (if any), and their + # primary sector / age group (from their profile) for the parenthetical after + # their name. "Still standing" is judged on the dates as well as the cached flag, + # and keeps a not-yet-started affiliation — a registrant here to be trained is + # credited to the org they're about to facilitate for. # Flagged registrants with blank shout-out text are omitted; org/sector/age are optional. def shoutouts @shoutouts ||= shoutout_registrants.filter_map do |person| text = person.shoutout_text.to_s.strip.presence next unless text - organization = person.affiliations.reject(&:inactive?).filter_map(&:organization).first + organization = person.affiliations.reject(&:inactive_on?).filter_map(&:organization).first Shoutout.new( recipient: person, organization: organization, diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 8f9d32e1ce..23ab63790b 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -596,6 +596,30 @@ def opt_in(person, text:) expect(shoutout.organization).to eq(org) end + # `inactive` is only re-derived on save, so a term that simply lapsed still + # reads inactive: false — judging on the flag alone credited the shout-out to + # an org the person had already left. + it "skips an organization whose affiliation ended, even with a stale inactive flag" do + opt_in(embedded_applicant, text: "Thank you.") + lapsed = create(:organization, name: "Lapsed Org") + ended = create(:affiliation, person: embedded_applicant, organization: lapsed, + start_date: 3.years.ago.to_date, end_date: 1.year.ago.to_date) + ended.update_column(:inactive, false) + + shoutout = dashboard.shoutouts.find { |s| s.recipient == embedded_applicant } + expect(shoutout.organization).to be_nil + end + + it "keeps an organization the registrant is about to start facilitating for" do + opt_in(embedded_applicant, text: "Excited to begin.") + upcoming_org = create(:organization, name: "Starting Soon Org") + create(:affiliation, person: embedded_applicant, organization: upcoming_org, + title: "Facilitator", start_date: 1.month.from_now.to_date, end_date: nil) + + shoutout = dashboard.shoutouts.find { |s| s.recipient == embedded_applicant } + expect(shoutout.organization).to eq(upcoming_org) + end + it "exposes the registrant's primary sector and age group" do age_range = create(:category_type, name: "AgeRange") embedded_applicant.sectorable_items.create!(sector: create(:sector, name: "Sexual Assault"), is_primary: true) diff --git a/spec/services/facilitator_program_status_spec.rb b/spec/services/facilitator_program_status_spec.rb index def084f800..7faa136e29 100644 --- a/spec/services/facilitator_program_status_spec.rb +++ b/spec/services/facilitator_program_status_spec.rb @@ -105,6 +105,27 @@ def status_on(date = anchor) expect(status.as_of).to eq(Date.current.beginning_of_year) expect(status).to be_year_anchored end + + # The anchor is the event's start date, never "today", so an org whose only + # facilitator affiliation was minted at a training reads New at that training + # whenever you look — on any day of a multi-day event, and years afterward. + it "reads New at the training on every day of it and looking back" do + org = create(:organization) + training_start = Date.new(2026, 9, 10) + create(:affiliation, person: create(:person), organization: org, + title: "Facilitator", start_date: training_start, end_date: nil) + org.reload + + [ training_start, training_start + 1.day, training_start + 2.days ].each do |viewed_on| + travel_to(viewed_on) do + expect(org.facilitator_program_status(as_of: training_start).status).to eq(:new) + end + end + + travel_to(training_start + 3.years) do + expect(org.facilitator_program_status(as_of: training_start).status).to eq(:new) + end + end end describe "#explanation" do From a89f2ffebaddb75ce2d9e7902748dfaa15889664 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 24 Aug 2026 08:33:00 -0400 Subject: [PATCH 22/23] Anchor the roster's program status on each row's own event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attendees roster called itself cross-event and fell back to the year anchor, but every row already knows the registration that linked the org — the query joins event_registrations and then plucks the event away. Keep the event start date and judge each (organization, event) pair there, so an org linked at a 2018 training reads New while the same org at a 2024 one reads Ongoing, and no row carries the "no event in view" caveat any more. Also gates the Upcoming chip on admin. The org index is becoming more than admin-facing, and to everyone else a program that starts next month is simply not active today; showing "Upcoming" there would promise a state of the world that hasn't arrived. Non-admins get plain "Inactive", coloured like Never active since the org has never facilitated. Admins keep Upcoming, which is the distinction they act on. status_bucket_styles takes the same flag, because the org edit form is admin-or-owner and its live chip would otherwise contradict the server render for an owner. Co-Authored-By: Claude Opus 5 (1M context) --- app/decorators/organization_decorator.rb | 43 +++++++++----- app/services/attendees_roster.rb | 56 ++++++++++++------- app/views/organizations/_form.html.erb | 4 +- .../organizations_results.html.erb | 2 +- app/views/organizations/show.html.erb | 2 +- ...nization-affiliation-and-program-status.md | 24 ++++++-- .../decorators/organization_decorator_spec.rb | 39 ++++++++++++- spec/services/attendees_roster_spec.rb | 25 +++++++++ 8 files changed, 153 insertions(+), 42 deletions(-) diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 2cc78b8649..312c442dd2 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -148,8 +148,23 @@ def legacy_status_mismatch? organization_status_bucket != stored_status_bucket end - def organization_status_label - ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) + # Upcoming is an admin-only distinction. Everyone else sees a program that + # hasn't started as plain "Inactive", neutral like Never active (it has never + # facilitated), so a public-facing index reflects the state of the world today + # rather than a scheduled future one. See ADR-0001 D3. + PUBLIC_UPCOMING = { label: "Inactive", bucket: :never_active }.freeze + + def self.display_bucket(bucket, admin:) + bucket == :upcoming && !admin ? PUBLIC_UPCOMING[:bucket] : bucket + end + + def self.display_label(bucket, admin:) + return PUBLIC_UPCOMING[:label] if bucket == :upcoming && !admin + ORG_STATUS_BUCKET_LABELS.fetch(bucket) + end + + def organization_status_label(admin: false) + self.class.display_label(organization_status_bucket, admin: admin) end def self.status_classes_for_bucket(bucket) @@ -162,28 +177,30 @@ def self.status_classes_for_bucket(bucket) end # Lets the edit form's Stimulus controller re-render the chip live without - # hard-coding theme classes in JS. - def self.status_bucket_styles + # hard-coding theme classes in JS. Takes the same admin flag as the server-side + # chip so the live value can't disagree with the rendered one. + def self.status_bucket_styles(admin: false) ORG_STATUS_BUCKET_LABELS.each_key.to_h do |bucket| - [ bucket, { label: ORG_STATUS_BUCKET_LABELS.fetch(bucket), classes: status_classes_for_bucket(bucket) } ] + [ bucket, { label: display_label(bucket, admin: admin), + classes: status_classes_for_bucket(display_bucket(bucket, admin: admin)) } ] end end - def organization_status_classes - self.class.status_classes_for_bucket(organization_status_bucket) + def organization_status_classes(admin: false) + self.class.status_classes_for_bucket(self.class.display_bucket(organization_status_bucket, admin: admin)) end - def organization_status_chip(data: {}) - h.content_tag(:span, organization_status_label, + def organization_status_chip(data: {}, admin: false) + h.content_tag(:span, organization_status_label(admin: admin), data: data, - class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes(admin: admin)}") end # 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}") + def program_since_chip(years = program_since_display, admin: false) + h.content_tag(:span, years.presence || organization_status_label(admin: admin), + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes(admin: admin)}") end # Reads the already-loaded affiliations, so a profile classifies many events diff --git a/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index b20125fe46..11571decc0 100644 --- a/app/services/attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -115,10 +115,13 @@ def affiliation_statuses_by_registrant end end - # Distinct program statuses of each person's affiliated organizations. + # Distinct program statuses of each person's affiliated organizations, each + # judged at the start date of the event whose registration linked it — the + # roster spans events but every row knows its own, so nothing falls back to the + # year anchor. An org linked at two trainings is judged separately at each. 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) + @program_statuses_by_registrant ||= linked_org_anchors_by_registrant.transform_values do |anchors| + anchors.filter_map { |organization_id, anchor| program_status_for(organization_id, anchor) }.uniq(&:status) end end @@ -191,27 +194,42 @@ def ce_registration_by_event_registration .transform_values(&:first) end - # Organization ids linked on each person's in-scope registrations - # (EventRegistrationOrganization), uniqued per person. - def linked_org_ids_by_registrant - @linked_org_ids_by_registrant ||= EventRegistrationOrganization - .joins(:event_registration) + # [ organization_id, event start date ] pairs per person, from the in-scope + # registrations (EventRegistrationOrganization) that linked each org. The event + # comes along because it is what each org's program status is anchored on. + def linked_org_anchors_by_registrant + @linked_org_anchors_by_registrant ||= EventRegistrationOrganization + .joins(event_registration: :event) .where(event_registration_id: registration_ids) - .pluck(Arel.sql("event_registrations.registrant_id"), :organization_id) - .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(registrant_id, organization_id), map| - map[registrant_id] << organization_id unless map[registrant_id].include?(organization_id) + .pluck(Arel.sql("event_registrations.registrant_id"), :organization_id, Arel.sql("events.start_date")) + .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(registrant_id, organization_id, start_date), map| + anchor = [ organization_id, start_date&.to_date ] + map[registrant_id] << anchor unless map[registrant_id].include?(anchor) end end - def affiliation_status(affiliation) - affiliation.status_on + # Organization ids linked on each person's in-scope registrations, uniqued per + # person — the roster's Organization column, which doesn't care which event. + def linked_org_ids_by_registrant + @linked_org_ids_by_registrant ||= linked_org_anchors_by_registrant + .transform_values { |anchors| anchors.map(&:first).uniq } end - # 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 ] - end + def organizations_by_id + @organizations_by_id ||= organizations.index_by(&:id) + end + + # Memoized per (organization, anchor) so an org linked on several people's + # registrations at the same event is classified once. + def program_status_for(organization_id, anchor) + organization = organizations_by_id[organization_id] + return nil unless organization + + @program_status_for ||= {} + @program_status_for[[ organization_id, anchor ]] ||= organization.facilitator_program_status(as_of: anchor) + end + + def affiliation_status(affiliation) + affiliation.status_on end end diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 4661a8f808..d12751bf79 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,7 +1,7 @@ <%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_merged_periods_value: true, - affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json + affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles(admin: allowed_to?(:manage?, Organization)).to_json } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> @@ -325,7 +325,7 @@ • Reinstated — new again, but formerly active (all prior facilitations had ended).

- <%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }) %> + <%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }, admin: allowed_to?(:manage?, Organization)) %> <%= render "organizations/program_status_event_chips", organization: f.object, events: org_events, return_to: "organization_edit" %>
diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 5e654ecf19..3704cd5a0c 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -49,7 +49,7 @@ - <%= organization.decorate.program_since_chip(@program_since_display[organization.id]) %> + <%= organization.decorate.program_since_chip(@program_since_display[organization.id], admin: allowed_to?(:manage?, Organization)) %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index daf7a5f1ca..9e97109cb7 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -41,7 +41,7 @@ <% if allowed_to?(:manage?, @organization) %> - <%= org_decorated.organization_status_chip %> + <%= org_decorated.organization_status_chip(admin: allowed_to?(:manage?, 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 0a5c1f638f..52ed017016 100644 --- a/docs/adr/0001-organization-affiliation-and-program-status.md +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -10,7 +10,8 @@ that are easy to confuse with one another: - **Affiliated since** - **Facilitators since** -- an org-wide **status chip** (Active / Upcoming / Formerly active / Never active) +- an org-wide **status chip** (Active / Upcoming / Formerly active / Never + active; non-admins see Upcoming as "Inactive") - per-event **program-status chips** (New / Ongoing / Reinstate) Several code paths compute overlapping-but-distinct classifications with subtle @@ -90,9 +91,11 @@ 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 agree. -**The filter dropdown has one more option than the chip.** The chip is only ever -one of the four buckets above; **Inactive is a filter option, never a chip**. Both -the organization index and the people directory offer five, in this order — +**The filter dropdown has one more option than the chip.** For an admin the chip +is only ever one of the four buckets above, so **Inactive is a filter option, not +an admin chip value** (non-admins are the exception — see the audience rule +below). Both the organization index and the people directory offer five, in this +order — **Active / Inactive / Upcoming / Formerly active / Never active** (`Organization::PROGRAM_STATUS_FILTER_OPTIONS`, `Person::FACILITATOR_STATUS_FILTER_OPTIONS`). @@ -103,7 +106,18 @@ facilitator affiliation at all), **and Upcoming** — because none of them are active right now (`Organization.program_status("formerly_or_never")`, `Person.facilitators_inactive`). Upcoming and Formerly active are the narrower options inside it. So an upcoming -org/person shows an *Upcoming* chip but is still found when you filter by Inactive. +org/person shows an *Upcoming* chip to an admin and is still found when you filter +by Inactive. + +**Upcoming is an admin-only chip.** Non-admins see a not-yet-started program as +plain **"Inactive"**, coloured neutrally like Never active (it has never +facilitated) — the org index is becoming more than admin-facing and must reflect +the state of the world *today*, not a scheduled future one. Admins keep the +Upcoming chip, which is the operationally useful distinction. The bucket itself +is unchanged; only the label and colour collapse +(`OrganizationDecorator#organization_status_label(admin:)`), and +`.status_bucket_styles(admin:)` takes the same flag so the edit form's live chip +can't disagree with the server render for a non-admin owner. On the edit form this chip **live-updates** from the visible facilitator rows. diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index a5a9735120..6f986a4c73 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -102,7 +102,7 @@ org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.month.from_now, end_date: nil) expect(org.reload.decorate.organization_status_bucket).to eq(:upcoming) - expect(org.reload.decorate.organization_status_label).to eq("Upcoming") + expect(org.reload.decorate.organization_status_label(admin: true)).to eq("Upcoming") end it "prefers :active over :upcoming when a facilitator is active and another is upcoming" do @@ -122,6 +122,43 @@ end end + describe "who sees the Upcoming chip" do + let(:org) do + organization = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) + create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", + start_date: 1.month.from_now, end_date: nil) + organization.reload.decorate + end + + it "shows admins the Upcoming label and its blue theme" do + expect(org.organization_status_label(admin: true)).to eq("Upcoming") + expect(org.organization_status_classes(admin: true)).to include("blue") + end + + it "shows everyone else plain Inactive, coloured like Never active" do + expect(org.organization_status_label).to eq("Inactive") + expect(org.organization_status_classes).to eq(described_class.status_classes_for_bucket(:never_active)) + end + + it "leaves the other buckets alone for both audiences" do + active = create(:organization) + create(:affiliation, organization: active, person: create(:person), title: "Facilitator", start_date: 1.year.ago) + + expect(active.reload.decorate.organization_status_label).to eq("Active") + expect(active.decorate.organization_status_label(admin: true)).to eq("Active") + end + + # The edit form is admin-or-owner, so its live-updating chip has to collapse + # Upcoming for a non-admin owner exactly the way the server render does. + it "collapses Upcoming in the styles the edit form hands to Stimulus" do + expect(described_class.status_bucket_styles(admin: true)[:upcoming][:label]).to eq("Upcoming") + + public_styles = described_class.status_bucket_styles + expect(public_styles[:upcoming][:label]).to eq("Inactive") + expect(public_styles[:upcoming][:classes]).to eq(public_styles[:never_active][:classes]) + 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")) diff --git a/spec/services/attendees_roster_spec.rb b/spec/services/attendees_roster_spec.rb index be2b4c7d15..4b910c868f 100644 --- a/spec/services/attendees_roster_spec.rb +++ b/spec/services/attendees_roster_spec.rb @@ -103,4 +103,29 @@ expect(roster.affiliation_statuses_by_registrant[person.id]).to eq([ "Active", "Upcoming", "Inactive" ]) end end + + describe "#program_statuses_by_registrant" do + # The roster spans events, but each row knows the registration that linked the + # org, so each org is judged at its own training rather than at the year anchor. + it "judges each linked organization at the event that linked it" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: create(:person), organization: org, + title: "Facilitator", start_date: Date.new(2020, 1, 1), end_date: nil) + + early = create(:event, facilitator_training: true, start_date: Date.new(2018, 6, 1)) + late = create(:event, facilitator_training: true, start_date: Date.new(2024, 6, 1)) + [ early, late ].each do |event| + registration = create(:event_registration, event: event, registrant: person, status: "attended") + registration.event_registration_organizations.create!(organization: org) + end + + roster = described_class.new([ person ]) + statuses = roster.program_statuses_by_registrant[person.id] + + expect(statuses.map(&:status)).to contain_exactly(:new, :ongoing) + expect(statuses).to all(satisfy { |status| !status.year_anchored? }) + expect(statuses.map(&:as_of)).to contain_exactly(Date.new(2018, 6, 1), Date.new(2024, 6, 1)) + end + end end From 64d18207bfd366bc59a3ef8108647f6bb3123847 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 24 Aug 2026 09:26:27 -0400 Subject: [PATCH 23/23] Affiliation status stays "as of today", and it says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roster now anchors program status on each row's event, which invites the same move for the Affiliation status column beside it. It shouldn't move: that column answers where a person stands *now* — the point is to spot a lapsed affiliation and chase it — and its filter (person_affiliation_status_ids → Affiliation.with_status) also judges today. Re-anchoring the column on the event would leave it contradicting the filter that selected the rows. Written down so the next reader doesn't "fix" it. Both comments also still named the old "Pending" status; the taxonomy has been Active / Upcoming / Inactive since Affiliation::STATUSES. Sorts Tailwind classes on the files this branch touches (ai/tw-sort) — pre-existing drift, reordering only, no class added or dropped. Co-Authored-By: Claude Opus 5 (1M context) --- app/controllers/events_controller.rb | 5 ++-- .../affiliation_dates_controller.js | 2 +- app/services/attendees_roster.rb | 11 +++++++-- app/views/people/people_results.html.erb | 24 +++++++++---------- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index dbe95d26e6..2d463c272b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -991,8 +991,9 @@ def scholarship_status_person_ids(value) .select(:registrant_id) end - # Person ids with at least one affiliation in the given status (Active / Pending - # / Inactive). + # Person ids with at least one affiliation in the given status (Affiliation:: + # STATUSES — Active / Upcoming / Inactive), judged as of today to match the + # roster's Affiliation status column. def person_affiliation_status_ids(status) Affiliation.with_status(status).select(:person_id) end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 188735c563..6ed5582afb 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -214,7 +214,7 @@ export default class extends Controller { if (!target) return let html = "" if (endDate) { - html += '' + html += '' } html += sinceDate ? this.formatDate(sinceDate) : "—" if (endDate) html += ` – ${this.formatDate(endDate)}` diff --git a/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index 11571decc0..bfd2e05ed3 100644 --- a/app/services/attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -106,8 +106,9 @@ def organization_ids_by_registrant linked_org_ids_by_registrant end - # Distinct affiliation statuses (Active / Pending / Inactive) per person, in - # display order — the index-only Affiliation status column and filter. + # Distinct affiliation statuses (Affiliation::STATUSES — Active / Upcoming / + # Inactive) per person, in display order — the index-only Affiliation status + # column and filter. def affiliation_statuses_by_registrant @affiliation_statuses_by_registrant ||= people.to_h do |person| statuses = person.affiliations.map { |affiliation| affiliation_status(affiliation) }.uniq @@ -229,6 +230,12 @@ def program_status_for(organization_id, anchor) @program_status_for[[ organization_id, anchor ]] ||= organization.facilitator_program_status(as_of: anchor) end + # Deliberately "as of today", unlike the program status above, which anchors on + # each row's event. This column answers a different question — where does this + # person stand *now*, so you can chase a lapsed affiliation — and its filter + # (EventsController#person_affiliation_status_ids) is Affiliation.with_status, + # which also judges today. Anchoring the column on the event would leave it + # disagreeing with the filter that selected the rows. def affiliation_status(affiliation) affiliation.status_on end diff --git a/app/views/people/people_results.html.erb b/app/views/people/people_results.html.erb index 1f94f859e2..e081788429 100644 --- a/app/views/people/people_results.html.erb +++ b/app/views/people/people_results.html.erb @@ -1,27 +1,27 @@ <%= turbo_frame_tag :people_results do %> <%= turbo_stream.replace("people_count", partial: "people_count") %> -
+
<% if @people.any? %>
- - - - - - + + + + + + <% if allowed_to?(:manage?, Person) %> - + <% end %> <% @people.each do |person| %> <% cache [ person, current_user.super_user?, current_user.person_id == person.id ] do %> - "> + "> - <% if allowed_to?(:manage?, Person) %> -
NameFacilitator sincePrimary sectorPrimary age rangeAffiliation(s)SocialsNameFacilitator sincePrimary sectorPrimary age rangeAffiliation(s)SocialsActionsActions
<% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %> <% if allowed_to?(:show?, person) %> @@ -112,12 +112,12 @@ <% end %> + <%= render "social_media_buttons", person: person %> + <%= link_to "User", user_path(person.user), data: { turbo_frame: "_top" }, class: "admin-only bg-blue-100 btn btn-secondary-outline px-2.5 py-1 text-xs" if person.user %> @@ -133,7 +133,7 @@
<% else %> -

+

No <%= Person.model_name.human.pluralize.downcase %> found.

<% end %>