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/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/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 6143c7e061..312c442dd2 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. @@ -138,12 +140,31 @@ 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 - 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) @@ -156,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/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 852b48170f..e42806d7f8 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -81,6 +81,29 @@ 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. 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 + 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 + # 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 "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/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 7c4731f5f6..6ed5582afb 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) } @@ -208,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/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 3083aeff31..6431dce205 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -4,10 +4,11 @@ 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 values = { expired: Boolean } + static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge", "inactiveBadge"] + static values = { expired: Boolean, today: String } connect() { if (this.hasTitleTarget) this.updateBorder(); @@ -36,9 +37,40 @@ export default class extends Controller { this.updateRowBackground(); this.styleTitle(); this.paintFields(); + this.updateBadges(); this.rowTarget.classList.toggle("aff-ended", this.isPast()); } + // "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() { + if (this.isPast()) return false; + const value = this.hasStartDateTarget ? this.startDateTarget.value : ""; + if (!value) return false; + return value > this.todayISO(); + } + + // "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")}`; + } + styleTitle() { if (!this.hasTitleTarget) return; const t = this.titleTarget; @@ -89,7 +121,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/app/models/affiliation.rb b/app/models/affiliation.rb index adba108fcb..cf4156ad47 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -37,13 +37,19 @@ 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. 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 @@ -100,11 +106,26 @@ 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 + + # 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 + + # 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 @@ -199,10 +220,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/app/models/organization.rb b/app/models/organization.rb index 3c5ec6cd53..f687ada8ef 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -120,15 +120,29 @@ 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 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/models/person.rb b/app/models/person.rb index 677cfffd61..77b7a6ae87 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, -> { @@ -211,10 +215,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 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)) } + # 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, -> { @@ -238,6 +250,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 +318,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 @@ -326,8 +340,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/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index b20125fe46..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 @@ -115,10 +116,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 +195,48 @@ 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 + + # 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 end 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/app/views/affiliations/_fields.html.erb b/app/views/affiliations/_fields.html.erb index 7d9fd9e594..37dd779882 100644 --- a/app/views/affiliations/_fields.html.erb +++ b/app/views/affiliations/_fields.html.erb @@ -6,6 +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. %> + <% 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. %> @@ -30,7 +37,8 @@ end %> <% record = person_side ? f.object.person : f.object.organization %> <% label = person_side ? "Person" : "Organization" %> -
| Name | -Affiliated since | -Primary sector | -Primary age range | -Affiliation(s) | -Socials | +Name | +Facilitator since | +Primary sector | +Primary age range | +Affiliation(s) | +Socials | <% if allowed_to?(:manage?, Person) %> -Actions | +Actions | <% end %>
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| <% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %> <% if allowed_to?(:show?, person) %> @@ -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 %>
|
@@ -68,7 +73,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? || 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 @@ -107,12 +112,12 @@ <% end %> | -+ | <%= render "social_media_buttons", person: person %> | <% if allowed_to?(:manage?, 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 %> @@ -128,7 +133,7 @@ |
+
No <%= Person.model_name.human.pluralize.downcase %> found.
<% end %> diff --git a/config/features.yml b/config/features.yml index aa081aee3b..916e87e96d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -160,6 +160,19 @@ 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" + pr_number: 2336 + summary: >- + A facilitator affiliation with a future start date now reads as "Upcoming" + 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 reads as Upcoming until that date arrives. Upcoming orgs also still appear under the Inactive filter." + - name: "Activity log: readable details column" area: reporting display_status: admin_facing diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index cfd809d2bd..6682055ea8 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,25 @@ link_org.call(registration, org) end end + + # A7: a facilitator dated to a future training — not started yet, so their + # 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: upcoming_org) + add_affiliation.call(person, aff_org, title: "Counselor") + 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 # Spread each registration's "registered on" date (created_at) around its event's diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md index ef4719a9c0..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 / 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 @@ -28,10 +29,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 +70,54 @@ 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 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`). + +**"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`). +Upcoming and Formerly active are the narrower options inside it. So an upcoming +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. @@ -87,6 +130,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/lib/domain_theme.rb b/lib/domain_theme.rb index 42dc090763..b49bc25d63 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -56,8 +56,11 @@ 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 (blue — a benign + # not-active state, not an amber warning), Formerly active a lapsed one, Never + # active neutral. org_active: :green, + org_upcoming: :blue, 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 e9e4c41b10..6f986a4c73 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -97,6 +97,66 @@ 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 :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).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 + 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 + + 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 @@ -121,6 +181,23 @@ org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) 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.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 + end end describe "#organization_status_chip" do diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index a166693648..1f260db697 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -72,6 +72,57 @@ 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 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 + let(:person) { create(:person) } + + 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 eq("Inactive") + 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) } diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index ed1e882f80..4086fb8e45 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -87,6 +87,30 @@ 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 '#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 @@ -94,6 +118,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 +136,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 @@ -202,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) 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 diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 03926c5604..ac6644d8eb 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 @@ -658,6 +672,32 @@ 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 "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) @@ -667,6 +707,24 @@ 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) + + results = Person.search_by_params(facilitator_status: "inactive") + expect(results).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", 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 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 diff --git a/spec/system/upcoming_badge_spec.rb b/spec/system/upcoming_badge_spec.rb new file mode 100644 index 0000000000..0ae2fd66a9 --- /dev/null +++ b/spec/system/upcoming_badge_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Affiliation status badges", type: :system do + # 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") } + + before do + driven_by(:selenium_chrome_headless) + sign_in admin + end + + # 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 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 "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", + 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) + + visit edit_person_path(person) + expect(page).to have_css(".nested-fields", minimum: 4, wait: 10) + rows = badges_by_title + + # 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