diff --git a/AGENTS.md b/AGENTS.md index 89340ea2a7..430064124b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,13 +198,16 @@ action, or `authorize! :workshop, to: :summary?`). ### Business Logic +- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods, at year precision for "Affiliated since" (e.g. "2010-2012, 2026") or month precision for "Art program since" (e.g. "Aug 2015 – Jun 2018, Feb 2024"); rendered server-side on the org show/index/edit pages, with `affiliation_dates_controller.js` mirroring it only to live-update the edit form - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build +- `FacilitatorProgramStatus` — The one rule for an organization's New / Ongoing / Reinstate program status as of a date (its facilitator affiliations, strict `<` on the anchor so the affiliation a training mints isn't prior history). Anchors on the event's start date, falling back to Jan 1 of the current year when no event is in view (`year_anchored?`). Carries the anchor, the month the program went active and its merged facilitator periods, so `#explanation` gives every badge its hover text. Reached via `Organization#facilitator_program_status(as_of:)`; see ADR-0001 D4/D5/D7 +- `EventProgramStatusReport` — Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see -- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries +- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries - `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, org city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards - `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the reports-hub scholarship summary card diff --git a/ai/test b/ai/test index 795d46ba15..23cbb932fa 100755 --- a/ai/test +++ b/ai/test @@ -75,6 +75,9 @@ if [ -d spec/system ] && [ -n "$keywords" ]; then related=$(find spec/system -name "*_spec.rb" | grep -Ei "$pattern" | sort -u || true) fi selected=$(printf "%s\n" "$direct" "$related" | grep -E "_spec\.rb$" | sort -u || true) +# A branch that DELETES a system spec still lists it in the diff, and handing rspec +# a path that no longer exists aborts the whole run with a LoadError. +selected=$(printf "%s\n" $selected | while IFS= read -r f; do [ -f "$f" ] && printf "%s\n" "$f"; done || true) # Non-system suite = every spec dir except system (+ any root-level specs). spec_targets=$(find spec -mindepth 1 -maxdepth 1 -type d ! -name system | sort) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 79638d7a6d..660ba21976 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,18 +3,18 @@ class EventsController < ApplicationController skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] before_action :set_event, only: %i[ show edit update destroy preview dashboard attendance sample_ticket registrants roster onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form feature_recipient_shoutout ] - before_action :set_report_filters, only: %i[ revenue participation reports scholarships signins ] + before_action :set_report_filters, only: %i[ revenue participation reports scholarships program_statuses signins ] # The cross-event report suite is visible to admins and event owners alike; what # differs is the rows, which EventPolicy's :reportable scope narrows to the # viewer's own events. #attendance is per-event, so it authorizes its own record # rather than joining this list. - before_action :authorize_report!, only: %i[ revenue participation reports scholarships attendees signins ] + before_action :authorize_report!, only: %i[ revenue participation reports scholarships program_statuses attendees signins ] # Log a visit to each event page / report. after_action so it only fires once # the action rendered successfully (authorization inside the actions has passed); # the turbo_frame_request? / redirect guards skip the lazy results/charts # sub-requests and the confirm-reminder bounce-back. send_reminder is logged # inline on a successful send (it always redirects). - after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships attendees signins confirm_reminder ] + after_action :track_page_view, only: %i[ dashboard attendance roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships program_statuses attendees signins confirm_reminder ] def index authorize! @@ -63,6 +63,7 @@ def reports @revenue_report = EventRevenueReport.new(report_events(Event.paid)) @participation_report = EventParticipationReport.new(report_events(Event.all)) @scholarship_report = EventScholarshipReport.new(report_events(Event.facilitator_trainings)) + @program_status_report = EventProgramStatusReport.new(report_events(Event.facilitator_trainings)) end # Cross-event scholarship report: scholarship dollars and award counts (funded @@ -74,6 +75,13 @@ def scholarships @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end + # Organizations by program status at each facilitator training, by year — the + # annual-reporting figures. Sibling of the other cross-event reports. + def program_statuses + events, selected_year = filtered_report_events(Event.facilitator_trainings) + @report = EventProgramStatusReport.new(events, featured_year: selected_year) + end + # Cross-event index of the people behind event registrations, deduped to one row # per person. Lazy-loaded like the people index: the frame request builds the # filtered/paginated page and its roster; the full request renders the shell @@ -214,7 +222,7 @@ def registrants scope = scope.funder_name(params[:funder_name]) if params[:funder_name].present? scope = scope.submission_status(params[:submission_status], @event) if params[:submission_status].present? scope = scope.registrant_city(params[:city]) if params[:city].present? - scope = scope.organization_status(params[:org_status], @event) if params[:org_status].present? + scope = scope.organization_linking_status(params[:org_status], @event) if params[:org_status].present? scope = scope.account_status(params[:account_status]) if params[:account_status].present? scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present? scope = scope.registrant_state(params[:state]) if params[:state].present? @@ -340,7 +348,8 @@ def recipients recipients = Person.where(id: @dashboard.scholarship_applicant_ids) @breakdowns = AttendeesBreakdowns.new(recipients, events: Event.where(id: @event.id), - registrations: EventRegistration.active) + registrations: EventRegistration.active, + as_of: @event.start_date&.to_date) return render :recipients_charts end @@ -944,14 +953,14 @@ def org_ids_by_city_label .transform_values { |pairs| pairs.map(&:first) } end - # Person ids whose linked training org currently has the given facilitator - # program status (new / ongoing / reinstated). + # Anchored the same way the index's own column is: this list spans events, so + # both read as of the start of the current year rather than disagreeing. def person_program_status_ids(status) status_sym = status.to_sym org_ids = Organization .where(id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id)) .includes(:affiliations) - .select { |organization| organization.facilitator_status_on(Date.current) == status_sym } + .select { |organization| organization.facilitator_status_on == status_sym } .map(&:id) return Person.none if org_ids.empty? person_linked_organization_ids(org_ids) @@ -1137,7 +1146,7 @@ def onboarding_csv_string def onboarding_csv_row(registration, cost_required, day_count, include_ce = false) person = registration.registrant scholarship = registration.scholarships.first - statuses = registration.program_statuses.map { |status| status.to_s.titleize }.join(", ") + statuses = registration.program_statuses.map(&:label).join(", ") row = [ person.first_name, diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 478165046d..be90534ab7 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,8 +8,10 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :windows_type, :organization_status, :sectors, :addresses, + :organization_status, :sectors, :sectorable_items, :addresses, :affiliations, { categorizable_items: { category: :category_type } }, + # Feeds the per-row sector and age-group roll-ups. + { people: Organization::PEOPLE_TAGGINGS }, logo_attachment: :blob )) filtered = base_scope.search_by_params(params).order(:name) @@ -17,15 +19,12 @@ def index @active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id") @organizations = filtered.paginate(page: params[:page], per_page: per_page) org_ids = @organizations.map(&:id) - @affiliated_since = Affiliation.where(organization_id: org_ids) - .group(:organization_id) - .minimum(:start_date) + @program_since_display = @organizations.to_h { |org| [ org.id, org.decorate.program_since_display ] } @active_people_counts = Affiliation.active .where(organization_id: org_ids) .group(:organization_id) .distinct .count(:person_id) - @program_statuses = Organization.program_statuses_by_id(org_ids) render :organizations_results else @@ -38,15 +37,24 @@ def show authorize! @organization if turbo_frame_request? && params[:section] == "events" - events = Event.where(id: @organization.event_registrations.active.select(:event_id)) - .includes(:primary_asset) - .order(start_date: :desc) - .paginate(page: params[:page], per_page: 9) + events = authorized_scope( + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .includes(:primary_asset) + .order(start_date: :desc) + ).paginate(page: params[:page], per_page: 9) return render partial: "organizations/sections/events", locals: { organization: @organization, events: events } end track_view(@organization) + # The admin-only "Program status" block. Trainings only — program status is + # meaningless for other events (ADR-0001 D6). + @organization_events = authorized_scope( + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .where(facilitator_training: true) + .order(start_date: :desc) + ) + workshop_logs = WorkshopLog.where(organization_id: @organization.id) @month_year_options = workshop_logs.group("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m')") .select("DATE_FORMAT(COALESCE(workshop_held_on, created_at, NOW()), '%Y-%m') AS ym, @@ -178,6 +186,18 @@ def set_form_variables @organization.affiliations.proxy_association.target.replace(sorted) end + # Drives the edit form's per-event program-status chips. Trainings only — + # program status is meaningless for other events (ADR-0001 D6). + @organization_events = if @organization.persisted? + authorized_scope( + Event.where(id: @organization.event_registrations.active.select(:event_id)) + .where(facilitator_training: true) + .order(start_date: :desc) + ) + else + Event.none + end + @org_categories_grouped = Category .includes(:category_type) .published @@ -188,7 +208,12 @@ def set_form_variables end def set_index_variables - @organization_statuses = OrganizationStatus.all + @sector_options = Sector.published.order(:name).pluck(:name) + @age_group_options = Category.joins(:category_type) + .where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) + .published + .order(:position, :name) + .pluck(:name) end def populations_served diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index b7c513a172..63474ab8a1 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -8,10 +8,10 @@ class OrganizationDecorator < ApplicationDecorator reinstated: :program_reinstated }.freeze - # Normalize either the :new/:ongoing/:reinstated symbol (EventDashboard / index - # controller) or the "New"/"Ongoing"/"Reinstate" string (Organization#program_status) - # to the canonical symbol; nil when blank or unrecognized. + # Normalize a FacilitatorProgramStatus, a :new/:ongoing/:reinstated symbol, or a + # "New"/"Ongoing"/"Reinstate" string to the canonical symbol; nil if unrecognized. def self.program_status_key(status) + status = status.status if status.respond_to?(:status) return if status.blank? key = status.to_s.downcase.start_with?("reinstat") ? :reinstated : status.to_s.downcase.to_sym @@ -39,16 +39,16 @@ def program_status_badge(status = object.program_status) key = self.class.program_status_key(status) return unless key + # A FacilitatorProgramStatus explains its own verdict; anything else can only + # name it. h.content_tag(:span, key.to_s.first.upcase, - title: key.to_s.titleize, + title: status.respond_to?(:explanation) ? status.explanation : key.to_s.titleize, class: "inline-flex shrink-0 items-center justify-center w-5 h-5 rounded-full border text-xs font-semibold #{self.class.program_status_classes(status)}") end - # The org form's type dropdown offers Organization::AGENCY_TYPES. A record may - # still hold a value that is no longer offered (e.g. the pre-rename legacy label - # "Other (please specify below)"); rendered as-is the select finds no match and - # an untouched save would silently reclassify the org as the first option. Fold - # any unrecognized non-blank value into the catch-all "Other". Blank stays blank. + # The stored agency_type, folding any value no longer offered (e.g. the legacy + # "Other (please specify below)") into "Other" so the select can't silently + # reclassify the org — see AGENCY_TYPES. Blank stays blank. def agency_type_option return object.agency_type if object.agency_type.blank? return object.agency_type if Organization::AGENCY_TYPES.include?(object.agency_type) @@ -90,24 +90,86 @@ def affiliation_end_date affiliations.maximum(:end_date) end - def facilitator_since_date - @facilitator_since_date ||= affiliations.facilitators.minimum(:start_date) + # "Affiliated since", falling back to the org's start_date, then blank. Pass + # preloaded affiliations on list pages to avoid an N+1. + def affiliated_since_display(affiliations = object.affiliations) + AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || "" end - def facilitation_end_date - facilitator_affiliations = affiliations.facilitators - return nil if facilitator_affiliations.active.exists? - facilitator_affiliations.maximum(:end_date) + # "Art program since" — facilitators only, at month precision, since the exact + # start/lapse month is the point. Blank when the org has never facilitated. + def program_since_display(affiliations = object.affiliations) + AffiliationPeriods.label(affiliations.select(&:facilitator?), precision: :month) || "" end - # In-memory program status (:new / :ongoing / :reinstated) for this org as it - # stood on a given date — the same New/Ongoing/Reinstate classification used in - # event context. Delegates to Organization#facilitator_status_on (single source - # of truth), which reads the already-loaded affiliations so a profile can - # classify many events without an N+1. `date` may be a datetime (event.start_date - # is one), so normalize to a Date before the model's date comparisons. + ORG_STATUS_BUCKET_LABELS = { + active: "Active", formerly_active: "Formerly active", never_active: "Never active" + }.freeze + ORG_STATUS_BUCKET_THEMES = { + active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active + }.freeze + + # Derived purely from facilitator affiliations; the stored organization_status + # never feeds into this (ADR-0001 D3). + def organization_status_bucket + facilitators = object.affiliations.select(&:facilitator?) + return :never_active if facilitators.none? + + facilitators.any?(&:active?) ? :active : :formerly_active + end + + # Only used to flag where the legacy column disagrees with the affiliations. + def stored_status_bucket + OrganizationStatus.program_bucket(object.organization_status&.name) + end + + # E.g. a stored "Active" on an org that never had a facilitator affiliation. + def legacy_status_mismatch? + organization_status_bucket != stored_status_bucket + end + + def organization_status_label + ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket) + end + + def self.status_classes_for_bucket(bucket) + theme_key = ORG_STATUS_BUCKET_THEMES.fetch(bucket) + [ + DomainTheme.bg_class_for(theme_key, intensity: 100), + DomainTheme.text_class_for(theme_key, intensity: 700), + DomainTheme.border_class_for(theme_key, intensity: 200) + ].join(" ") + 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 + ORG_STATUS_BUCKET_LABELS.each_key.to_h do |bucket| + [ bucket, { label: ORG_STATUS_BUCKET_LABELS.fetch(bucket), classes: status_classes_for_bucket(bucket) } ] + end + end + + def organization_status_classes + self.class.status_classes_for_bucket(organization_status_bucket) + end + + def organization_status_chip(data: {}) + h.content_tag(:span, organization_status_label, + data: data, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") + end + + # Facilitator years, coloured by the org's status; falls back to the status + # label when there are no facilitator years to show. + def program_since_chip(years = program_since_display) + h.content_tag(:span, years.presence || organization_status_label, + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}") + end + + # Reads the already-loaded affiliations, so a profile classifies many events + # without an N+1. `date` may be a datetime (event.start_date is one). def facilitator_status_as_of(date) - object.facilitator_status_on(date&.to_date) + object.facilitator_program_status(as_of: date&.to_date) end def badges diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 09bb4d5421..3351a47f47 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -90,6 +90,16 @@ def affiliated_since_date @affiliated_since_date ||= affiliations.filter_map(&:start_date).min end + # The server-rendered twin of affiliation_dates_controller#updateDisplay, which + # replaces this content as the person form's affiliation rows are edited. + def affiliated_since_range + date_range_display(affiliated_since_date, affiliation_end_date, ended_title: "No active affiliations") + end + + def facilitator_since_range + date_range_display(facilitator_since_date, facilitation_end_date, ended_title: "No active facilitator affiliations") + end + private def compute_badges @@ -112,6 +122,14 @@ def compute_badges badges end + def date_range_display(since, ended, ended_title:) + parts = [] + parts << h.content_tag(:i, "", class: "fa-solid fa-circle-xmark text-red-400 mr-1", title: ended_title) if ended + parts << (since&.strftime("%b %Y") || "—") + parts << " – #{ended.strftime('%b %Y')}" if ended + h.safe_join(parts) + end + def badge(label, key) { label: label, diff --git a/app/decorators/scholarship_decorator.rb b/app/decorators/scholarship_decorator.rb index d9346ec10d..258fb48a83 100644 --- a/app/decorators/scholarship_decorator.rb +++ b/app/decorators/scholarship_decorator.rb @@ -36,7 +36,7 @@ def program_status def program_status_classes case program&.program_status(object.recipient) when "Ongoing" then "bg-blue-50 text-blue-700 border-blue-200" - when "New" then "bg-green-50 text-green-700 border-green-200" + when "New" then "bg-indigo-50 text-indigo-700 border-indigo-200" when "Reinstate" then "bg-purple-50 text-purple-700 border-purple-200" else "bg-gray-50 text-gray-500 border-gray-200" end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 8bf2371abe..641e23adaf 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -1,7 +1,16 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer"] + static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] + // The person form shows a single Mon YYYY – Mon YYYY range; the org form + // (mergedPeriods) mirrors the AffiliationPeriods service so the live value + // matches the server render. affiliatedSinceFallback is the org's own start_date, + // and statusBuckets each bucket's label + pill classes from DomainTheme. + static values = { + mergedPeriods: Boolean, + affiliatedSinceFallback: String, + statusBuckets: Object + } initialize() { this.boundRecalculate = () => this.recalculate() @@ -38,25 +47,29 @@ export default class extends Controller { recalculate() { const affiliations = this.getVisibleAffiliations() - - // Affiliated since = min start_date of all affiliations - const allStartDates = affiliations.map(a => a.startDate).filter(Boolean) - const affiliatedSince = allStartDates.length - ? new Date(Math.min(...allStartDates.map(d => new Date(d)))) - : null - - // Affiliated end = only if ALL affiliations are inactive (end_date in the past) const now = new Date() const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())) - const allInactive = affiliations.length > 0 && - affiliations.every(a => a.endDate && new Date(a.endDate) < today) - const affiliatedEnd = allInactive - ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) - : null - // Facilitator since/end — same logic filtered by title. Mirror - // Affiliation#facilitator?: an exact, case-sensitive match on "Facilitator" - // (trimmed), so the live figure matches what the server will render. + if (this.hasAffiliatedSinceTarget) { + if (this.mergedPeriodsValue) { + const label = this.periodsLabel(affiliations, today, "year") || this.affiliatedSinceFallbackValue + this.affiliatedSinceTarget.textContent = label || "—" + } else { + const startDates = affiliations.map(a => a.startDate).filter(Boolean) + const affiliatedSince = startDates.length + ? new Date(Math.min(...startDates.map(d => new Date(d)))) + : null + const allInactive = affiliations.length > 0 && + affiliations.every(a => a.endDate && new Date(a.endDate) < today) + const affiliatedEnd = allInactive + ? new Date(Math.max(...affiliations.map(a => new Date(a.endDate)))) + : null + this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) + } + } + + // Mirrors Affiliation#facilitator?: exact, case-sensitive, trimmed — so the + // live figure matches the server render. const facilitatorAffiliations = affiliations.filter(a => a.title.trim() === "Facilitator" ) @@ -70,14 +83,35 @@ export default class extends Controller { ? new Date(Math.max(...facilitatorAffiliations.map(a => new Date(a.endDate)))) : null - if (this.hasAffiliatedSinceTarget) { - this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd) - } if (this.hasFacilitatorSinceTarget) { - this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) + if (this.mergedPeriodsValue) { + this.facilitatorSinceTarget.textContent = + this.periodsLabel(facilitatorAffiliations, today, "month") || "—" + } else { + this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd) + } + } + + // Mirrors OrganizationDecorator#organization_status_bucket. + if (this.hasProgramStatusTarget) { + let bucket + if (facilitatorAffiliations.length === 0) { + bucket = "never_active" + } else { + bucket = allFacInactive ? "formerly_active" : "active" + } + this.updateProgramStatus(bucket) } } + updateProgramStatus(bucket) { + const style = this.statusBucketsValue[bucket] + if (!style) return + const base = "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5" + this.programStatusTarget.textContent = style.label + this.programStatusTarget.className = `${base} ${style.classes}` + } + getVisibleAffiliations() { if (!this.hasAffiliationsContainerTarget) return [] const fields = this.affiliationsContainerTarget.querySelectorAll(".nested-fields") @@ -98,6 +132,52 @@ export default class extends Controller { return `${months[date.getUTCMonth()]} ${date.getUTCFullYear()}` } + // The JS twin of AffiliationPeriods — keep the two in step. Null when no + // affiliation has a start date, so the caller falls back to the org's start_date. + periodsLabel(affiliations, today, precision) { + const intervals = affiliations + .filter(a => a.startDate) + .map(a => [ new Date(a.startDate), a.endDate ? new Date(a.endDate) : null ]) + .sort((a, b) => a[0] - b[0]) + if (!intervals.length) return null + + const periods = [] + intervals.forEach(([ start, finish ]) => { + const last = periods[periods.length - 1] + if (last && (last[1] === null || start <= last[1])) { + last[1] = last[1] === null || finish === null ? null : new Date(Math.max(last[1], finish)) + } else { + periods.push([ start, finish ]) + } + }) + + const ongoing = finish => finish === null || finish >= today + if (precision === "month") { + return periods + .map(([ start, finish ]) => + ongoing(finish) + ? this.formatDate(start) + : `${this.formatDate(start)} – ${this.formatDate(finish)}`) + .join(", ") + } + // A single ongoing period is a fresh org — worth the month's precision. + if (periods.length === 1 && ongoing(periods[0][1])) { + return this.yearOrMonth(periods[0][0], today) + } + return periods + .map(([ start, finish ]) => + ongoing(finish) || start.getUTCFullYear() === finish.getUTCFullYear() + ? `${start.getUTCFullYear()}` + : `${start.getUTCFullYear()}-${finish.getUTCFullYear()}`) + .join(", ") + } + + yearOrMonth(date, today) { + return date.getUTCFullYear() === today.getUTCFullYear() + ? this.formatDate(date) + : `${date.getUTCFullYear()}` + } + updateDisplay(target, sinceDate, endDate) { if (!target) return let html = "" diff --git a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js index 03d63f011c..702edbf6a8 100644 --- a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js +++ b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js @@ -17,10 +17,13 @@ export default class extends Controller { this.handleSubmit = (event) => this.guardSubmit(event); // Capture phase so this runs before other submit listeners (e.g. dirty-form). this.element.addEventListener("submit", this.handleSubmit, true); + // Signal that snapshots are captured so tests can wait before interacting. + this.element.dataset.affiliationFacilitatorWarningReady = ""; } disconnect() { this.element.removeEventListener("submit", this.handleSubmit, true); + delete this.element.dataset.affiliationFacilitatorWarningReady; } guardSubmit(event) { diff --git a/app/helpers/event_participation_helper.rb b/app/helpers/event_participation_helper.rb index 1398b6c712..0abd9715cb 100644 --- a/app/helpers/event_participation_helper.rb +++ b/app/helpers/event_participation_helper.rb @@ -1,4 +1,26 @@ module EventParticipationHelper + # On both org pages' "Program status" block, so returning from the participation + # report lands on the chips the user clicked. + PROGRAM_STATUS_ANCHOR = "program-status".freeze + + # [label, path] for the participation report's eyebrow. Reachable from the + # reports hub, an event dashboard, and an org's program-status chips, so each + # origin passes return_to plus the id its path needs. + def participation_return_link + organization_id = params[:return_organization_id] + + case params[:return_to] + when "organization" + return [ "← Organization", organization_path(organization_id, anchor: PROGRAM_STATUS_ANCHOR) ] if organization_id.present? + when "organization_edit" + return [ "← Organization", edit_organization_path(organization_id, anchor: PROGRAM_STATUS_ANCHOR) ] if organization_id.present? + when "dashboard" + return [ "← Dashboard", dashboard_event_path(params[:event_id]) ] if params[:event_id].present? + end + + [ "← Reports", reports_events_path(report_to_hub_params) ] + end + # A small, neutral year-over-year change indicator for a headcount, e.g. # "▲ 12" / "▼ 3". Direction only, uncoloured. Returns nil when there's no prior # period or no change. diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 3f7b2f181e..ccb0c96ca1 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -100,6 +100,22 @@ def scholarships_report_return_path anchor: params[:return_anchor].presence) end + # A program status is only meaningful relative to a date, so the header names the + # event it was judged at — "Program status (TOS205)". + def program_status_column_label(event = nil) + return "Program status" if event.blank? + + "Program status (#{event.decorate.compact_label})" + end + + # Which date the column's verdicts were judged on. Must match how the data was + # actually anchored — see ADR-0001 D7. + def program_status_column_note(event = nil) + return "New / Ongoing / Reinstate as of #{event.start_date.strftime('%b %-d, %Y')}, this event's start date." if event&.start_date + + "No single event in view, so each organization reads as of #{Date.current.beginning_of_year.strftime('%b %-d, %Y')} — the start of the current reporting year." + end + # Ordered column descriptors for the event Onboarding matrix. The array index # is the table-sort column index, so the header row and every body row iterate # this same list — keeping header buttons and cell positions aligned no matter @@ -116,7 +132,7 @@ def onboarding_columns(event) columns << { key: "attendance", label: "Event attendance", kind: :attendance, sortable: true, align: "center", toggle: "attendance" } columns += [ { key: "program", label: "Organization", kind: :program, sortable: true, align: "left", toggle: "program" }, - { key: "program_type", label: "Program type", kind: :program_type, sortable: true, align: "center", toggle: "program_type" } + { key: "program_type", label: program_status_column_label(event), kind: :program_type, sortable: true, align: "center", toggle: "program_type", note: program_status_column_note(event) } ] if event.cost_cents.to_i > 0 columns << { key: "payment", label: "Payment", kind: :payment, sortable: true, align: "center", toggle: "payment" } diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 41e9666078..956ccc5db9 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -428,12 +428,11 @@ def self.scholarship_allocatable_ids(scholarships) else all end } - # "linked" = at least one organization linked; "unlinked" = no organization - # linked (whether or not an agency name was submitted); "pending" = the - # registrant submitted an agency name on the event's registration form but - # nothing is linked yet (mirrors the Pending chip on the roster). Needs the - # event to resolve its registration form's agency_name field. - scope :organization_status, ->(value, event) { + # The registrant's organization-LINKING status, not the org's own + # OrganizationStatus: "linked" = at least one org linked; "unlinked" = none; + # "pending" = an agency name was submitted but nothing is linked yet (the Pending + # chip on the roster). Needs the event to resolve its agency_name field. + scope :organization_linking_status, ->(value, event) { linked = EventRegistrationOrganization.select(:event_registration_id) case value when "linked" then where(id: linked) @@ -913,20 +912,14 @@ def sync_attendance_status_to_days! true end - # Program status(es) for THIS registration only: classify each organization - # linked to the registration as of the training date, excluding the registrant's - # own facilitator affiliation to that org so the status reflects whether the *org* - # was already a facilitator program when they joined. Using the actual training - # date (not the 1st of its month) means a facilitator affiliation started earlier - # that same month still counts toward the org's activity. Distinct, so one linked - # org shows one badge — unlike the registrant-wide rollup, this ignores - # affiliations to other organizations. + # The organizations linked to THIS registration, as of the training date — the + # same verdict the dashboard, the org profile and the annual report show. Deduped + # by verdict, and unlike the registrant-wide rollup it ignores affiliations to + # other organizations. def program_statuses - reference_date = event&.start_date&.to_date || Date.current - organizations.filter_map do |organization| - own = registrant.affiliations.find { |affiliation| affiliation.organization_id == organization.id && affiliation.facilitator? } - organization.facilitator_status_on(reference_date, excluding_affiliation_id: own&.id) - end.uniq + reference_date = event&.start_date&.to_date + organizations.map { |organization| organization.facilitator_program_status(as_of: reference_date) } + .uniq(&:status) end remote_searchable_by :registrant, diff --git a/app/models/organization.rb b/app/models/organization.rb index 7f316defb5..6c5153c1fd 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -32,18 +32,19 @@ class Organization < ApplicationRecord saver: { quality: 80 } end - # The organization classifications offered by the org form and the registration - # form's "Organization Type" question, in display order. "Other" is the generic - # catch-all; any stored value not in this list (e.g. a legacy label like the - # pre-rename "Other (please specify below)") is folded into it for display so an - # unmatched select can't silently save as the first option. + # List pages must preload people with exactly this, or each row re-queries. + PEOPLE_TAGGINGS = [ { sectorable_items: :sector }, { categorizable_items: { category: :category_type } } ].freeze + + # Org classifications offered by the org form and the registration form's + # "Organization Type" question, in display order. Any stored value not listed + # (e.g. the legacy "Other (please specify below)") folds into "Other" for display + # so an unmatched select can't silently save as the first option. AGENCY_TYPE_OTHER = "Other" AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze - # The organization that runs this app. A grant it self-funds is the org funding - # itself, so reports count it as subsidy (unfunded), not external funding. - # Identified by name via ORGANIZATION_NAME — the only marker available today. - # Not memoized: the record can be created mid-process (seeds, tests). + # The organization that runs this app. A grant it self-funds counts as subsidy + # (unfunded), not external funding, in reports. Not memoized: the record can be + # created mid-process (seeds, tests). def self.awbw find_by(name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) end @@ -105,6 +106,47 @@ def self.awbw end scope.distinct end + # Off facilitator affiliations only, never the legacy organization_status, so the + # filter and the status chip can't disagree (ADR-0001 D3). + scope :program_status, ->(bucket) { + fac_ids = Affiliation.facilitators.select(:organization_id) + active_fac_ids = Affiliation.facilitators.active.select(:organization_id) + 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 "never_active" then where.not(id: fac_ids) + when "formerly_or_never" then where.not(id: active_fac_ids) + else all + end + } + + # Matches a tag on the org itself OR an affiliated person's PRIMARY tag — + # mirroring the aggregate the index/profile columns show. + scope :sector_name_including_people, ->(name) { + next all if name.blank? + term = name.to_s.downcase + direct = joins(:sectors).where("LOWER(sectors.name) = ?", term).select(:id) + via_people = joins(people: { sectorable_items: :sector }) + .where(sectorable_items: { is_primary: true }) + .where("LOWER(sectors.name) = ?", term) + .select(Arel.sql("organizations.id")) + where(id: direct).or(where(id: via_people)) + } + + scope :age_group_name_including_people, ->(name) { + next all if name.blank? + age_category_ids = Category.joins(:category_type) + .where(category_types: { name: AgeGroupTaggable::AGE_RANGE_CATEGORY_TYPE }) + .where("LOWER(categories.name) = ?", name.to_s.downcase) + .select(:id) + direct = joins(:categories).where(categories: { id: age_category_ids }).select(:id) + via_people = joins(people: { categorizable_items: :category }) + .where(categorizable_items: { is_primary: true }) + .where(categories: { id: age_category_ids }) + .select(Arel.sql("organizations.id")) + where(id: direct).or(where(id: via_people)) + } + scope :organization_ids, ->(organization_ids) { where(id: organization_ids.to_s.split("-").map(&:to_i)) } scope :project_ids, ->(project_ids) { where(id: project_ids.to_s.split("-").map(&:to_i)) } scope :published, -> { active } @@ -112,12 +154,11 @@ def self.awbw def self.search_by_params(params) organizations = is_a?(ActiveRecord::Relation) ? self : all organizations = organizations.search(params[:query]) if params[:query].present? - organizations = organizations.sector_names_all(params[:sector_names_all]) if params[:sector_names_all].present? - organizations = organizations.category_names_all(params[:category_names_all]) if params[:category_names_all].present? + organizations = organizations.sector_name_including_people(params[:sector_name]) if params[:sector_name].present? + organizations = organizations.age_group_name_including_people(params[:age_group_name]) if params[:age_group_name].present? organizations = organizations.address(params[:address]) if params[:address].present? - organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? - organizations = organizations.where(organization_status_id: params[:organization_status_id]) if params[:organization_status_id].present? + organizations = organizations.program_status(params[:program_status]) if params[:program_status].present? organizations end @@ -128,45 +169,18 @@ def affiliated_workshop_logs direct.or(legacy).distinct end - # Facilitator program statuses in display order — the values #facilitator_status - # and #facilitator_status_on return, and the attendees index filters on. - FACILITATOR_PROGRAM_STATUSES = %i[ new ongoing reinstated ].freeze - - # Classifies this organization as a facilitator program relative to a reference - # ("current") facilitator affiliation — typically a registrant's affiliation - # captured through the event registration form: - # :new — the reference is the organization's first facilitator - # affiliation (none started before it) - # :ongoing — the organization already had a facilitator affiliation that - # was still active when the reference one started - # :reinstated — the organization had facilitator affiliation(s) before, but - # they all ended before the reference one started (a lapse) - def facilitator_status(current_affiliation) - facilitator_status_on(current_affiliation.start_date, excluding_affiliation_id: current_affiliation.id) - end - - # Classifies this organization relative to a reference DATE rather than a - # reference affiliation — used when a registrant has no facilitator affiliation - # yet (admins create those manually), so we ask "if they got one today, would - # this org be new/ongoing/reinstated?": - # :new — the org has no facilitator affiliation starting before the date - # :ongoing — an earlier facilitator affiliation is still active on the date - # :reinstated — earlier facilitator affiliation(s) existed but all ended first - def facilitator_status_on(reference_date, excluding_affiliation_id: nil) - reference_start = reference_date || Date.current - - # Filter the (often preloaded) affiliations in Ruby rather than firing a query - # per org — the event dashboard classifies every represented org this way. - earlier = affiliations.select do |affiliation| - affiliation.facilitator? && - affiliation.start_date && affiliation.start_date < reference_start && - affiliation.id != excluding_affiliation_id - end + # Display order, and what the attendees index filters on. + FACILITATOR_PROGRAM_STATUSES = FacilitatorProgramStatus::STATUSES - return :new if earlier.empty? + # A FacilitatorProgramStatus (verdict + reasoning) as of a date; nil `as_of` + # anchors on the start of the current year. See ADR-0001 D4. + def facilitator_program_status(as_of: nil) + FacilitatorProgramStatus.for(self, as_of: as_of) + end - active_overlap = earlier.any? { |affiliation| affiliation.end_date.nil? || affiliation.end_date >= reference_start } - active_overlap ? :ongoing : :reinstated + # The bare symbol, for counting and filtering. + def facilitator_status_on(reference_date = nil) + facilitator_program_status(as_of: reference_date).status end # Methods @@ -202,16 +216,10 @@ def program_location [ first_active.city, first_active.state ].compact_blank.join(", ").presence end - # Status of this organization as an AWBW "program," relative to a scholarship - # recipient — the New/Ongoing/Reinstate column on the scholarship index: - # * "Reinstate" — the org has facilitator affiliations but none are currently - # active (it is returning after a lapse); - # * "Ongoing" — the org has facilitator affiliations beyond this recipient - # (an established program); - # * "New" — no prior facilitator affiliations (this recipient is the - # program's first). - # Heuristic based on affiliations (computed in memory to reuse a preloaded - # association); confirm the exact business rule with the team. + # This org's program status relative to a scholarship recipient (the + # New/Ongoing/Reinstate column on the scholarship index): Reinstate = lapsed, + # Ongoing = has facilitators beyond this recipient, New = none prior. In-memory + # facilitator-affiliation heuristic, to reuse a preloaded association. def program_status(recipient = nil) facilitators = affiliations.select(&:facilitator?) return "New" if facilitators.empty? @@ -221,21 +229,6 @@ def program_status(recipient = nil) prior.any? ? "Ongoing" : "New" end - # Bulk program status (:new / :ongoing / :reinstated) for the given org ids, - # keyed by id — the recipient-less form of #program_status, computed with - # aggregate queries so list pages avoid loading each org's affiliations. An org - # with no facilitator affiliations is :new; with facilitators but none - # currently active it is :reinstated; otherwise :ongoing. - def self.program_statuses_by_id(org_ids) - facilitator_scope = Affiliation.facilitators.where(organization_id: org_ids) - with_facilitators = facilitator_scope.distinct.pluck(:organization_id).to_set - with_active = facilitator_scope.active.distinct.pluck(:organization_id).to_set - org_ids.index_with do |id| - next :new if with_facilitators.exclude?(id) - with_active.include?(id) ? :ongoing : :reinstated - end - end - def type_name "#{name} #{ " (#{windows_type.short_name})" if windows_type}" end @@ -255,6 +248,10 @@ def organization_locality def published? # needed for my_bookmarks return true if organization_status&.name == "Active" + # #active? is the in-memory twin of the `active` scope, so a list page that + # preloaded affiliations doesn't query once per row. + return affiliations.any?(&:active?) if affiliations.loaded? + affiliations.active.exists? end @@ -266,12 +263,9 @@ def direct_sectors sectors end + # Only affiliated people's PRIMARY sector; their others don't roll up to the org. def affiliated_sectors - users - .includes(person: :sectors) - .map(&:person) - .compact - .flat_map(&:sectors) + affiliated_people.flat_map { |person| person.sectorable_items.filter_map { |item| item.sector if item.is_primary? } } end def all_sectors @@ -285,8 +279,18 @@ def all_primary_age_groups collect_age_groups(:primary_age_groups) end + # The org's OWN only — affiliated people contribute just their primary age groups. def all_additional_age_groups - collect_age_groups(:additional_age_groups) - all_primary_age_groups + additional_age_groups - all_primary_age_groups + end + + # The roll-up cells aggregate across affiliated people, so retagging a person or + # adding an affiliation leaves the organizations row untouched and `[organization]` + # alone caches them stale. Reads preloaded associations, so it costs no queries. + def rollup_cache_version + records = affiliations.to_a + sectorable_items.to_a + categorizable_items.to_a + + affiliated_people.flat_map { |person| person.sectorable_items.to_a + person.categorizable_items.to_a } + [ records.size, records.filter_map(&:updated_at).max ] end remote_searchable_by :name @@ -305,16 +309,15 @@ def website_link_url private - # Union of the org's own age groups and its affiliated people's, deduped. The - # affiliated people (with their taggings) are loaded once and memoized so the - # several aggregation calls a page render makes don't re-query. + # Union of the org's own age groups and its affiliated people's, deduped. def collect_age_groups(kind) - ([ self ] + affiliated_people_with_age_data).flat_map { |record| record.public_send(kind) }.uniq + ([ self ] + affiliated_people).flat_map { |record| record.public_send(kind) }.uniq end - def affiliated_people_with_age_data - @affiliated_people_with_age_data ||= - people.includes(categorizable_items: { category: :category_type }).to_a + # Reused as-is when already preloaded — list pages preload `people` with the + # PEOPLE_TAGGINGS nest, and a bare `:people` would defeat that. + def affiliated_people + @affiliated_people ||= people.loaded? ? people.to_a : people.includes(PEOPLE_TAGGINGS).to_a end def affiliation_dates_locked diff --git a/app/models/organization_status.rb b/app/models/organization_status.rb index fde753171b..d28fa7cec9 100644 --- a/app/models/organization_status.rb +++ b/app/models/organization_status.rb @@ -1,7 +1,23 @@ class OrganizationStatus < ApplicationRecord ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ] + # Legacy: nothing derives program status from these any more (ADR-0001 D3). The + # mapping survives only so the edit form can flag where the stored value + # contradicts the affiliations. Anything unmapped reads as :never_active. + PROGRAM_STATUS_BUCKETS = { + "Active" => :active, + "Reinstate" => :active, + "Inactive" => :formerly_active, + "Suspended" => :formerly_active, + "Pending" => :never_active, + "Unknown" => :never_active + }.freeze + has_many :organizations validates :name, presence: true, uniqueness: true + + def self.program_bucket(name) + PROGRAM_STATUS_BUCKETS.fetch(name.to_s, :never_active) + end end diff --git a/app/services/affiliation_periods.rb b/app/services/affiliation_periods.rb new file mode 100644 index 0000000000..d9ea82c0e5 --- /dev/null +++ b/app/services/affiliation_periods.rb @@ -0,0 +1,95 @@ +# An affiliation history as merged periods: overlapping intervals collapse, a real +# gap starts a new period, and the periods join chronologically with ", ". +# +# * :year (default) — "Affiliated since", e.g. "2010-2012, 2026". A lone ongoing +# period keeps its month when it began this year ("Jul 2026"). +# * :month — "Art program since", e.g. "Aug 2015 – Jun 2018, Feb 2024", where the +# month a program started or lapsed is the point. +# +# Nil when no affiliation carries a start date, so callers can fall back to the +# organization's own start_date. +class AffiliationPeriods + PRECISIONS = %i[ year month ].freeze + + def self.label(affiliations, today: Date.current, precision: :year) + new(affiliations, today: today, precision: precision).label + end + + def initialize(affiliations, today: Date.current, precision: :year) + raise ArgumentError, "unknown precision #{precision.inspect}" unless PRECISIONS.include?(precision) + + @today = today + @precision = precision + @intervals = affiliations + .filter_map { |affiliation| interval_for(affiliation) } + .sort_by { |start, _finish| start } + end + + def label + return nil if @intervals.empty? + + periods = merged + # A single ongoing period is a fresh org — worth the month's precision. + if @precision == :year && periods.one? && ongoing?(periods.first[1]) + return year_or_month(periods.first[0]) + end + + periods.map { |period| format_period(period) }.join(", ") + end + + private + + # Affiliations without a start date can't be placed on the timeline. + def interval_for(affiliation) + return nil if affiliation.start_date.blank? + + [ affiliation.start_date.to_date, affiliation.end_date&.to_date ] + end + + def merged + @intervals.each_with_object([]) do |(start, finish), periods| + last = periods.last + if last && overlaps?(last, start) + last[1] = later_end(last[1], finish) + else + periods << [ start, finish ] + end + end + end + + # A nil end is ongoing and swallows every later interval. + def overlaps?(period, next_start) + period[1].nil? || next_start <= period[1] + end + + def later_end(current, other) + return nil if current.nil? || other.nil? + + [ current, other ].max + end + + def ongoing?(finish) + finish.nil? || finish >= @today + end + + def format_period((start, finish)) + return month_period(start, finish) if @precision == :month + return start.year.to_s if ongoing?(finish) || start.year == finish.year + + "#{start.year}-#{finish.year}" + end + + def month_period(start, finish) + return month(start) if ongoing?(finish) + + "#{month(start)} – #{month(finish)}" + end + + def month(date) + date.strftime("%b %Y") + end + + def year_or_month(date) + date.year == @today.year ? month(date) : date.year.to_s + end +end diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index 7107e18ff7..c9e9ea0617 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -13,10 +13,14 @@ class AttendeesBreakdowns # registrations: the registration scope those breakdowns draw from — attended # registrations by default, or any active ones when a caller (e.g. the recipients # charts) wants a single event's people regardless of attendance. - def initialize(people, events: Event.all, registrations: EventRegistration.attended) + # as_of: the date program status is judged on. A caller scoped to one event passes + # that event's start date so its verdicts match the event's own dashboard; the + # cross-event index leaves it nil, anchoring on the start of the current year. + def initialize(people, events: Event.all, registrations: EventRegistration.attended, as_of: nil) @people = people @events = events @registrations = registrations + @as_of = as_of end def registrant_count @@ -293,9 +297,13 @@ def org_registrant_pairs .pluck(:organization_id, Arel.sql("event_registrations.registrant_id")) end + # Judged on #as_of — the event's start date when the caller is scoped to one, + # otherwise nil, which FacilitatorProgramStatus anchors on the start of the + # current year. Either way it matches what the breakdown card's note says. + # Symbols here — these feed counts and drill-in buckets, not a badge. def program_status_by_organization @program_status_by_organization ||= organizations.to_h do |organization| - [ organization.id, organization.facilitator_status_on(Date.current) ] + [ organization.id, organization.facilitator_status_on(@as_of) ] end end diff --git a/app/services/attendees_roster.rb b/app/services/attendees_roster.rb index 7360fe5853..b20125fe46 100644 --- a/app/services/attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -115,12 +115,10 @@ def affiliation_statuses_by_registrant end end - # Distinct program statuses (:new / :ongoing / :reinstated) of each person's - # affiliated organizations, as they stand today. Cross-event, so reported as of - # the current date rather than any single event. + # Distinct program statuses of each person's affiliated organizations. def program_statuses_by_registrant @program_statuses_by_registrant ||= organization_ids_by_registrant.transform_values do |organization_ids| - organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq + organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq(&:status) end end @@ -209,9 +207,11 @@ def affiliation_status(affiliation) affiliation.status_on 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_status_on(Date.current) ] + [ organization.id, organization.facilitator_program_status ] end end end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index c87326a8f9..896e605f96 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -499,18 +499,16 @@ def organization_count # Every organization represented at this event (the same set counted by # organization_count) bucketed as :new, :ongoing, or :reinstated — so the three - # buckets always total organization_count. Each org is classified by its - # facilitator history, using the registrant's own (earliest) affiliation to it - # as the reference when present, otherwise the org's earliest facilitator - # affiliation; an org with no facilitator history at all counts as :new. + # buckets always total organization_count. Each org is classified as it stood on + # the event's start date; an org with no facilitator history before then is :new. def program_status_counts @program_status_counts ||= program_status_by_organization.each_with_object({ new: 0, ongoing: 0, reinstated: 0 }) do |(_organization_id, status), counts| - counts[status] += 1 + counts[status.status] += 1 end end - # Program status (:new / :ongoing / :reinstated) per represented organization, - # keyed by organization id — the same classification as program_status_counts. + # The classification behind program_status_counts, carrying the anchor date and + # reasoning each display hovers to explain. def program_status_by_organization @program_status_by_organization ||= organizations.to_h { |organization| [ organization.id, program_status_for(organization) ] } end @@ -523,7 +521,7 @@ def program_status_by_organization def program_status_registrant_ids @program_status_registrant_ids ||= program_status_by_organization .each_with_object({ new: [], ongoing: [], reinstated: [] }) do |(organization_id, status), map| - map[status].concat(organization_registrant_ids_by_org.fetch(organization_id, []).to_a) + map[status.status].concat(organization_registrant_ids_by_org.fetch(organization_id, []).to_a) end .transform_values(&:uniq) end @@ -532,7 +530,7 @@ def program_status_registrant_ids # Person id — for the registrant roster's program-status column. def program_statuses_by_registrant @program_statuses_by_registrant ||= organization_ids_by_registrant.transform_values do |organization_ids| - organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq + organization_ids.filter_map { |organization_id| program_status_by_organization[organization_id] }.uniq(&:status) end end @@ -1017,37 +1015,11 @@ def registrant_ids_by_status end end - # Facilitator status for one represented organization, used by the - # program-status breakdown. Prefers a registrant's own active affiliation to - # the org as the reference point, falling back to the org's earliest - # facilitator affiliation, and treating an org with no facilitator history as - # new. + # The event's own start date, not #reference_date's today-fallback: an undated + # event has no anchor, and the annual report reads it year-anchored too — the + # two must not diverge. def program_status_for(organization) - reference = registrant_affiliations_by_org[organization.id] - &.select(&:facilitator?) - &.min_by { |affiliation| affiliation.start_date || reference_date } - if reference - return organization.facilitator_status_on(reference.start_date || reference_date, - excluding_affiliation_id: reference.id) - end - - # The registrant has no facilitator affiliation to this org as of the event - # (admins create those manually after the fact). Classify the org as it stood - # at the time of the event rather than today, so an org that already had an - # active facilitator reads as :ongoing and a lapsed one as :reinstated — and - # the breakdown doesn't drift as affiliations change afterward. - organization.facilitator_status_on(reference_date) - end - - # This event's active registrants' affiliations that overlapped the event date, - # grouped by organization id — the reference points for the program-status - # breakdown. Anchored to the event (#reference_date) rather than "now" so the - # breakdown reflects the programs as they stood at the time of the event. - def registrant_affiliations_by_org - @registrant_affiliations_by_org ||= Affiliation.active_on(reference_date) - .where(person_id: registrant_ids) - .includes(:organization) - .group_by(&:organization_id) + organization.facilitator_program_status(as_of: event.start_date&.to_date) end # The fixed point in time the organization breakdown is reported as of: the diff --git a/app/services/event_program_status_report.rb b/app/services/event_program_status_report.rb new file mode 100644 index 0000000000..32101108c6 --- /dev/null +++ b/app/services/event_program_status_report.rb @@ -0,0 +1,160 @@ +# Organizations by program status at each facilitator training, grouped by calendar +# year — the figures behind annual reporting. Sibling of EventRevenueReport / +# EventParticipationReport / EventScholarshipReport. Takes decorated events. +# +# Counted two ways, because they answer different questions (ADR-0001 D9): +# the row and year totals count an org once PER training, while +# #distinct_status_counts counts it once for the period, at its earliest training. +class EventProgramStatusReport + STATUSES = FacilitatorProgramStatus::STATUSES + + # One training's organizations and their statuses, keyed by organization id. + Column = Struct.new(:event, :statuses, keyword_init: true) do + def new_count = count_of(:new) + def ongoing_count = count_of(:ongoing) + def reinstated_count = count_of(:reinstated) + def organization_count = statuses.size + + def label = event.compact_label + def date_label = event.start_date? ? event.short_date_range : nil + def year = event.start_date&.year + def anchor_date = event.start_date&.to_date + + def count_of(status) = statuses.count { |_id, program_status| program_status.status == status } + end + + # The additive figures — summed across a year's columns for its totals, and + # across every column for the all-time total. + SUMMABLE = %i[ new_count ongoing_count reinstated_count organization_count ].freeze + + module Aggregates + SUMMABLE.each do |attribute| + define_method(attribute) { columns.sum(&attribute) } + end + + def distinct_status_counts + @distinct_status_counts ||= first_status_by_organization + .values + .each_with_object(STATUSES.index_with(0)) { |status, counts| counts[status.status] += 1 } + end + + def distinct_organization_count = first_status_by_organization.size + def distinct_new_count = distinct_status_counts[:new] + def distinct_ongoing_count = distinct_status_counts[:ongoing] + def distinct_reinstated_count = distinct_status_counts[:reinstated] + + # The two ways of adding up disagree, which is when the view needs to say so. + def repeat_organizations? = organization_count != distinct_organization_count + + private + + def first_status_by_organization + @first_status_by_organization ||= chronological_columns.each_with_object({}) do |column, statuses| + column.statuses.each { |organization_id, status| statuses[organization_id] ||= status } + end + end + + def chronological_columns + columns.sort_by { |column| column.event.start_date || Time.zone.at(0) } + end + end + + # One calendar year of trainings, with its columns and totals. + YearGroup = Struct.new(:year, :columns, :in_progress, keyword_init: true) do + include Aggregates + end + + include Aggregates + include ReportPeriods + + def initialize(events, current_year: Date.current.year, featured_year: nil) + @events = events.to_a + @current_year = current_year + # nil means no specific year is featured (all-time): the headline aggregates + # every training rather than collapsing to the current year. + @featured_year_value = featured_year + end + + # Loads every training's org links, then those orgs with their affiliations, and + # classifies in memory — a fixed number of queries however many trainings. + def columns + @columns ||= begin + links = organization_ids_by_event + organizations = Organization.where(id: links.values.flatten.uniq).includes(:affiliations).index_by(&:id) + @events.map do |event| + statuses = (links[event.id] || []).to_h do |organization_id| + [ organization_id, organizations.fetch(organization_id).facilitator_program_status(as_of: event.start_date&.to_date) ] + end + Column.new(event: event, statuses: statuses) + end + end + end + + def any? = columns.any? + + # Newest year first; undated trainings fall under a nil year that sorts last. + def years + @years ||= columns + .group_by(&:year) + .map { |year, year_columns| YearGroup.new(year: year, columns: sorted(year_columns), in_progress: year == @current_year) } + .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } + end + + # The filtered/navigated-from year, or every training when none is featured. + def featured_year + return all_trainings_group if @featured_year_value.nil? + years_by_value[@featured_year_value] || years.first + end + + def all_trainings_group + @all_trainings_group ||= YearGroup.new(year: nil, columns: columns, in_progress: false) + end + + # The most recent year older than the featured one, for a year-over-year delta. + def prior_year + return nil unless featured_year&.year + years.find { |group| group.year && group.year < featured_year.year } + end + + # Stacked-column series by year, oldest to newest, for the hub card's mini chart. + def chart_series + ascending = years.reject { |group| group.year.nil? }.reverse + { + "New" => :new_count, + "Ongoing" => :ongoing_count, + "Reinstated" => :reinstated_count + }.map do |name, attribute| + { name: name, data: ascending.map { |group| [ group.year.to_s, group.public_send(attribute) ] } } + end + end + + private + + # "Represented" is the population the event dashboard counts: organizations + # linked to an active registration. + def organization_ids_by_event + event_ids = @events.map(&:id) + return {} if event_ids.empty? + + EventRegistrationOrganization + .joins(:event_registration) + .merge(EventRegistration.active) + .where(event_registrations: { event_id: event_ids }) + .pluck(Arel.sql("event_registrations.event_id"), :organization_id) + .group_by(&:first) + .transform_values { |rows| rows.map(&:last).uniq } + end + + # Lets the summary card render 0 rather than blank for a trainingless period. + def empty_year_group(year) + YearGroup.new(year: year, columns: [], in_progress: false) + end + + def years_by_value + @years_by_value ||= years.index_by(&:year) + end + + def sorted(year_columns) + year_columns.sort_by { |column| column.event.start_date || Time.zone.at(0) } + end +end diff --git a/app/services/facilitator_program_status.rb b/app/services/facilitator_program_status.rb new file mode 100644 index 0000000000..7abb8dfa64 --- /dev/null +++ b/app/services/facilitator_program_status.rb @@ -0,0 +1,94 @@ +# The one rule for an organization's New / Ongoing / Reinstated program status, +# judged on its Facilitator affiliations as of an anchor date. Every surface that +# shows the word goes through here, so they can't disagree. See ADR-0001 D4–D7 for +# the rule, the strict-`<` boundary and the anchor. +class FacilitatorProgramStatus + STATUSES = %i[ new ongoing reinstated ].freeze + + # Reads the already-loaded affiliations, so a page can classify many orgs + # without an N+1. + def self.for(organization, as_of: nil) + new(organization.affiliations, as_of: as_of) + end + + attr_reader :as_of + + def initialize(affiliations, as_of: nil) + @as_of = (as_of || Date.current.beginning_of_year).to_date + @year_anchored = as_of.nil? + @facilitators = affiliations.select { |affiliation| affiliation.facilitator? && affiliation.start_date } + end + + # Views that show a year-anchored figure add a caveat saying so. + def year_anchored? = @year_anchored + + def status + @status ||= if earlier.empty? + :new + elsif active_on_anchor.any? + :ongoing + else + :reinstated + end + end + + def label = status.to_s.titleize + + # For :ongoing the most recent start still running on the anchor; for + # :reinstated the most recent start of the lapsed history. Nil for :new. + def active_since + @active_since ||= (active_on_anchor.presence || earlier).filter_map(&:start_date).max + end + + # When a :reinstated program's history ran out. Nil for the other statuses. + def lapsed_on + return nil unless status == :reinstated + + @lapsed_on ||= earlier.filter_map(&:end_date).max + end + + # The facilitator history behind the verdict, e.g. "Aug 2015 – Jun 2018, Feb 2024". + def periods_label + @periods_label ||= AffiliationPeriods.label(@facilitators, today: as_of, precision: :month) + end + + # Hover text, so every display site explains the figure the same way. + def explanation + [ anchor_sentence, reason_sentence, periods_sentence ].compact.join(" ") + end + + private + + def anchor_sentence + anchored = year_anchored? ? "start of #{as_of.year} — no event in view" : "event start date" + "#{label} as of #{as_of.strftime('%b %-d, %Y')} (#{anchored})." + end + + def reason_sentence + case status + when :new then "No facilitator affiliation started before this date." + when :ongoing then "Active facilitator affiliation since #{month(active_since)}." + when :reinstated + ended = lapsed_on ? " through #{month(lapsed_on)}" : "" + "Previously active from #{month(active_since)}#{ended}, with none active on this date." + end + end + + def periods_sentence + return nil if periods_label.blank? + + "Facilitator periods: #{periods_label}." + end + + def month(date) = date&.strftime("%b %Y") + + # Strictly before: an affiliation starting ON the anchor is the one this event + # minted (ADR-0001 D8), so a first-time org still reads New at its own training. + def earlier + @earlier ||= @facilitators.select { |affiliation| affiliation.start_date < as_of } + end + + def active_on_anchor + @active_on_anchor ||= earlier.select { |affiliation| affiliation.end_date.nil? || affiliation.end_date >= as_of } + end +end diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index f8e28cde9f..715a8142ce 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -71,7 +71,7 @@ def dropdown_matched_ids scope = scope.ce_status(@params[:ce_status]) if @params[:ce_status].present? scope = scope.scholarship_status(@params[:scholarship]) if @params[:scholarship].present? scope = scope.comment_status(@params[:comment_status]) if @params[:comment_status].present? - scope = scope.organization_status(@params[:org_status], @event) if @params[:org_status].present? + scope = scope.organization_linking_status(@params[:org_status], @event) if @params[:org_status].present? scope = scope.account_status(@params[:account_status]) if @params[:account_status].present? scope = scope.submission_status(@params[:submission_status], @event) if @params[:submission_status].present? scope = scope.registrant_state(@params[:state]) if @params[:state].present? diff --git a/app/views/events/_breakdown_card.html.erb b/app/views/events/_breakdown_card.html.erb index 3fa581c30b..1c9d5cc50f 100644 --- a/app/views/events/_breakdown_card.html.erb +++ b/app/views/events/_breakdown_card.html.erb @@ -10,7 +10,9 @@ even when there's nothing to chart; omit to let the caller hide the card entirely. row_paths: optional hash of { label => path }. When a row's label has a - path, the whole row links to it (the filtered registrant list). %> + path, the whole row links to it (the filtered registrant list). + note: optional hover text on an info icon beside the title, for a + figure whose basis needs spelling out. %> <% chart = local_assigns.fetch(:chart, nil) %> <% empty_message = local_assigns.fetch(:empty_message, nil) %> <%# map_color: optional base hex for the choropleth ramp (e.g. the addresses color). %> @@ -22,7 +24,12 @@ <% anchor = title.parameterize %>
-

<%= title %>

+

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

<%= data.size %>
diff --git a/app/views/events/_program_status_report.html.erb b/app/views/events/_program_status_report.html.erb new file mode 100644 index 0000000000..7f9761c6f5 --- /dev/null +++ b/app/views/events/_program_status_report.html.erb @@ -0,0 +1,117 @@ +<%# One row per facilitator training, subtotalled by year, then the same period + counted once per organization — because the rows count an org once per training + it attended. Pass `report:` (an EventProgramStatusReport) and `all_time:`. %> +<% years = report.years %> +<% multi_year = years.size > 1 %> +
+
+

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

+ <%= pluralize(report.columns.size, "training") %> +
+ +
+ + + + + + + + + + + + + <% years.each do |group| %> + <% if multi_year %> + + + + <% end %> + <% group.columns.each do |column| %> + + + + + + + + + <% end %> + <% if multi_year %> + + + + + + + + <% end %> + <% end %> + + + + + + + + +
TrainingStatus as ofNewOngoingReinstatedOrganizations
<%= group.year || "Undated" %>
+ <%= link_to column.label, dashboard_event_path(column.event), class: "text-blue-600 hover:underline", title: column.event.title %> + <% if column.date_label %><%= column.date_label %><% end %> + + <%= column.anchor_date&.strftime("%b %-d, %Y") || "—" %> + <%= column.new_count %><%= column.ongoing_count %><%= column.reinstated_count %><%= column.organization_count %>
<%= group.year || "Undated" %> total<%= group.new_count %><%= group.ongoing_count %><%= group.reinstated_count %><%= group.organization_count %>
<%= all_time ? "All-time total" : "Total" %><%= report.new_count %><%= report.ongoing_count %><%= report.reinstated_count %><%= report.organization_count %>
+
+ + <%# The figure to report when "how many programs" means distinct programs rather + than program-events. %> +
+

+ Distinct organizations + +

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

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

+ <% end %> +
+
diff --git a/app/views/events/_program_status_summary.html.erb b/app/views/events/_program_status_summary.html.erb new file mode 100644 index 0000000000..709959c94f --- /dev/null +++ b/app/views/events/_program_status_summary.html.erb @@ -0,0 +1,43 @@ +<%# Reports-hub headline. Expects `report` (an EventProgramStatusReport) and + `period` (its resolved PeriodScope). The tiles count organizations once per + training; the footnote gives the distinct-organization figure. %> +
+
+

Program status

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

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

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

No facilitator trainings match these filters.

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

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

+ <% else %> +
+ No facilitator trainings match these filters yet. +
+ <% end %> +
+
diff --git a/app/views/events/reports.html.erb b/app/views/events/reports.html.erb index e9200bf832..200c1ef316 100644 --- a/app/views/events/reports.html.erb +++ b/app/views/events/reports.html.erb @@ -44,6 +44,7 @@ <%= render "events/participation_summary", report: @participation_report, period: @participation_report.period_scope(@period) %> <%= render "events/scholarship_summary", report: @scholarship_report, period: @scholarship_report.period_scope(@period) %> <%= render "events/revenue_summary", report: @revenue_report, period: @revenue_report.period_scope(@period) %> + <%= render "events/program_status_summary", report: @program_status_report, period: @program_status_report.period_scope(@period) %>
diff --git a/app/views/events/roster.html.erb b/app/views/events/roster.html.erb index b02c595b72..c8478b553e 100644 --- a/app/views/events/roster.html.erb +++ b/app/views/events/roster.html.erb @@ -135,7 +135,7 @@
<% end %>
- <%= render "events/registrant_roster", roster: @dashboard, registrants: @roster_registrants, row_return_to: "roster", show_attendance_status: true %> + <%= render "events/registrant_roster", roster: @dashboard, registrants: @roster_registrants, row_return_to: "roster", show_attendance_status: true, program_status_event: @event %>
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index b5d3edc59f..572895a469 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -1,4 +1,9 @@ -<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning" } }) do |f| %> +<%= simple_form_for(@organization, html: { data: { + controller: "affiliation-dates affiliation-facilitator-warning", + affiliation_dates_merged_periods_value: true, + affiliation_dates_affiliated_since_fallback_value: @organization.start_date&.strftime("%b %Y") || "", + affiliation_dates_status_buckets_value: OrganizationDecorator.status_bucket_styles.to_json + } }) do |f| %> <%= render 'shared/errors', resource: @organization if @organization.errors.any? %> <%= render "duplicate_organizations_warning" %> <% has_affiliations = f.object.persisted? && f.object.affiliations.any? %> @@ -160,10 +165,10 @@ <% end %> - <% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %> - <% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %> - <% show_status_select = allowed_to?(:manage?, Organization) && - (params[:admin] || (f.object.persisted? && !status_matches_affiliations)) %> + <%# Compared as program-status buckets, not raw status names — the legacy column + no longer drives anything, so this only flags where it has gone stale. %> + <% status_matches_affiliations = !f.object.decorate.legacy_status_mismatch? %> + <% show_status_select = allowed_to?(:manage?, Organization) && params[:admin] %> <% unless show_status_select %> <%= f.hidden_field :organization_status_id, value: f.object.organization_status_id || OrganizationStatus.find_by(name: "Active")&.id %> <% end %> @@ -263,14 +268,17 @@

Does not match affiliations status

<% end %> + <% elsif allowed_to?(:manage?, Organization) && !status_matches_affiliations %> +
+ + +
<% end %> <% org_earliest_aff = f.object.persisted? ? f.object.affiliations.minimum(:start_date) : nil %> <% org_aff_ended = f.object.persisted? && f.object.affiliations.any? && !f.object.affiliations.active.exists? %> - <% org_latest_end = f.object.persisted? ? f.object.affiliations.maximum(:end_date) : nil %> - <% org_end_date = org_aff_ended ? org_latest_end : f.object.end_date %> <% org_decorated = f.object.decorate %> - <% org_fac_start = org_decorated.facilitator_since_date %> - <% org_fac_ended = org_decorated.facilitation_end_date %>
- <% if org_aff_ended || (f.object.end_date.present? && !has_affiliations) %><% end %><%= (org_earliest_aff || f.object.start_date)&.strftime('%b %Y') || "—" %><%= " – #{org_end_date.strftime('%b %Y')}" if org_end_date.present? %> + <%= org_decorated.affiliated_since_display.presence || "—" %> <% if has_affiliations && org_earliest_aff.nil? %> <% elsif f.object.start_date.present? && org_earliest_aff.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %> @@ -314,27 +322,34 @@
- <% if org_fac_ended %><% end %><%= org_fac_start&.strftime("%b %Y") || "—" %><%= " – #{org_fac_ended.strftime('%b %Y')}" if org_fac_ended %> + <%= org_decorated.program_since_display.presence || "—" %>
-
- - -
- <%= org_decorated.program_status_badge %> - <%= f.object.program_status %> + <% if allowed_to?(:manage?, Organization) %> + <% org_events = @organization_events || [] %> +
+ + +
+ <%= org_decorated.organization_status_chip(data: { affiliation_dates_target: "programStatus" }) %> + <%= render "organizations/program_status_event_chips", + organization: f.object, events: org_events, return_to: "organization_edit" %> +
-
+ <% end %>
<% if allowed_to?(:manage?, Organization) %>
diff --git a/app/views/organizations/_program_status_event_chips.html.erb b/app/views/organizations/_program_status_event_chips.html.erb new file mode 100644 index 0000000000..e9bef4a779 --- /dev/null +++ b/app/views/organizations/_program_status_event_chips.html.erb @@ -0,0 +1,13 @@ +<%# Per-event program status, on both the org profile and the edit form. The chips + open in a new tab, where the back button is no help, so return_to names which of + the two the participation report should return to. %> +<% decorated = organization.decorate %> +<% events.each do |event| %> + <% status = decorated.facilitator_status_as_of(event.start_date) %> + <%= link_to participation_events_path(event_id: event.id, return_organization_id: organization.id, return_to: return_to), + target: "_blank", rel: "noopener noreferrer", + title: "#{event.title} — #{status.explanation}", + class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{OrganizationDecorator.program_status_classes(status.status)}" do %> + <%= "#{event.start_date.strftime('%b %Y')} · " if event.start_date %><%= status.label %> · <%= event.decorate.compact_label.truncate(24) %> + <% end %> +<% end %> diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index e8159e42b3..936efc6936 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -9,12 +9,20 @@ class: search_field_class(extra: "w-64") %>
- <%= label_tag :windows_type_name, "Windows audience", class: search_label_class %> - <%= select_tag :windows_type_name, - options_for_select(WindowsType::TYPES.map { |wt| [wt, wt] }, - params[:windows_type_name]), - include_blank: "All types", - class: search_field_class(extra: "w-40 search-select-placeholder") %> + <%= label_tag :sector_name, "Sector", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :sector_name, + options_for_select((@sector_options || []).map { |name| [ name, name ] }, params[:sector_name]), + include_blank: "All sectors", + class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+
+ <%= label_tag :age_group_name, "Age group", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :age_group_name, + options_for_select((@age_group_options || []).map { |name| [ name, name ] }, params[:age_group_name]), + include_blank: "All age groups", + class: "w-40 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
<%= label_tag :address, "Address", class: search_label_class %> @@ -24,12 +32,17 @@ <% if allowed_to?(:manage?, Organization) %>
- <%= label_tag :status, "Status", class: search_label_class %> - <%= select_tag :organization_status_id, - options_for_select(@organization_statuses.map { |ps| [ps.name, ps.id] }, - params[:organization_status_id].to_s), + <%= label_tag :program_status, "Program status", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :program_status, + options_for_select([ + [ "Active", "active" ], + [ "Formerly active", "formerly_active" ], + [ "Never active", "never_active" ], + [ "Formerly + Never active", "formerly_or_never" ] + ], params[:program_status]), include_blank: "All statuses", - class: search_field_class(extra: "w-40 search-select-placeholder") %> + class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
<% end %> diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 489610ee52..6e9e0f1ccf 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -7,13 +7,10 @@ - <% if allowed_to?(:manage?, Organization) %> - - <% end %> - + - + <% if allowed_to?(:manage?, Organization) %> @@ -23,26 +20,19 @@ <% @organizations.each do |organization| %> - <% cache [organization, @affiliated_since[organization.id], @active_people_counts[organization.id], @program_statuses&.dig(organization.id), current_user.super_user?] do %> - - <% if allowed_to?(:manage?, Organization) %> - - <% end %> - + <%# The status bucket turns on facilitator activity and rollup_cache_version on + affiliated people's tags — neither touches the organizations row. %> + <% cache [organization, organization.rollup_cache_version, @program_since_display[organization.id], organization.decorate.organization_status_bucket, organization.organization_status_id, @active_people_counts[organization.id], current_user.super_user?] do %> + <% published = organization.published? %> + ">
ProgramOrganizationDesignationsSectors Age group(s)Affiliated sinceProgram since People (<%= number_with_delimiter(@active_people_count) %>)Actions
- <%= organization.decorate.program_status_badge(@program_statuses&.dig(organization.id)) || content_tag(:span, "—", class: "text-gray-300") %> -
- <% status_label = organization.published? ? nil : organization.organization_status&.name %> + <% status_label = published ? nil : organization.organization_status&.name %> <%= organization_profile_button(organization, truncate_at: 30, subtitle: organization.organization_locality, label: status_label, data: { turbo_frame: "_top" }) %>
- <% if organization.windows_type %> - <%= render "shared/badge", label: organization.windows_type.short_name.titleize, classes: "bg-purple-100 text-purple-800 border-purple-200" %> - <% end %> - - <% organization.sectors.each do |sector| %> + <% organization.all_sectors.sort_by { |s| s&.name.to_s }.each do |sector| %> <%= render "sectors/tagging_label", sector: sector %> <% end %>
@@ -59,8 +49,7 @@
- <% affiliated_since = @affiliated_since[organization.id] || organization.start_date %> - <%= affiliated_since&.strftime('%b %Y') %> + <%= organization.decorate.program_since_chip(@program_since_display[organization.id]) %> diff --git a/app/views/organizations/sections/_events.html.erb b/app/views/organizations/sections/_events.html.erb index f8becefa6e..b3949771c8 100644 --- a/app/views/organizations/sections/_events.html.erb +++ b/app/views/organizations/sections/_events.html.erb @@ -1,22 +1,5 @@ <%= turbo_frame_tag "organization_events_section" do %> <% if events.any? %> - <% if allowed_to?(:manage?, Organization) %> - <% decorated_org = organization.decorate %> -
-

Program status by event

-
- <% events.each do |event| %> - <% status = decorated_org.facilitator_status_as_of(event.start_date) %> - <%= render "shared/badge", - label: "#{status.to_s.titleize} · #{event.decorate.compact_label.truncate(24)}", - classes: OrganizationDecorator.program_status_classes(status), - href: event_path(event), - title: event.title %> - <% end %> -
-
- <% end %> -
<% events.each do |event| %> <% decorated = event.decorate %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index 59ae7b6af9..44fd3fc31d 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -47,14 +47,19 @@ <%= [address.locality, [address.city, address.state].compact_blank.join(", "), address.district].compact_blank.join(" · ") %>

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

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

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

+ Art program since + <%= program_since %>

<% end %> @@ -104,6 +109,16 @@ <% end %>
+ <% if allowed_to?(:manage?, @organization) %> +
+

Program status

+
+ <%= org_decorated.organization_status_chip %> + <%= render "organizations/program_status_event_chips", + organization: @organization, events: @organization_events, return_to: "organization" %> +
+
+ <% end %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 676608aa3a..edf690898f 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -274,7 +274,7 @@ <% end %>
- <% if decorated.affiliation_end_date %><% end %><%= decorated.affiliated_since_date&.strftime("%b %Y") || "—" %><%= " – #{decorated.affiliation_end_date.strftime('%b %Y')}" if decorated.affiliation_end_date %> + <%= decorated.affiliated_since_range %> <% if decorated.affiliated_since_date.nil? && person.affiliations.exists? %> <% elsif decorated.member_since_earlier_than_all_affiliations? %> @@ -302,7 +302,7 @@ <% end %>
- <% if decorated.facilitation_end_date %><% end %><%= decorated.facilitator_since_date&.strftime("%b %Y") || "—" %><%= " – #{decorated.facilitation_end_date.strftime('%b %Y')}" if decorated.facilitation_end_date %> + <%= decorated.facilitator_since_range %> <% if decorated.member_since_earlier_than_facilitator_affiliations? %>

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

<% elsif decorated.member_since_differs_from_facilitator_affiliations? %> diff --git a/config/features.yml b/config/features.yml index 91ad92c865..da2a5a23ca 100644 --- a/config/features.yml +++ b/config/features.yml @@ -27,6 +27,51 @@ # ── 2026-08 ───────────────────────────────────────────────────────────────── +- name: "Program status report for annual reporting" + area: reporting + display_status: admin_facing + released_on: 2026-08-18 + action_path: "/events/program_statuses" + pr_number: 1993 + summary: >- + A cross-event report counting how many organizations were New, Ongoing or + Reinstated at each facilitator training, with year totals for annual reporting. + pro_tips: + - The table counts an organization once per training it attended; the + distinct-organization table below counts each one once for the period. + - Filter by year or event type, then read the year total row. + +- name: "One program status, explained on hover" + area: people + display_status: admin_facing + released_on: 2026-08-18 + action_path: "/organizations" + pr_number: 1993 + summary: >- + New / Ongoing / Reinstated now means the same thing everywhere and is judged on + the event's own date, so the dashboard, roster, onboarding matrix and + organization pages always agree. Hover any badge to see why it says what it says. + pro_tips: + - The hover names the date the status was judged on and the facilitator periods + behind it. + - An organization's overall status comes from its facilitator affiliations, not + the old status field — the edit form flags where the two disagree. + +- name: "Sector, age group and program-status filters on the organization index" + area: people + display_status: user_facing + released_on: 2026-08-18 + action_path: "/organizations" + pr_number: 1993 + summary: >- + The organization list gains Sectors and Age group columns and an "Art program + since" chip, filterable by sector, age group and program status. + pro_tips: + - Sector and age group match the organization's own tags plus its affiliated + people's primary tags. + - The "Art program since" chip shows each stretch of facilitating separately, + so a lapse and a return read as two periods. + - name: "Edit an affiliation's details and comments" area: people display_status: admin_facing diff --git a/config/routes.rb b/config/routes.rb index 6a0cbfa1ee..836f2b9c2a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -165,6 +165,7 @@ get :participation get :reports get :scholarships + get :program_statuses get :attendees get :signins end diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md new file mode 100644 index 0000000000..6c92d267f6 --- /dev/null +++ b/docs/adr/0001-organization-affiliation-and-program-status.md @@ -0,0 +1,223 @@ +# ADR-0001 — Organization affiliation dates & program status + +- **Status:** Accepted +- **Date:** 2026-08-03 + +## Context + +The organization profile/edit page surfaces several affiliation-derived figures +that are easy to confuse with one another: + +- **Affiliated since** +- **Facilitations/program since** +- an org-wide **status chip** (Active / Formerly active / Never active) +- per-event **program-status chips** (New / Ongoing / Reinstate) + +Several code paths compute overlapping-but-distinct classifications with subtle +differences — different reference dates, whether the "registrant's own" +affiliation is excluded, and strict-vs-inclusive date boundaries. We kept +re-deriving these rules from scratch. This ADR pins the definitions and the +decisions that resolve the ambiguities so they're written down once. + +## Vocabulary + +- **Affiliation** — an Org ↔ Person link (`affiliations` table) with `title`, + `start_date`, `end_date`, and a cached `inactive` flag. **Not tied to any + event** (there is no `event_id` on an affiliation). +- **Facilitator affiliation** — an affiliation whose `title` is **exactly + `"Facilitator"`** (trimmed, case-sensitive). No fuzzy/`LIKE` matching; "Lead + Facilitator" and "facilitator" do **not** count. See `Affiliation#facilitator?` + and the `.facilitators` scope. +- **Active affiliation** — `inactive == false` **and** (`end_date` is null or + `>= today`). `inactive` is a cached column derived from the dates on save + (`set_inactive_from_dates`: `inactive = end_date.present? && end_date < today`), + so in practice "active" reduces to **no end date, or end date ≥ today**. +- **Facilitator-training event** — `events.facilitator_training == true`. The + only events for which per-event program status is meaningful. + +## Decisions + +### D1 — "Affiliated since": all affiliations, org-only + +Keyed off **all** of the org's affiliations (any title), with **nothing to do +with a registrant**. Rendered as merged year-based periods +(`AffiliationPeriods.label`), e.g. `2010-2012, 2026`; falls back to the org's own +`start_date`, then blank. See `OrganizationDecorator#affiliated_since_display`. + +### D2 — "Art program since": facilitator affiliations only, month precision + +Same merged-period rendering as D1 but over **facilitator** affiliations only, +and at **month** precision (`AffiliationPeriods.label(…, precision: :month)`) — +when a program started or lapsed is the point of the figure. Ongoing reads +`Feb 2024`, closed reads `Aug 2015 – Jun 2018`, e.g. +`Aug 2015 – Jun 2018, Feb 2024`. Blank when the org has never facilitated. See +`OrganizationDecorator#program_since_display`. + +**One value on every surface** (index chip, profile, edit form). The edit form +previously rendered its own earliest-start→latest-end span, which collapsed a +lapse-and-return into a single unbroken range and hid the gap; that is why this +is a single decorator method rather than per-page view logic. + +### D3 — Org-wide status chip: Active / Formerly active / Never active + +Three display buckets (`OrganizationDecorator#organization_status_bucket`), **not +event-relative**, derived from **facilitator affiliations only**: + +- Any **active** facilitator affiliation → **Active** +- Facilitator affiliation(s), but **all ended** → **Formerly active** +- **No** facilitator affiliation → **Never active** + +The stored `OrganizationStatus` column plays **no part**. It is legacy data that +was maintained by hand and drifted; an org is "active" because someone is +facilitating there, not because a column says so. The same rule backs the index +filter (`Organization.program_status`), so the filter and the chip can't disagree. + +On the edit form this chip **live-updates** from the visible facilitator rows. + +### D3a — The legacy status column: flagged, never consulted + +`OrganizationStatus::PROGRAM_STATUS_BUCKETS` survives for one purpose: bucketing +the stored value (`Active`/`Reinstate` → active, `Inactive`/`Suspended` → formerly +active, `Pending`/`Unknown`/missing → never active) so the **org edit form** can +show a warning where it contradicts the affiliations +(`OrganizationDecorator#legacy_status_mismatch?`). Nothing else reads it. Expect +the warning on a fair number of orgs — that is the drift it exists to surface. + +### D4 — Program status: New / Ongoing / Reinstate, judged on one anchor date + +**One value per (organization, anchor date)**, computed over **all** of the org's +facilitator affiliations by a single class, `FacilitatorProgramStatus`: + +- **New** — no facilitator affiliation started **before** the anchor (strict `<`). +- **Ongoing** — an earlier facilitator affiliation is **still active** on the + anchor (no end date, or it ends on/after it). +- **Reinstated** — earlier facilitator affiliation(s) existed but **all ended** + before the anchor (a lapse, now returning). + +`Organization#facilitator_program_status(as_of:)` returns that object; +`#facilitator_status_on(date)` is the bare symbol for counting and filtering. +Nothing else classifies a program — the dashboard breakdown, the onboarding +matrix, the rosters, the org profile/edit chips and the annual report all call +this one method, so they cannot disagree. + +The status object also carries **why**: the anchor date, the month the program +went (or last was) active, and the full facilitator history as merged periods +(`AffiliationPeriods`). `#explanation` renders that as one sentence, which every +display hangs on the badge as hover text. + +The symbols are `:new` / `:ongoing` / `:reinstated`, and `#label` is the one +display word — "New", "Ongoing", **"Reinstated"**. (The scholarship index's +separate `Organization#program_status(recipient)` says "Reinstate"; that string is +not this vocabulary.) + +### D5 — Per-event, not per-registrant: no self-exclusion + +Program status includes **all** of the org's facilitator affiliations, including +those held by the registrants at the event. We do **not** exclude "the +registrant's own affiliation." + +The old per-registrant framing ("was the org already a program *before I +joined*?") is dropped. Two versions of it existed — +`EventRegistration#program_statuses` excluded the registrant's own affiliation as +of the event date, while `EventDashboard#program_status_for` re-anchored on that +affiliation's own start date — so the same org at the same event could read +Ongoing on the onboarding matrix and New in the dashboard pie, and the dashboard's +answer moved when a different registrant signed up. Both now ask the per-event +question: "at this event, was the org New / Ongoing / Reinstate?" + +**What makes this safe:** the affiliation a training mints starts **on the +training date** (D8), and "before" is strict, so a first-time organization still +reads New at its own first training without any exclusion. + +### D6 — Per-event chips only on facilitator-training events + +The org-profile per-event chips render **only** for events where +`facilitator_training == true` (among the events the org is represented at via +active registrations). Attendance at a non-training event does **not** produce a +program-status chip — this is what stops attendance-only events from reading +"New" or "Reinstate". + +### D7 — The anchor date: the event's start date, else January 1 + +The classification anchors on the event's actual `start_date` (not the 1st of its +month, not "today"), so revisiting a past event always reports what was true then. + +**With no event in view** — the cross-event attendees roster and its breakdowns — +there is no event date to anchor on, so the status reads as of **January 1 of the +current year**: where each program stands this reporting year. +`FacilitatorProgramStatus` applies that fallback itself and flags `year_anchored?`, +and those views carry a caveat saying so. Any column showing a status names its +anchor: "Program status (TOS205)" in event context, with a hover giving the exact +date. + +**The fallback is for surfaces that genuinely span events, not for every +cross-event class.** `AttendeesBreakdowns` backs both the cross-event attendees +index *and* one event's scholarship-recipients charts, so it takes an `as_of:`: the +recipients frame passes that event's `start_date` and reports the same verdicts its +dashboard does, while the index leaves it nil and gets the year anchor. A +single-event surface reaching for the year fallback is a bug — it makes the same +org read two ways at the same event, and the column's own note then describes a +basis the numbers don't have. + +### D8 — Registration mints the facilitator affiliation, dated to the training + +A facilitator-training registration creates the registrant's Facilitator +affiliation at submission time (`AffiliationServices::CreateFromRegistration`, +from both the public registration flow and admin org-linking), with +`start_date = event.start_date`. Dating it to the training rather than to the +submission is what lets D5 drop self-exclusion: the minted row is never "prior +history" for its own training. An event with no start date is the one gap — the +affiliation then falls back to the creation date. That event has no anchor either, +so both its dashboard and the annual report read it as year-anchored (D7) rather +than one of them silently using "today". + +### D9 — Annual reporting counts organizations two ways + +`EventProgramStatusReport` (the "Program status" report page, alongside revenue / +participation / scholarships) reports, per facilitator training and grouped by +calendar year: + +- **Organization-trainings** (the row and year totals) — one count per + organization **per training**, using that training's own anchor date. An org at + three trainings in a year counts three times. This is the per-event figure that + adds up across a year. +- **Distinct organizations** — each organization counted **once** for the period, + classified at the **earliest** training it appeared at. This is "how many + distinct programs did we touch, and what were they when we first saw them." + +Both are shown, always labelled, because they answer different questions and only +coincide when no organization attended twice. + +## The classifiers (map) + +| Method | Role | +|---|---| +| `FacilitatorProgramStatus` | The rule. Verdict + anchor + reasoning. | +| `Organization#facilitator_program_status(as_of:)` | Entry point; reads preloaded affiliations. | +| `Organization#facilitator_status_on(date)` | The bare symbol, for counting/filtering. | +| `Organization#program_status(recipient)` | Scholarship-index string variant, **recipient-relative** — a distinct context (see below). | + +## Boundary conventions + +- **Strict `<`** for "earlier": `start_date == anchor` is **not** earlier (so the + affiliation a training mints is **New**, not Ongoing). +- **Active-at-date** uses `end_date IS NULL OR end_date >= anchor`. + +## Notes / open items + +- **Scholarship `program_status(recipient)`** stays recipient-relative (it + excludes the recipient's own affiliations to answer a scholarship-specific + question). It is intentionally **not** covered by D5; reconcile with the + per-event model later if the two need to agree. +- **Legacy affiliation dates are the one risk in dropping self-exclusion.** D5 is + safe for anything minted since #2176, which dates the row to the training (D8). + **Rows created before that may still be dated to the 1st of the month**, so a + registrant's own affiliation can precede a same-month training and read that org + as Ongoing where it should be New. Worth a one-off count of facilitator + affiliations dated to the 1st that precede a training the same month before + trusting a historical year's New figures. +- **What creates a facilitator affiliation:** since #2194 only a facilitator + *training* registration mints one (non-training registrations get a job + affiliation instead), and the row records its creating `event_registration_id`. + That makes the D3 status buckets — which now key off facilitator affiliations + alone — a read on training participation rather than on any registration. diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index 31006f0113..830a2b67d1 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -49,11 +49,18 @@ module DomainTheme user_only: :amber, person_bio: :purple, - # Organization program status badges (amber is reserved for warnings) - program_new: :green, + # Per-event program status badges — New is indigo (not green) so it never + # collides with the org-wide "Active" status (amber is reserved for warnings). + program_new: :indigo, program_ongoing: :blue, program_reinstated: :purple, + # Org-wide program status (the stored organization_status): Active is the + # positive current state, Formerly active a lapsed one, Never active neutral. + org_active: :green, + org_formerly_active: :orange, + org_never_active: :gray, + # Badges (non-model-specific) legacy_facilitator: :yellow, seasoned_facilitator: :sky, diff --git a/spec/decorators/organization_decorator_spec.rb b/spec/decorators/organization_decorator_spec.rb index dd1abf06b7..66b9ef57a0 100644 --- a/spec/decorators/organization_decorator_spec.rb +++ b/spec/decorators/organization_decorator_spec.rb @@ -1,9 +1,136 @@ require "rails_helper" RSpec.describe OrganizationDecorator do + describe "#affiliated_since_display" do + let(:organization) { create(:organization) } + + it "is blank when there are no affiliations and no start date" do + organization.update_column(:start_date, nil) + expect(organization.decorate.affiliated_since_display).to eq("") + end + + it "falls back to the org start_date when there are no affiliations" do + organization.update_column(:start_date, Date.new(2015, 3, 1)) + expect(organization.decorate.affiliated_since_display).to eq("Mar 2015") + end + + it "shows merged affiliation periods" do + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2010, 1, 1), end_date: Date.new(2012, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2013, 1, 1), end_date: Date.new(2015, 6, 1)) + expect(organization.reload.decorate.affiliated_since_display).to eq("2010-2012, 2013-2015") + end + end + + describe "#program_since_display" do + let(:organization) { create(:organization) } + + it "is blank when the org has never had a facilitator affiliation" do + create(:affiliation, organization: organization, person: create(:person), title: "Volunteer", start_date: Date.new(2010, 1, 1)) + expect(organization.reload.decorate.program_since_display).to eq("") + end + + it "shows merged facilitator-affiliation periods, ignoring non-facilitator ones" do + create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), title: "Volunteer", start_date: Date.new(2005, 1, 1), end_date: nil) + create(:affiliation, organization: organization, person: create(:person), title: "Facilitator", start_date: Date.new(2024, 2, 1), end_date: nil) + expect(organization.reload.decorate.program_since_display).to eq("Jan 2015 – Jun 2018, Feb 2024") + end + end + + describe "#organization_status_label" do + # Every stored status reads "Never active" here: with no facilitator affiliation + # there is no program, whatever the legacy column says. + OrganizationStatus::ORGANIZATION_STATUSES.each do |stored| + it "ignores stored '#{stored}' and reads 'Never active' without facilitator affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: stored)) + expect(org.decorate.organization_status_label).to eq("Never active") + end + end + + it "renders a missing status as 'Never active'" do + org = create(:organization) + org.update_columns(organization_status_id: nil) + expect(org.decorate.organization_status_label).to eq("Never active") + end + end + + describe "#organization_status_bucket (facilitator affiliations only)" do + it "is :active for an active facilitator affiliation even when stored status is Suspended" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Suspended")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:active) + end + + it "is :formerly_active for only-lapsed facilitator affiliations even when stored status is Active" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + expect(org.reload.decorate.organization_status_bucket).to eq(:formerly_active) + end + + it "is :never_active when a stored 'Active' org has only non-facilitator affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate.organization_status_bucket).to eq(:never_active) + end + end + + describe "#legacy_status_mismatch?" do + it "is true when the stored status outranks the affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + expect(org.reload.decorate).to be_legacy_status_mismatch + end + + it "is true for a stored 'Active' org that has never had a facilitator affiliation" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + expect(org.decorate).to be_legacy_status_mismatch + end + + it "is false when the stored status buckets the same way as the affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + expect(org.reload.decorate).not_to be_legacy_status_mismatch + end + + it "is false for a stored 'Pending' org with no facilitator affiliations" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Pending")) + expect(org.decorate).not_to be_legacy_status_mismatch + end + end + + describe "#organization_status_chip" do + it "renders a pill with the bucketed label and its status color" do + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + chip = org.reload.decorate.organization_status_chip + expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") + expect(chip).to include("orange") + end + end + + describe "#program_since_chip" do + it "shows the years in the org's status colour (Active → green)" do + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 1.year.ago, end_date: nil) + chip = org.reload.decorate.program_since_chip("2021") + expect(Capybara.string(chip)).to have_css("span", text: "2021") + expect(chip).to include("green") + end + + it "falls back to the status label (orange) when there are no facilitator years" do + org = create(:organization) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", start_date: 3.years.ago, end_date: 1.year.ago) + chip = org.reload.decorate.program_since_chip("") + expect(Capybara.string(chip)).to have_css("span", text: "Formerly active") + expect(chip).to include("orange") + end + end + describe ".program_status_classes" do it "maps each status to its pill classes, accepting symbols or model strings" do - expect(described_class.program_status_classes(:new)).to include("green") + expect(described_class.program_status_classes(:new)).to include("indigo") expect(described_class.program_status_classes(:ongoing)).to include("blue") expect(described_class.program_status_classes(:reinstated)).to include("purple") # Organization#program_status returns "Reinstate" (no trailing d). @@ -61,27 +188,27 @@ let(:reference) { Date.new(2026, 6, 1) } it "is :new when there are no facilitator affiliations starting before the date" do - expect(organization.decorate.facilitator_status_as_of(reference)).to eq(:new) + expect(organization.decorate.facilitator_status_as_of(reference).status).to eq(:new) end it "ignores facilitator affiliations that start on or after the date" do create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference) - expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:new) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).to eq(:new) end it "is :ongoing when an earlier facilitator affiliation is still active on the date" do create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference - 1.year, end_date: nil) - expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:ongoing) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).to eq(:ongoing) end it "is :reinstated when earlier facilitator affiliations all ended before the date" do create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: reference - 2.years, end_date: reference - 1.year) - expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:reinstated) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).to eq(:reinstated) end it "ignores non-facilitator affiliations" do create(:affiliation, organization: organization, person: person, title: "Volunteer", start_date: reference - 1.year, end_date: nil) - expect(organization.reload.decorate.facilitator_status_as_of(reference)).to eq(:new) + expect(organization.reload.decorate.facilitator_status_as_of(reference).status).to eq(:new) end end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index 08c5531b95..fa1ff6a1a2 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -23,6 +23,276 @@ end end + describe 'associations' do + it { should belong_to(:organization) } + it { should belong_to(:person) } + it { should belong_to(:organization_address).class_name("Address").optional } + end + + describe 'validations' do + subject do + build(:affiliation, organization: create(:organization), person: create(:person)) + end + it { should validate_presence_of(:organization_id) } + # it { should validate_presence_of(:person_id) } # we needed to not have this to support nested attrs + end + + describe '#organization_address' do + let(:organization) { create(:organization) } + let(:address) { create(:address, addressable: organization) } + + it 'is valid when the address belongs to the same organization' do + affiliation = build(:affiliation, organization: organization, organization_address: address) + expect(affiliation).to be_valid + end + + it 'is valid when no address is linked' do + affiliation = build(:affiliation, organization: organization, organization_address: nil) + expect(affiliation).to be_valid + end + + it 'is invalid when the address belongs to a different organization' do + other_address = create(:address, addressable: create(:organization)) + affiliation = build(:affiliation, organization: organization, organization_address: other_address) + expect(affiliation).not_to be_valid + expect(affiliation.errors[:organization_address_id]).to be_present + end + + it "is invalid when the address belongs to a person" do + person_address = create(:address, addressable: create(:person)) + affiliation = build(:affiliation, organization: organization, organization_address: person_address) + expect(affiliation).not_to be_valid + end + + it 'is nullified when its linked address is destroyed' do + affiliation = create(:affiliation, organization: organization, organization_address: address) + address.destroy + expect(affiliation.reload.organization_address_id).to be_nil + end + end + + describe '#active?' do + it 'is true when not inactive and has no end date' do + expect(build(:affiliation, inactive: false, end_date: nil).active?).to be true + end + + it 'is true when not inactive and the end date is in the future' do + expect(build(:affiliation, inactive: false, end_date: 1.month.from_now).active?).to be true + end + + it 'is false when flagged inactive' do + expect(build(:affiliation, inactive: true, end_date: nil).active?).to be false + end + + it 'is false when the end date has passed' do + expect(build(:affiliation, inactive: false, end_date: 1.day.ago).active?).to be false + end + end + + describe '.active' do + let!(:active_op) { create(:affiliation, inactive: false, end_date: nil) } + let!(:active_with_future_end) { create(:affiliation, inactive: false, end_date: 1.month.from_now) } + let!(:inactive_by_flag) { create(:affiliation, inactive: true, end_date: nil) } + let!(:inactive_by_end_date) { create(:affiliation, inactive: false, end_date: 1.day.ago) } + + it 'includes records with inactive: false and no end date' do + expect(described_class.active).to include(active_op) + end + + it 'includes records with inactive: false and future end date' do + expect(described_class.active).to include(active_with_future_end) + end + + it 'excludes records with inactive: true' do + expect(described_class.active).not_to include(inactive_by_flag) + end + + it 'excludes records with past end date' do + expect(described_class.active).not_to include(inactive_by_end_date) + end + + it 'qualifies end_date when joined with organizations (which also has end_date)' do + expect { + described_class.active.joins(:organization).to_a + }.not_to raise_error + end + end + + describe '#facilitator?' do + it 'is true for the exact title "Facilitator"' do + expect(build(:affiliation, title: "Facilitator").facilitator?).to be true + end + + it 'ignores surrounding whitespace' do + expect(build(:affiliation, title: " Facilitator ").facilitator?).to be true + end + + it 'is false for title variants like "Lead Facilitator"' do + expect(build(:affiliation, title: "Lead Facilitator").facilitator?).to be false + end + + it 'is case-sensitive' do + expect(build(:affiliation, title: "facilitator").facilitator?).to be false + expect(build(:affiliation, title: "FACILITATOR").facilitator?).to be false + end + + it 'is false when the title is blank' do + expect(build(:affiliation, title: nil).facilitator?).to be false + end + end + + describe '.facilitators' do + let!(:exact) { create(:affiliation, title: "Facilitator") } + let!(:whitespace) { create(:affiliation, title: " Facilitator ") } + let!(:variant) { create(:affiliation, title: "Lead Facilitator") } + let!(:lowercase) { create(:affiliation, title: "facilitator") } + + it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do + expect(described_class.facilitators).to contain_exactly(exact, whitespace) + end + end + + describe '#sync_organization_status_with_affiliations' do + let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") } + let!(:inactive_status) { OrganizationStatus.find_or_create_by!(name: "Inactive") } + + it 'sets the organization to Inactive when its last active affiliation goes inactive' do + org = create(:organization, organization_status: active_status) + affiliation = create(:affiliation, organization: org, inactive: false, end_date: nil) + + affiliation.update!(inactive: true) + + expect(org.reload.organization_status).to eq(inactive_status) + end + + it 'sets an Inactive organization back to Active when it regains an active affiliation' do + org = create(:organization, organization_status: inactive_status) + + create(:affiliation, organization: org, inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(active_status) + end + + it 'ignores non-facilitator affiliations when deciding status' do + org = create(:organization, organization_status: active_status) + create(:affiliation, organization: org, title: "Volunteer", inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(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) + org = create(:organization, organization_status: status) + + create(:affiliation, organization: org, inactive: false, end_date: nil) + + expect(org.reload.organization_status).to eq(status) + end + end + end + + describe '.active_on' do + let(:date) { Date.new(2024, 6, 1) } + let!(:spanning) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: Date.new(2025, 1, 1)) } + let!(:open_ended) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil) } + let!(:ended_before) { create(:affiliation, start_date: Date.new(2020, 1, 1), end_date: Date.new(2021, 1, 1)) } + let!(:starts_after) { create(:affiliation, start_date: Date.new(2025, 1, 1), end_date: nil) } + let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + + it 'includes affiliations whose span covers the date' do + expect(described_class.active_on(date)).to include(spanning, open_ended) + end + + it 'excludes affiliations that ended before the date' do + expect(described_class.active_on(date)).not_to include(ended_before) + end + + it 'excludes affiliations that start after the date' do + expect(described_class.active_on(date)).not_to include(starts_after) + end + + it 'includes affiliations with no dates on record' do + expect(described_class.active_on(date)).to include(no_dates) + end + + it 'ignores the cached inactive flag, judging purely by dates' do + flagged = create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil, inactive: true) + expect(described_class.active_on(date)).to include(flagged) + end + end + + describe 'status (#status_on and .with_status)' do + let!(:active_open) { create(:affiliation, start_date: Date.current.prev_year, end_date: nil) } + let!(:active_span) { create(:affiliation, start_date: Date.current.prev_year, end_date: Date.current.next_year) } + let!(:upcoming) { create(:affiliation, start_date: Date.current.next_year, end_date: nil) } + let!(:ended) { create(:affiliation, start_date: Date.current.prev_year(2), end_date: Date.current.prev_year) } + let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) } + + it 'exposes the taxonomy in display order' do + expect(Affiliation::STATUSES).to eq(%w[ Active Upcoming Inactive ]) + end + + it '#status_on classifies by flag and dates' do + expect(active_open.reload.status_on).to eq("Active") + expect(active_span.reload.status_on).to eq("Active") + expect(upcoming.reload.status_on).to eq("Upcoming") + expect(ended.reload.status_on).to eq("Inactive") + expect(no_dates.reload.status_on).to eq("Active") + end + + it '.with_status returns exactly the rows whose #status_on matches (SQL ↔ Ruby agree)' do + Affiliation::STATUSES.each do |status| + expected = Affiliation.all.select { |a| a.status_on == status }.map(&:id).sort + expect(Affiliation.with_status(status).ids.sort).to eq(expected), "mismatch for #{status}" + end + end + + it 'offers the combined filter option alongside the chip taxonomy' do + expect(Affiliation::FILTER_STATUSES) + .to eq([ "Active", "Upcoming", "Active & Upcoming", "Inactive" ]) + end + + it '.with_status("Active & Upcoming") returns exactly the Active and Upcoming rows' do + expected = Affiliation.all.select { |a| a.status_on.in?(%w[ Active Upcoming ]) }.map(&:id).sort + + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING).ids.sort).to eq(expected) + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).to include(active_open, active_span, upcoming, no_dates) + expect(Affiliation.with_status(Affiliation::ACTIVE_OR_UPCOMING)).not_to include(ended) + end + + it '.with_status is empty for an unknown status' do + expect(Affiliation.with_status("bogus")).to be_empty + end + end + + describe '#set_inactive_from_dates' do + let(:op) { create(:affiliation, inactive: false, end_date: nil) } + + it 'sets inactive to true when end_date is set to a past date' do + op.update!(end_date: 1.day.ago) + expect(op.reload.inactive).to be true + end + + it 'sets inactive to false when end_date is set to a future date' do + op.update!(inactive: true, end_date: 1.day.ago) + op.update!(end_date: 1.month.from_now) + expect(op.reload.inactive).to be false + end + + it 'sets inactive to false when end_date is cleared' do + op.update!(end_date: 1.day.ago) + op.update!(end_date: nil) + expect(op.reload.inactive).to be false + end + + it 'does not change inactive when unrelated fields change' do + op.update!(inactive: true) + op.update!(title: "New Title") + expect(op.reload.inactive).to be true + end + end + describe "reassigning the organization" do let(:old_org) { create(:organization) } let(:new_org) { create(:organization) } @@ -46,14 +316,26 @@ expect(affiliation.reload.organization_address_id).to eq(new_address.id) end + end - it "clears the event registration link" do + describe "the registration that created the affiliation" do + it "drops the link when the affiliation is moved to a different organization" do registration = create(:event_registration) - affiliation = create(:affiliation, organization: old_org, event_registration: registration) + affiliation = create(:affiliation, event_registration: registration) + other_org = create(:organization) - affiliation.update!(organization: new_org) + affiliation.update!(organization: other_org) expect(affiliation.reload.event_registration_id).to be_nil end + + it "keeps the link when other attributes change" do + registration = create(:event_registration) + affiliation = create(:affiliation, event_registration: registration) + + affiliation.update!(title: "Lead Facilitator") + + expect(affiliation.reload.event_registration).to eq(registration) + end end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 4afa565438..da529dc155 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -952,8 +952,8 @@ def registration_with_scholarship create(:event_registration_organization, event_registration: linked) unlinked = create(:event_registration, event: event) - expect(EventRegistration.organization_status("linked", event)).to contain_exactly(linked) - expect(EventRegistration.organization_status("unlinked", event)).to contain_exactly(unlinked) + expect(EventRegistration.organization_linking_status("linked", event)).to contain_exactly(linked) + expect(EventRegistration.organization_linking_status("unlinked", event)).to contain_exactly(unlinked) end end @@ -1244,6 +1244,7 @@ def registration_for(person) describe "#program_statuses" do let(:registration) { create(:event_registration) } + let(:training_date) { registration.event.start_date.to_date } let(:linked_org) { create(:organization, name: "Registration Org") } let(:other_org) { create(:organization, name: "Other Org") } @@ -1251,19 +1252,29 @@ def registration_for(person) create(:event_registration_organization, event_registration: registration, organization: linked_org) # An unrelated facilitator affiliation to a different org must be ignored. create(:affiliation, organization: other_org, person: registration.registrant, - title: "Facilitator", start_date: Date.current) + title: "Facilitator", start_date: training_date) - expect(registration.reload.program_statuses).to eq([ :new ]) + expect(registration.reload.program_statuses.map(&:status)).to eq([ :new ]) end - it "is ongoing when the linked org already had an active facilitator, excluding the registrant's own" do + # The registrant's own affiliation is dated to the training, so it isn't prior + # history — no exclusion needed to read New (ADR-0001 D5). + it "is new when the registrant's own affiliation is the org's first, dated to the training" do + create(:event_registration_organization, event_registration: registration, organization: linked_org) + create(:affiliation, organization: linked_org, person: registration.registrant, + title: "Facilitator", start_date: training_date) + + expect(registration.reload.program_statuses.map(&:status)).to eq([ :new ]) + end + + it "is ongoing when the linked org already had an active facilitator" do create(:event_registration_organization, event_registration: registration, organization: linked_org) create(:affiliation, organization: linked_org, title: "Facilitator", start_date: 2.years.ago, end_date: nil) create(:affiliation, organization: linked_org, person: registration.registrant, - title: "Facilitator", start_date: Date.current) + title: "Facilitator", start_date: training_date) - expect(registration.reload.program_statuses).to eq([ :ongoing ]) + expect(registration.reload.program_statuses.map(&:status)).to eq([ :ongoing ]) end it "counts a facilitator affiliation started earlier the same month as the training" do @@ -1276,7 +1287,17 @@ def registration_for(person) create(:affiliation, organization: linked_org, person: reg.registrant, title: "Facilitator", start_date: Date.new(2026, 6, 20)) - expect(reg.reload.program_statuses).to eq([ :ongoing ]) + expect(reg.reload.program_statuses.map(&:status)).to eq([ :ongoing ]) + end + + it "anchors on the training date and explains itself" do + create(:event_registration_organization, event_registration: registration, organization: linked_org) + + status = registration.reload.program_statuses.first + + expect(status.as_of).to eq(training_date) + expect(status.label).to eq("New") + expect(status.explanation).to include("No facilitator affiliation started before this date") end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index ea6a79d0d7..ffaa6b3216 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -79,40 +79,6 @@ end end - describe '#facilitator_status' do - let(:organization) { create(:organization) } - let(:current) do - create(:affiliation, organization: organization, title: "Facilitator", start_date: Date.new(2026, 1, 1)) - end - - it 'is :new when it is the only facilitator affiliation' do - expect(organization.facilitator_status(current)).to eq(:new) - end - - it 'is :new when every other facilitator affiliation started on or after it' do - create(:affiliation, organization: organization, title: "Facilitator", start_date: Date.new(2026, 6, 1)) - expect(organization.facilitator_status(current)).to eq(:new) - end - - it 'is :ongoing when an earlier facilitator affiliation was still active when it started' do - create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2024, 1, 1), end_date: nil) - expect(organization.facilitator_status(current)).to eq(:ongoing) - end - - it 'is :reinstated when all earlier facilitator affiliations ended before it started' do - create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2022, 1, 1), end_date: Date.new(2023, 1, 1)) - expect(organization.facilitator_status(current)).to eq(:reinstated) - end - - it 'ignores non-facilitator affiliations when classifying' do - create(:affiliation, organization: organization, title: "Volunteer", - start_date: Date.new(2020, 1, 1), end_date: nil) - expect(organization.facilitator_status(current)).to eq(:new) - end - end - describe '#facilitator_status_on' do let(:organization) { create(:organization) } let(:reference_date) { Date.new(2026, 1, 1) } @@ -133,10 +99,16 @@ expect(organization.facilitator_status_on(reference_date)).to eq(:reinstated) end - it 'can exclude a specific affiliation from the classification' do - own = create(:affiliation, organization: organization, title: "Facilitator", - start_date: Date.new(2020, 1, 1), end_date: nil) - expect(organization.facilitator_status_on(reference_date, excluding_affiliation_id: own.id)).to eq(:new) + it 'ignores an affiliation starting ON the date — the one that event mints' do + create(:affiliation, organization: organization, title: "Facilitator", + start_date: reference_date, end_date: nil) + expect(organization.facilitator_status_on(reference_date)).to eq(:new) + end + + it 'falls back to the start of the current year when given no date' do + create(:affiliation, organization: organization, title: "Facilitator", + start_date: Date.current.beginning_of_year - 1.day, end_date: nil) + expect(organization.facilitator_status_on).to eq(:ongoing) end end @@ -254,15 +226,139 @@ end end - context 'with status dropdown' do - it 'filters by organization_status_id' do - results = Organization.search_by_params(organization_status_id: active_status.id.to_s) + context 'with program status filter' do + it 'filters to orgs with an active facilitator affiliation' do + create(:affiliation, organization: active_org, person: create(:person), title: "Facilitator", end_date: nil) + results = Organization.search_by_params(program_status: "active") expect(results).to include(active_org) expect(results).not_to include(inactive_org) end end end + # Buckets come from facilitator affiliations alone — the stored organization_status + # is deliberately ignored, so each org here carries a status that contradicts its + # affiliations (see ADR-0001 D3). + describe ".program_status scope" do + def org_with(status_name, **affiliation_attrs) + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: status_name)).tap do |org| + next if affiliation_attrs.empty? + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", **affiliation_attrs) + end + end + + let!(:active_fac) { org_with("Suspended", start_date: 1.year.ago, end_date: nil) } + let!(:lapsed_fac) { org_with("Active", start_date: 3.years.ago, end_date: 1.year.ago) } + let!(:no_fac) { org_with("Active") } + let!(:non_fac_only) do + create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Reinstate")).tap do |org| + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", start_date: 1.year.ago, end_date: nil) + end + end + + it "buckets an active facilitator affiliation as active" do + expect(Organization.program_status("active")).to contain_exactly(active_fac) + end + + it "buckets only-lapsed facilitator affiliations as formerly_active" do + expect(Organization.program_status("formerly_active")).to contain_exactly(lapsed_fac) + end + + it "buckets orgs with no facilitator affiliation as never_active, whatever the stored status" do + expect(Organization.program_status("never_active")).to contain_exactly(no_fac, non_fac_only) + end + + it "combines formerly + never" do + expect(Organization.program_status("formerly_or_never")).to contain_exactly(lapsed_fac, no_fac, non_fac_only) + end + end + + describe "search_by_params sector and age-group filters" do + let!(:age_type) { create(:category_type, name: "AgeRange", published: true) } + let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } + let!(:sector) { create(:sector, :published, name: "Housing") } + let!(:direct_org) { create(:organization, name: "Direct Org") } + let!(:primary_person_org) { create(:organization, name: "Primary Person Org") } + let!(:additional_person_org) { create(:organization, name: "Additional Person Org") } + let!(:untagged) { create(:organization, name: "Untagged Org") } + + before do + # Tagged directly on the org (org's own tags count regardless of primary). + direct_org.tag_age_groups(primary_ids: [], additional_ids: [ teen.id ]) + direct_org.sectorable_items.create!(sector: sector, is_primary: false) + + # An affiliated person's PRIMARY tags — should match. + primary_person = create(:person) + create(:affiliation, organization: primary_person_org, person: primary_person) + primary_person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + primary_person.sectorable_items.create!(sector: sector, is_primary: true) + + # An affiliated person's ADDITIONAL / non-primary tags — should NOT match. + additional_person = create(:person) + create(:affiliation, organization: additional_person_org, person: additional_person) + additional_person.tag_age_groups(primary_ids: [], additional_ids: [ teen.id ]) + additional_person.sectorable_items.create!(sector: sector, is_primary: false) + end + + it "matches a sector on the org or an affiliated person's primary, not their non-primary" do + results = Organization.search_by_params(sector_name: "Housing") + expect(results).to include(direct_org, primary_person_org) + expect(results).not_to include(additional_person_org, untagged) + end + + it "matches an age group on the org or an affiliated person's primary, not their additional" do + results = Organization.search_by_params(age_group_name: "13-17") + expect(results).to include(direct_org, primary_person_org) + expect(results).not_to include(additional_person_org, untagged) + end + end + + describe "#all_sectors" do + let!(:housing) { create(:sector, :published, name: "Housing") } + let!(:legal) { create(:sector, :published, name: "Legal") } + let!(:other) { create(:sector, :published, name: "Other Services") } + let(:organization) { create(:organization) } + + it "includes the org's own sectors and affiliated people's primary sector only" do + organization.sectorable_items.create!(sector: housing, is_primary: false) + person = create(:person) + create(:affiliation, organization: organization, person: person) + person.sectorable_items.create!(sector: legal, is_primary: true) + person.sectorable_items.create!(sector: other, is_primary: false) + + expect(organization.all_sectors).to contain_exactly(housing, legal) + end + end + + # The index caches each row, and the roll-up cells aggregate across affiliated + # people — none of which touches the organizations row itself. + describe "#rollup_cache_version" do + let!(:sector) { create(:sector, :published, name: "Housing") } + let(:organization) { create(:organization) } + let(:person) { create(:person) } + + # A fresh instance each time, the way a page render loads it — the roll-ups + # memoize their affiliated people, so #reload wouldn't re-read them. + def version = Organization.find(organization.id).rollup_cache_version + + it "changes when an affiliated person is retagged" do + create(:affiliation, organization: organization, person: person) + before_version = version + + person.sectorable_items.create!(sector: sector, is_primary: true) + + expect(version).not_to eq(before_version) + end + + it "changes when an affiliation is added" do + before_version = version + + create(:affiliation, organization: organization, person: person) + + expect(version).not_to eq(before_version) + end + end + describe "age groups served" do let(:age_type) { create(:category_type, name: "AgeRange", published: true) } let!(:young) { create(:category, :published, category_type: age_type, name: "3-5") } @@ -277,11 +373,13 @@ create(:affiliation, organization: organization, person: person_b) end - it "aggregates and dedupes age groups across affiliated people and the org itself" do - organization.tag_age_groups(primary_ids: [ adult.id ], additional_ids: []) - person_a.tag_age_groups(primary_ids: [ young.id ], additional_ids: [ teen.id ]) - person_b.tag_age_groups(primary_ids: [ young.id ], additional_ids: []) + it "includes org-direct age groups and affiliated people's primary (not their additional)" do + organization.tag_age_groups(primary_ids: [ adult.id ], additional_ids: [ teen.id ]) + person_a.tag_age_groups(primary_ids: [ young.id ], additional_ids: []) + person_b.tag_age_groups(primary_ids: [], additional_ids: [ young.id ]) + # org primary (adult) + people's primary (young); org's own additional (teen) + # stays; person_b's additional (young) is ignored. expect(organization.all_primary_age_groups).to contain_exactly(young, adult) expect(organization.all_additional_age_groups).to contain_exactly(teen) end @@ -338,33 +436,6 @@ end end - describe ".program_statuses_by_id" do - it "buckets each org by facilitator history, keyed by id" do - new_org = create(:organization) - - ongoing_org = create(:organization) - create(:affiliation, organization: ongoing_org, person: create(:person), title: "Facilitator") - - reinstate_org = create(:organization) - create(:affiliation, organization: reinstate_org, person: create(:person), title: "Facilitator", end_date: 1.year.ago.to_date) - - ids = [ new_org.id, ongoing_org.id, reinstate_org.id ] - - expect(Organization.program_statuses_by_id(ids)).to eq( - new_org.id => :new, - ongoing_org.id => :ongoing, - reinstate_org.id => :reinstated - ) - end - - it "treats a non-facilitator affiliation as New" do - org = create(:organization) - create(:affiliation, organization: org, person: create(:person), title: "Member") - - expect(Organization.program_statuses_by_id([ org.id ])).to eq(org.id => :new) - end - end - describe ".awbw" do it "finds the org named by ORGANIZATION_NAME" do awbw = create(:organization, name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index de94e15280..e3ff272eaf 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -431,6 +431,16 @@ def toggle_certificate(value) expect(response.body).to include("") end + it "renders each linked-organization chip as a new-tab link to the org profile" do + organization = create(:organization, name: "A Window Between Worlds") + existing_registration.organizations << organization + + get edit_event_registration_path(existing_registration) + + expect(response.body).to include("href=\"#{organization_path(organization)}\"") + expect(response.body).to include("A Window Between Worlds") + end + it "shows a Delete button for a deletable registration" do get edit_event_registration_path(existing_registration) diff --git a/spec/requests/events_program_statuses_spec.rb b/spec/requests/events_program_statuses_spec.rb new file mode 100644 index 0000000000..d05332c439 --- /dev/null +++ b/spec/requests/events_program_statuses_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +# The annual-reporting page: organizations by program status at each facilitator +# training, with year totals. See ADR-0001 D4/D8. +RSpec.describe "Events program-status report", type: :request do + let(:admin) { create(:user, :admin) } + let!(:training) do + create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", + facilitator_training: true, start_date: Date.new(2026, 3, 1), end_date: Date.new(2026, 3, 2)) + end + let!(:established) { create(:organization, name: "Established Program") } + + def represent(organization, event) + registration = create(:event_registration, event: event, registrant: create(:person), status: "registered") + registration.event_registration_organizations.create!(organization: organization) + end + + before do + create(:affiliation, organization: established, person: create(:person), + title: "Facilitator", start_date: Date.new(2019, 5, 1)) + represent(established, training) + sign_in admin + end + + it "lists each training with its organizations split by status on the training's date" do + get program_statuses_events_path + + expect(response).to be_successful + expect(response.body).to include("TOS205") + # The date every verdict in the row was judged on. + expect(response.body).to include("Mar 1, 2026") + expect(response.body).to include("Distinct organizations") + end + + it "excludes events that aren't facilitator trainings" do + social = create(:event, title: "Zibberpicnic Social", facilitator_training: false, start_date: Date.new(2026, 4, 1)) + represent(established, social) + + get program_statuses_events_path + + # Only the report card — the event filter's select lists every event by name. + report = Capybara.string(response.body).find("#program-status-report") + expect(report).to have_text("TOS205") + expect(report).to have_no_text("Zibberpicnic") + end + + it "is reachable from the reports hub" do + get reports_events_path + + expect(response.body).to include("Program status") + expect(response.body).to include(program_statuses_events_path) + end + + it "is refused to a signed-out visitor" do + sign_out admin + + get program_statuses_events_path + + expect(response).to redirect_to(new_user_session_path) + end +end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 1cbad10b90..51286deaed 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -405,6 +405,16 @@ def add_ce_registrant(target_event) expect(nav).to have_no_link("Scholarships") end + it "renders on the program-status report with Program status current" do + sign_in admin + get program_statuses_events_path + + nav = Capybara.string(response.body).find("nav[aria-label='Report views']") + expect(nav).to have_link("Details") + expect(nav).to have_link("Scholarships") + expect(nav).to have_no_link("Program status") + end + # Breakdowns is a panel on the attendees index, not a page, so it has no tab. it "has no Breakdowns tab" do sign_in admin @@ -509,6 +519,34 @@ def add_ce_registrant(target_event) ) expect(response.body).to match(/]*name="return_to"[^>]*value="events"/) end + + it "returns to the organization profile when arriving from its program-status chips" do + organization = create(:organization) + sign_in admin + get participation_events_path(event_id: training_2026.id, return_organization_id: organization.id, return_to: "organization") + + expect(response.body).to include("← Organization") + expect(response.body).to include(CGI.escapeHTML(organization_path(organization, anchor: "program-status"))) + # The origin has to survive a filter change, or the eyebrow reverts. + expect(response.body).to match(/]*name="return_organization_id"[^>]*value="#{organization.id}"/) + end + + it "returns to the organization edit form when arriving from its program-status chips" do + organization = create(:organization) + sign_in admin + get participation_events_path(event_id: training_2026.id, return_organization_id: organization.id, return_to: "organization_edit") + + expect(response.body).to include("← Organization") + expect(response.body).to include(CGI.escapeHTML(edit_organization_path(organization, anchor: "program-status"))) + end + + it "falls back to the reports eyebrow when the organization origin has no id" do + sign_in admin + get participation_events_path(return_to: "organization") + + expect(response.body).to include("← Reports") + expect(response.body).not_to include("← Organization") + end end context "as non-admin" do @@ -2516,12 +2554,14 @@ def ce_chip_text expect(page).to have_link(href: registrants_event_path(event, ce_status: "registered"), visible: :all) end - it "shows a program status badge next to each organization" do + it "shows a program status badge next to each organization, hovering to explain it" do get dashboard_event_path(event) # The org list lives inside a collapsed
, so match hidden nodes too. page = Capybara.string(response.body) - expect(page).to have_css("span[title='New']", text: "N", visible: :all) + badge = page.all("span", text: "N", visible: :all).find { |node| node[:title]&.start_with?("New as of") } + expect(badge).to be_present + expect(badge[:title]).to include("event start date") end it "renders the payments section with totals for a paid event" do @@ -3185,6 +3225,25 @@ def ce_chip_text expect(response.body).to include("Richmond, CA") end + # These charts cover one event, so program status has to be judged on that + # event's start date — the same verdict its dashboard shows, and what the + # card's note claims. The affiliation here starts after Jan 1 but before the + # training, so the cross-event start-of-year fallback would read it as New. + it "anchors the program-status breakdown on the event's start date" do + org = create(:organization, name: "Anchored Org") + create(:affiliation, organization: org, person: create(:person), + title: "Facilitator", start_date: 2.weeks.from_now.to_date) + registration = EventRegistration.find_by!(registrant: applicant, event: event) + create(:event_registration_organization, event_registration: registration, organization: org) + + get recipients_event_path(event), headers: { "Turbo-Frame" => "recipients_charts" } + + card = Capybara.string(response.body).find("#organization-program-status", visible: :all) + expect(card).to have_text(:all, "Ongoing") + expect(card).to have_no_text(:all, "New") + expect(card).to have_css("i[title*='#{event.start_date.strftime('%b %-d, %Y')}']", visible: :all) + end + it "renders the collapsible card controls and an expand/collapse-all button" do get recipients_event_path(event) diff --git a/spec/requests/organizations_events_section_spec.rb b/spec/requests/organizations_events_section_spec.rb index 35e5af8a63..abb6d18496 100644 --- a/spec/requests/organizations_events_section_spec.rb +++ b/spec/requests/organizations_events_section_spec.rb @@ -47,31 +47,34 @@ def register(event:, status: "registered") end it "lists each event once even when several members attended it" do - event = create(:event, title: "Shared Event", abbreviation: "SE1") + event = create(:event, title: "Shared Event") register(event: event) register(event: event) get_events_section - # One event card, and one deduped program-status chip (keyed by its - # abbreviation) — not one per registration. The card renders the title as a - # text node (">Shared Event"); the chip references it only in a title="…" - # tooltip, so the card count keys off the leading ">". + # One card, not one per registration (the title renders as a ">Shared Event" node). expect(response.body.scan(/>\s*Shared Event/).size).to eq(1) - expect(response.body.scan("SE1").size).to eq(1) end - it "shows an admin program-status chip labeled with the event abbreviation" do - event = create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", start_date: 2.days.from_now) + it "shows an admin program-status chip per event in the profile's Program status block" do + event = create(:event, title: "Trauma-Informed Onsite", abbreviation: "TOS205", start_date: 2.days.from_now, facilitator_training: true) person = create(:person) create(:affiliation, organization: organization, person: person, title: "Facilitator", start_date: 1.year.ago, end_date: nil) registration = create(:event_registration, registrant: person, event: event, status: "registered") registration.event_registration_organizations.create!(organization: organization) - get_events_section + get organization_path(organization) + expect(response.body).to include("Program status") expect(response.body).to include("TOS205") expect(response.body).to include("Ongoing") + # The chip opens in a new tab, so it must tell the report how to get back here — + # and the block needs the id the report's eyebrow anchors to. + expect(response.body).to include( + CGI.escapeHTML(participation_events_path(event_id: event.id, return_organization_id: organization.id, return_to: "organization")) + ) + expect(response.body).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") end it "renders the section heading and lazy frame on the profile page" do diff --git a/spec/requests/organizations_index_preloading_spec.rb b/spec/requests/organizations_index_preloading_spec.rb new file mode 100644 index 0000000000..01beb5cbc4 --- /dev/null +++ b/spec/requests/organizations_index_preloading_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +# The results frame rolls up each org's sectors and age groups across its +# affiliated people (Organization#affiliated_people). Those roll-ups re-query per +# row unless the controller preloads people with the PEOPLE_TAGGINGS nest, so this +# guards the preload rather than a specific query count: adding orgs must not add +# queries. +RSpec.describe "Organizations index preloading", type: :request do + let(:admin) { create(:user, :admin) } + let!(:sector) { create(:sector, :published, name: "Housing") } + let!(:age_type) { create(:category_type, name: "AgeRange", published: true) } + let!(:teen) { create(:category, :published, category_type: age_type, name: "13-17") } + + before { sign_in admin } + + def create_orgs(count) + count.times do |i| + org = create(:organization, name: "Preload Org #{Organization.count}#{i}") + person = create(:person) + create(:affiliation, organization: org, person: person, title: "Facilitator") + person.sectorable_items.create!(sector: sector, is_primary: true) + person.tag_age_groups(primary_ids: [ teen.id ], additional_ids: []) + end + end + + def queries_for_results_frame + Rails.cache.clear + count = 0 + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + count += 1 unless payload[:name].to_s.match?(/SCHEMA|TRANSACTION/) + end + get organizations_url, headers: { "Turbo-Frame" => "organizations_results" } + ActiveSupport::Notifications.unsubscribe(subscriber) + expect(response).to be_successful + count + end + + it "does not issue more queries as more organizations are listed" do + create_orgs(2) + baseline = queries_for_results_frame + + create_orgs(6) + expect(queries_for_results_frame).to eq(baseline) + end +end diff --git a/spec/requests/organizations_spec.rb b/spec/requests/organizations_spec.rb index b7177699c2..9cbdee18a6 100644 --- a/spec/requests/organizations_spec.rb +++ b/spec/requests/organizations_spec.rb @@ -38,24 +38,6 @@ expect(response).to be_successful end - it "shows single-letter program status badges per organization" do - create(:organization, name: "Brand New Org", organization_status: organization_status) - - ongoing_org = create(:organization, name: "Ongoing Org", organization_status: organization_status) - create(:affiliation, organization: ongoing_org, person: create(:person), title: "Facilitator") - - reinstate_org = create(:organization, name: "Reinstate Org", organization_status: organization_status) - create(:affiliation, organization: reinstate_org, person: create(:person), title: "Facilitator", end_date: 1.year.ago.to_date) - - get organizations_url, headers: { "Turbo-Frame" => "organizations_results" } - - expect(response).to be_successful - page = Capybara.string(response.body) - expect(page).to have_css("span[title='New']", text: "N") - expect(page).to have_css("span[title='Ongoing']", text: "O") - expect(page).to have_css("span[title='Reinstated']", text: "R") - end - it "renders the results frame with deduped age groups from affiliated people" do organization = Organization.create!(valid_attributes) age_type = create(:category_type, name: "AgeRange", published: true) @@ -121,8 +103,8 @@ affiliated_sector_2 = create(:sector, name: "Affiliated Sector 2") person_1 = create(:person) person_2 = create(:person) - create(:sectorable_item, sector: affiliated_sector_1, sectorable: person_1) - create(:sectorable_item, sector: affiliated_sector_2, sectorable: person_2) + create(:sectorable_item, sector: affiliated_sector_1, sectorable: person_1, is_primary: true) + create(:sectorable_item, sector: affiliated_sector_2, sectorable: person_2, is_primary: true) org = create(:organization, organization_status: organization_status) create(:sectorable_item, sector: create(:sector, name: "Direct Sector 1"), sectorable: org) @@ -193,15 +175,35 @@ expect(response.body).to include("Monthly reports") end - it "shows the program status in the affiliations section" do + it "renders affiliated-since as merged year-based periods (server-side)" do organization = Organization.create!(valid_attributes) - create(:affiliation, organization: organization, person: create(:person), title: "Facilitator") + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2010, 1, 1), end_date: Date.new(2012, 6, 1)) + create(:affiliation, organization: organization, person: create(:person), + start_date: Date.new(2013, 1, 1), end_date: Date.new(2015, 6, 1)) get edit_organization_url(organization) - page = Capybara.string(response.body) - expect(page).to have_content("Program status") - expect(page).to have_css("span[title='Ongoing']", text: "O") + expect(response.body).to include("2010-2012, 2013-2015") + end + + it "renders per-event program-status chips only for facilitator-training events" do + organization = Organization.create!(valid_attributes) + create(:affiliation, organization: organization, person: create(:person), + title: "Facilitator", start_date: Date.new(2020, 1, 1)) + training = create(:event, title: "Qwultz Training", facilitator_training: true, + start_date: Date.new(2026, 8, 1)) + non_training = create(:event, title: "Zibberpicnic Social", facilitator_training: false, + start_date: Date.new(2026, 8, 2)) + [ training, non_training ].each do |event| + registration = create(:event_registration, registrant: create(:person), event: event, status: "registered") + registration.event_registration_organizations.create!(organization: organization) + end + + get edit_organization_url(organization) + + expect(response.body).to include("Qwultz Training") + expect(response.body).not_to include("Zibberpicnic Social") end end diff --git a/spec/services/affiliation_periods_spec.rb b/spec/services/affiliation_periods_spec.rb new file mode 100644 index 0000000000..1d328d3964 --- /dev/null +++ b/spec/services/affiliation_periods_spec.rb @@ -0,0 +1,105 @@ +require "rails_helper" + +RSpec.describe AffiliationPeriods do + let(:today) { Date.new(2026, 7, 15) } + + # Lightweight stand-in for an affiliation — the service only reads dates. + Interval = Struct.new(:start_date, :end_date) + + def label(*intervals) + described_class.label(intervals, today: today) + end + + it "returns nil when there are no affiliations" do + expect(label).to be_nil + end + + it "returns nil when no affiliation carries a start date" do + expect(label(Interval.new(nil, nil))).to be_nil + end + + describe "an ongoing period" do + it "shows the month and year when it began this year" do + expect(label(Interval.new(Date.new(2026, 7, 1), nil))).to eq("Jul 2026") + end + + it "shows only the start year when it began in an earlier year" do + expect(label(Interval.new(Date.new(2024, 3, 1), nil))).to eq("2024") + end + + it "treats an end date today or later as ongoing" do + expect(label(Interval.new(Date.new(2024, 3, 1), today))).to eq("2024") + end + end + + describe "a closed past period" do + it "shows a single year when start and end fall in the same year" do + expect(label(Interval.new(Date.new(2010, 3, 1), Date.new(2010, 9, 1)))).to eq("2010") + end + + it "shows a year range across multiple years" do + expect(label(Interval.new(Date.new(2010, 3, 1), Date.new(2016, 9, 1)))).to eq("2010-2016") + end + end + + it "merges overlapping intervals into one period" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2011, 3, 1), Date.new(2012, 12, 1)) + )).to eq("2010-2012") + end + + it "keeps gapped intervals as separate periods" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2013, 1, 1), Date.new(2015, 6, 1)) + )).to eq("2010-2012, 2013-2015") + end + + it "shows a past period alongside a current ongoing one" do + expect(label( + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)), + Interval.new(Date.new(2026, 2, 1), nil) + )).to eq("2010-2012, 2026") + end + + it "orders periods chronologically regardless of input order" do + expect(label( + Interval.new(Date.new(2026, 2, 1), nil), + Interval.new(Date.new(2010, 1, 1), Date.new(2012, 6, 1)) + )).to eq("2010-2012, 2026") + end + + describe "month precision" do + def month_label(*intervals) + described_class.label(intervals, today: today, precision: :month) + end + + it "shows the month on both ends of a closed period" do + expect(month_label(Interval.new(Date.new(2015, 8, 1), Date.new(2018, 6, 1)))).to eq("Aug 2015 – Jun 2018") + end + + it "shows only the start month for an ongoing period" do + expect(month_label(Interval.new(Date.new(2015, 8, 1), nil))).to eq("Aug 2015") + end + + it "keeps a lapse and a return as separate periods" do + expect(month_label( + Interval.new(Date.new(2015, 8, 1), Date.new(2018, 6, 1)), + Interval.new(Date.new(2024, 2, 1), nil) + )).to eq("Aug 2015 – Jun 2018, Feb 2024") + end + + it "keeps the month even when a period starts and ends in the same year" do + expect(month_label(Interval.new(Date.new(2010, 3, 1), Date.new(2010, 9, 1)))).to eq("Mar 2010 – Sep 2010") + end + + it "returns nil when no affiliation carries a start date" do + expect(month_label(Interval.new(nil, nil))).to be_nil + end + end + + it "rejects an unknown precision rather than silently formatting as years" do + expect { described_class.label([], precision: :day) }.to raise_error(ArgumentError, /day/) + end +end diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 2b0644561b..2f51485ca1 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -847,25 +847,31 @@ def opt_in(person, text:) let(:cancelled_facilitator) { create(:person) } before do + # Each registrant's own facilitator affiliation is dated to the training + # itself, the way AffiliationServices::CreateFromRegistration mints it. It + # is NOT excluded from the classification (ADR-0001 D5) — it simply doesn't + # count as prior history, because "before" is strict. + training_date = event.start_date.to_date + # New program: the registrant's affiliation is the org's first facilitator. create(:affiliation, organization: new_org, person: new_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Ongoing program: a facilitator was already active before this registrant. create(:affiliation, organization: ongoing_org, title: "Facilitator", start_date: Date.new(2023, 1, 1), end_date: nil) create(:affiliation, organization: ongoing_org, person: ongoing_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Reinstated program: a prior facilitator ended before this registrant's. create(:affiliation, organization: reinstated_org, title: "Facilitator", start_date: Date.new(2020, 1, 1), end_date: Date.new(2021, 1, 1)) create(:affiliation, organization: reinstated_org, person: reinstated_facilitator, - title: "Facilitator", start_date: Date.new(2026, 1, 1)) + title: "Facilitator", start_date: training_date) # Cancelled registrant's program must be ignored. create(:affiliation, organization: new_org, person: cancelled_facilitator, - title: "Facilitator", start_date: Date.new(2026, 2, 1)) + title: "Facilitator", start_date: training_date + 1.day) create(:event_registration, event: event, registrant: new_facilitator, status: "registered") create(:event_registration, event: event, registrant: ongoing_facilitator, status: "registered") @@ -880,12 +886,20 @@ def opt_in(person, text:) it "maps each registrant to their organization's program status" do statuses = dashboard.program_statuses_by_registrant - expect(statuses[new_facilitator.id]).to eq([ :new ]) - expect(statuses[ongoing_facilitator.id]).to eq([ :ongoing ]) - expect(statuses[reinstated_facilitator.id]).to eq([ :reinstated ]) + expect(statuses[new_facilitator.id].map(&:status)).to eq([ :new ]) + expect(statuses[ongoing_facilitator.id].map(&:status)).to eq([ :ongoing ]) + expect(statuses[reinstated_facilitator.id].map(&:status)).to eq([ :reinstated ]) expect(statuses).not_to have_key(cancelled_facilitator.id) end + it "anchors every verdict on the event's start date, for the hover to explain" do + status = dashboard.program_statuses_by_registrant[ongoing_facilitator.id].first + + expect(status.as_of).to eq(event.start_date.to_date) + expect(status).not_to be_year_anchored + expect(status.explanation).to include("Ongoing as of", "event start date", "Jan 2023") + end + it "groups registrant ids by program status, for the breakdown drill-in" do ids = dashboard.program_status_registrant_ids @@ -938,7 +952,7 @@ def opt_in(person, text:) end it "classifies the org as ongoing, not new" do - expect(dashboard.program_statuses_by_registrant[person.id]).to eq([ :ongoing ]) + expect(dashboard.program_statuses_by_registrant[person.id].map(&:status)).to eq([ :ongoing ]) end end @@ -950,7 +964,7 @@ def opt_in(person, text:) end it "classifies the org as reinstated" do - expect(dashboard.program_statuses_by_registrant[person.id]).to eq([ :reinstated ]) + expect(dashboard.program_statuses_by_registrant[person.id].map(&:status)).to eq([ :reinstated ]) end end end @@ -973,7 +987,9 @@ def opt_in(person, text:) end it "still counts the program as it was at the time of the event" do - expect(dashboard.program_status_counts).to eq(new: 1, ongoing: 0, reinstated: 0) + # Ongoing, not reinstated: the affiliation began a year before the event and + # was still running on the event date, even though it has since ended. + expect(dashboard.program_status_counts).to eq(new: 0, ongoing: 1, reinstated: 0) end it "keeps the program in the organization count" do diff --git a/spec/services/event_program_status_report_spec.rb b/spec/services/event_program_status_report_spec.rb new file mode 100644 index 0000000000..4253131348 --- /dev/null +++ b/spec/services/event_program_status_report_spec.rb @@ -0,0 +1,141 @@ +require "rails_helper" + +RSpec.describe EventProgramStatusReport do + def training(title, start_date) + create(:event, title: title, abbreviation: title.parameterize.upcase.first(6), + facilitator_training: true, start_date: start_date, end_date: start_date) + end + + def represent(organization, event, status: "registered") + registration = create(:event_registration, event: event, registrant: create(:person), status: status) + registration.event_registration_organizations.create!(organization: organization) + registration + end + + def facilitator_since(organization, start_date, end_date = nil) + create(:affiliation, organization: organization, person: create(:person), + title: "Facilitator", start_date: start_date, end_date: end_date) + end + + let(:spring) { training("Spring Training", Date.new(2026, 3, 1)) } + let(:fall) { training("Fall Training", Date.new(2026, 9, 1)) } + let(:report) { described_class.new([ spring, fall ].map(&:decorate)) } + + describe "per training" do + let(:brand_new) { create(:organization, name: "Brand New") } + let(:established) { create(:organization, name: "Established") } + let(:lapsed) { create(:organization, name: "Lapsed") } + + before do + # Its first facilitator affiliation is minted by the training itself. + facilitator_since(brand_new, spring.start_date) + facilitator_since(established, Date.new(2019, 5, 1)) + facilitator_since(lapsed, Date.new(2015, 1, 1), Date.new(2017, 1, 1)) + + [ brand_new, established, lapsed ].each { |organization| represent(organization, spring) } + end + + it "splits the organizations represented at each training by status on its start date" do + column = report.years.first.columns.first + + expect(column.event).to eq(spring) + expect(column.new_count).to eq(1) + expect(column.ongoing_count).to eq(1) + expect(column.reinstated_count).to eq(1) + expect(column.organization_count).to eq(3) + end + + it "counts only organizations on active registrations" do + cancelled_org = create(:organization, name: "Cancelled") + represent(cancelled_org, spring, status: "cancelled") + + expect(report.years.first.columns.first.organization_count).to eq(3) + end + + it "reads each organization as of the training it attended, not as of today" do + # By the fall training the brand-new org has been facilitating since spring. + represent(brand_new, fall) + + fall_column = report.columns.find { |column| column.event == fall } + + expect(fall_column.ongoing_count).to eq(1) + expect(fall_column.new_count).to eq(0) + end + end + + describe "adding them up" do + let(:repeat_org) { create(:organization, name: "Repeat") } + let(:one_off) { create(:organization, name: "One Off") } + + before do + facilitator_since(repeat_org, spring.start_date) + facilitator_since(one_off, Date.new(2019, 5, 1)) + represent(repeat_org, spring) + represent(repeat_org, fall) + represent(one_off, fall) + end + + it "sums the rows as organization-trainings, counting a repeat attender twice" do + expect(report.organization_count).to eq(3) + expect(report.new_count).to eq(1) # Repeat, at spring + expect(report.ongoing_count).to eq(2) # Repeat at fall, One Off at fall + end + + it "counts each organization once for the period, at its earliest training" do + expect(report.distinct_organization_count).to eq(2) + expect(report.distinct_new_count).to eq(1) # Repeat, as it was in spring + expect(report.distinct_ongoing_count).to eq(1) # One Off + expect(report).to be_repeat_organizations + end + end + + describe "grouping" do + let(:last_year) { training("Prior Training", Date.new(2025, 6, 1)) } + let(:report) { described_class.new([ spring, fall, last_year ].map(&:decorate)) } + + before do + organization = create(:organization) + facilitator_since(organization, Date.new(2019, 5, 1)) + [ spring, fall, last_year ].each { |event| represent(organization, event) } + end + + it "groups columns by calendar year, newest first, chronological within a year" do + expect(report.years.map(&:year)).to eq([ 2026, 2025 ]) + expect(report.years.first.columns.map(&:event)).to eq([ spring, fall ]) + end + + it "totals each year separately" do + expect(report.years.first.organization_count).to eq(2) + expect(report.years.last.organization_count).to eq(1) + end + + it "counts the repeat organization once per year in the distinct view" do + expect(report.years.first.distinct_organization_count).to eq(1) + expect(report.distinct_organization_count).to eq(1) + end + end + + it "is empty without trainings" do + empty = described_class.new([]) + + expect(empty).not_to be_any + expect(empty.organization_count).to eq(0) + expect(empty.distinct_status_counts).to eq(new: 0, ongoing: 0, reinstated: 0) + end + + it "loads the organizations and their affiliations in a fixed number of queries" do + 6.times do |index| + organization = create(:organization, name: "Org #{index}") + facilitator_since(organization, Date.new(2019, 5, 1)) + represent(organization, spring) + end + + subject_report = report # build the events before measuring + + queries = 0 + counter = ->(*, payload) { queries += 1 unless payload[:name].to_s.match?(/SCHEMA|TRANSACTION/) } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") { subject_report.columns } + + expect(queries).to be <= 3 + end +end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 3eaae29353..49c66aa547 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -52,6 +52,17 @@ def register_with(position:) .to contain_exactly("Facilitator") end + # The facilitator affiliation is dated to the training, not to the day the form + # was submitted — the whole New/Ongoing/Reinstate rule leans on that, since an + # affiliation starting ON the training isn't prior history (ADR-0001 D8). + it "dates the facilitator affiliation to the event's start date" do + person = register_with(position: nil) + + facilitator = person.affiliations.find_by(organization: organization, title: "Facilitator") + expect(facilitator.start_date).to eq(event.start_date.to_date) + expect(organization.reload.facilitator_status_on(event.start_date.to_date)).to eq(:new) + end + it "links the created affiliations to the agency address built from the form" do params = base_form_params(first_name: "Sam", last_name: "Rowe", email: "sam@example.com").merge( field_id(described_class::ORGANIZATION_NAME_IDENTIFIER) => "Helping Hands", diff --git a/spec/services/facilitator_program_status_spec.rb b/spec/services/facilitator_program_status_spec.rb new file mode 100644 index 0000000000..2bc9df9855 --- /dev/null +++ b/spec/services/facilitator_program_status_spec.rb @@ -0,0 +1,114 @@ +require "rails_helper" + +RSpec.describe FacilitatorProgramStatus do + let(:organization) { create(:organization) } + let(:anchor) { Date.new(2026, 6, 15) } + + def facilitator(start_date:, end_date: nil, title: "Facilitator") + create(:affiliation, organization: organization, person: create(:person), + title: title, start_date: start_date, end_date: end_date) + end + + def status_on(date = anchor) + organization.reload.facilitator_program_status(as_of: date) + end + + describe "the verdict" do + it "is :new without any facilitator affiliation before the anchor" do + expect(status_on.status).to eq(:new) + end + + it "is :new when the only facilitator affiliation starts ON the anchor" do + # The affiliation the training itself mints — not prior history. + facilitator(start_date: anchor) + expect(status_on.status).to eq(:new) + end + + it "is :ongoing when an earlier facilitator affiliation is still active" do + facilitator(start_date: Date.new(2019, 3, 1)) + expect(status_on.status).to eq(:ongoing) + end + + it "is :ongoing when an earlier affiliation ends exactly on the anchor" do + facilitator(start_date: Date.new(2019, 3, 1), end_date: anchor) + expect(status_on.status).to eq(:ongoing) + end + + it "is :reinstated when every earlier facilitator affiliation has ended" do + facilitator(start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + expect(status_on.status).to eq(:reinstated) + end + + it "ignores non-facilitator affiliations" do + facilitator(start_date: Date.new(2010, 1, 1), title: "Volunteer") + expect(status_on.status).to eq(:new) + end + end + + describe "the anchor" do + it "reads as of the given date" do + expect(status_on.as_of).to eq(anchor) + expect(status_on).not_to be_year_anchored + end + + it "falls back to the start of the current year when no date is given" do + status = status_on(nil) + + expect(status.as_of).to eq(Date.current.beginning_of_year) + expect(status).to be_year_anchored + end + end + + describe "#explanation" do + it "names the anchor, the date the program went active, and the periods" do + facilitator(start_date: Date.new(2019, 3, 1)) + + explanation = status_on.explanation + + expect(explanation).to include("Ongoing as of Jun 15, 2026 (event start date).") + expect(explanation).to include("Active facilitator affiliation since Mar 2019.") + expect(explanation).to include("Facilitator periods: Mar 2019.") + end + + it "names when a reinstated program last ran" do + facilitator(start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + + explanation = status_on.explanation + + expect(explanation).to include("Reinstated as of Jun 15, 2026") + expect(explanation).to include("Previously active from Aug 2015 through Jun 2018") + expect(explanation).to include("Facilitator periods: Aug 2015 – Jun 2018.") + end + + it "says there is no prior history for a new program" do + expect(status_on.explanation).to include("New as of Jun 15, 2026", "No facilitator affiliation started before this date.") + end + + it "says the year is the anchor when there is no event in view" do + expect(status_on(nil).explanation).to include("no event in view") + end + + # The most recent activation is what the hover reports, not the earliest. + it "reports the latest of several overlapping active affiliations" do + facilitator(start_date: Date.new(2019, 3, 1)) + facilitator(start_date: Date.new(2024, 9, 1)) + + expect(status_on.active_since).to eq(Date.new(2024, 9, 1)) + end + end + + # List pages classify many organizations at once, so this has to ride on the + # preloaded association rather than querying per row. + it "reads preloaded affiliations without querying" do + facilitator(start_date: Date.new(2019, 3, 1)) + preloaded = Organization.where(id: organization.id).includes(:affiliations).first + + queries = 0 + counter = ->(*, payload) { queries += 1 if payload[:sql].to_s.include?("FROM `affiliations`") } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + expect(preloaded.facilitator_program_status(as_of: anchor).explanation).to be_present + end + + expect(queries).to eq(0) + end +end diff --git a/spec/system/affiliation_dates_spec.rb b/spec/system/affiliation_dates_spec.rb index b6d7feb5d5..d686f9a0de 100644 --- a/spec/system/affiliation_dates_spec.rb +++ b/spec/system/affiliation_dates_spec.rb @@ -120,4 +120,49 @@ def set_text_input(input, value) affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) expect(affiliated).to have_text("Jun 2022", wait: 5) end + + # The org form renders "Affiliated since" as merged, year-based periods + # (AffiliationPeriods) rather than a single range; it must live-update in that + # same format so the value doesn't jump on save. + context "on the organization form" do + let!(:org_person) { create(:person) } + let!(:merged_org) { create(:organization) } + + before do + create(:affiliation, person: org_person, organization: merged_org, + title: "Facilitator", start_date: "2018-01-01", end_date: "2019-12-31") + create(:affiliation, person: org_person, organization: merged_org, + title: "Facilitator", start_date: "2022-01-01", end_date: nil) + end + + it "live-updates the merged 'Affiliated since' periods" do + visit_and_wait edit_organization_path(merged_org) + + affiliated = find("[data-affiliation-dates-target='affiliatedSince']") + expect(affiliated).to have_text("2018-2019, 2022") + + ongoing_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| + f.find("input[name*='start_date']").value == "2022-01-01" + } + set_date_input(ongoing_row.find("input[name*='start_date']"), "2021-05-01") + + expect(affiliated).to have_text("2018-2019, 2021", wait: 5) + end + + # "Art program since" is the same merged-period value at month precision, so it + # has to live-update in that format too — not the old earliest→latest range. + it "live-updates 'Art program since' as month-precision periods" do + visit_and_wait edit_organization_path(merged_org) + + program = find("[data-affiliation-dates-target='facilitatorSince']") + expect(program).to have_text("Jan 2018 – Dec 2019, Jan 2022") + + ongoing_row = all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| + f.find("input[name*='start_date']").value == "2022-01-01" + } + set_date_input(ongoing_row.find("input[name*='start_date']"), "2021-05-01") + + expect(program).to have_text("Jan 2018 – Dec 2019, May 2021", wait: 5) + end + end end diff --git a/spec/system/organization_affiliation_dates_spec.rb b/spec/system/organization_affiliation_dates_spec.rb deleted file mode 100644 index 7077f7caa8..0000000000 --- a/spec/system/organization_affiliation_dates_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require "rails_helper" - -RSpec.describe "Organization affiliation dates auto-update", type: :system do - let(:admin) { create(:user, :admin) } - let!(:admin_person) { create(:person, user: admin) } - let!(:person1) { create(:person) } - let!(:person2) { create(:person) } - let!(:organization) { create(:organization) } - - before do - driven_by(:selenium_chrome_headless) - create(:affiliation, organization: organization, person: person1, title: "Facilitator", start_date: "2019-05-01", end_date: nil) - create(:affiliation, organization: organization, person: person2, title: "Volunteer", start_date: "2021-09-15", end_date: nil) - sign_in admin - end - - def visit_and_wait(path) - visit path - expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) - end - - def set_date_input(input, value) - page.execute_script( - "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))", - input, value - ) - end - - it "updates Affiliated since when a start date changes" do - visit_and_wait edit_organization_path(organization, admin: true) - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - expect(affiliated).to have_text("May 2019") - - start_inputs = all("input[name*='affiliations_attributes'][name*='start_date']") - set_date_input(start_inputs.first, "2017-02-01") - - expect(affiliated).to have_text("Feb 2017", wait: 5) - end - - it "shows end date and icon when all affiliations are inactive" do - visit_and_wait edit_organization_path(organization, admin: true) - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']") - - end_inputs = all("input[name*='affiliations_attributes'][name*='end_date']") - set_date_input(end_inputs[0], "2023-03-01") - set_date_input(end_inputs[1], "2024-08-01") - - expect(affiliated).to have_text("Aug 2024", wait: 5) - within(affiliated) do - expect(page).to have_css("i.fa-circle-xmark") - end - end - - it "removes an affiliation via the editor and recalculates" do - # Persisted affiliations are now deleted from the affiliation editor (reached - # via the row's gear); removing the Facilitator (May 2019) leaves Volunteer (Sep 2021). - facilitator = organization.affiliations.find_by!(title: "Facilitator") - visit edit_affiliation_path(facilitator, return_to: "organization", origin_id: organization.id) - - accept_confirm { click_button "Delete" } - - affiliated = find("[data-affiliation-dates-target='affiliatedSince']", wait: 10) - expect(affiliated).to have_text("Sep 2021", wait: 5) - end -end diff --git a/spec/system/organization_facilitator_warning_spec.rb b/spec/system/organization_facilitator_warning_spec.rb index 3461cbc61b..04193727c8 100644 --- a/spec/system/organization_facilitator_warning_spec.rb +++ b/spec/system/organization_facilitator_warning_spec.rb @@ -16,7 +16,7 @@ def visit_and_wait(path) visit path - expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) + expect(page).to have_css("[data-affiliation-facilitator-warning-ready]", wait: 10) end def set_date_input(input, value) @@ -27,7 +27,7 @@ def set_date_input(input, value) end def row_for(title) - all("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields").find { |f| + all("#affiliations .nested-fields").find { |f| f.find("input[name*='title']").value.include?(title) } end diff --git a/spec/system/organization_program_status_spec.rb b/spec/system/organization_program_status_spec.rb new file mode 100644 index 0000000000..3e72193ef5 --- /dev/null +++ b/spec/system/organization_program_status_spec.rb @@ -0,0 +1,55 @@ +require "rails_helper" + +RSpec.describe "Organization program status live update", type: :system do + let(:admin) { create(:user, :admin) } + let!(:person) { create(:person) } + # A stored status that contradicts the affiliations, to prove it never feeds the + # chip: the org reads Active purely because a facilitator affiliation is active. + let!(:stored_status) { create(:organization_status, name: "Pending") } + let!(:organization) { create(:organization, organization_status: stored_status) } + + before do + driven_by(:selenium_chrome_headless) + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: "2020-03-01", end_date: nil) + sign_in admin + end + + def visit_and_wait(path) + visit path + expect(page).to have_css("[data-affiliation-dates-ready]", wait: 10) + end + + def set_date_input(input, value) + page.execute_script( + "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))", + input, value + ) + end + + def status_chip + find("[data-affiliation-dates-target='programStatus']") + end + + it "flips Active to Formerly active when the facilitator's end date moves to the past" do + visit_and_wait edit_organization_path(organization) + expect(status_chip).to have_text("Active") + + end_input = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields input[name*='end_date']") + set_date_input(end_input, "2020-06-01") + + expect(status_chip).to have_text("Formerly active", wait: 5) + end + + it "drops to Never active when the only facilitator row is retitled to a non-facilitator" do + visit_and_wait edit_organization_path(organization) + expect(status_chip).to have_text("Active") + + # Persisted rows can't be removed inline (that's the gear's affiliation editor), + # so retitle the sole facilitator: the live chip counts zero facilitators. + title_input = find("[data-affiliation-dates-target='affiliationsContainer'] .nested-fields input[name*='title']") + title_input.set("Volunteer") + + expect(status_chip).to have_text("Never active", wait: 5) + end +end diff --git a/spec/views/organizations/edit.html.erb_spec.rb b/spec/views/organizations/edit.html.erb_spec.rb index 807628c2f7..958789bfdf 100644 --- a/spec/views/organizations/edit.html.erb_spec.rb +++ b/spec/views/organizations/edit.html.erb_spec.rb @@ -48,29 +48,140 @@ def org_with_status(name) ) end - it "shows the status select for an org with no affiliations" do + it "hides the status select without the admin param" do + assign(:organization, org_with_status("Active")) + render + assert_select "select[name=?]", "organization[organization_status_id]", false + assert_select "input[type=hidden][name=?]", "organization[organization_status_id]" + end + + it "shows the status select with the admin param" do + allow(view).to receive(:params).and_return(ActionController::Parameters.new(admin: "true")) assign(:organization, org_with_status("Active")) render assert_select "select[name=?]", "organization[organization_status_id]" end - it "hides the status select (and the mismatch hint) when the status matches the affiliation-calculated status" do - org = org_with_status("Active") + it "shows a warning icon (not the select) when the stored status does not match the affiliation status" do + org = org_with_status("Pending") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render assert_select "select[name=?]", "organization[organization_status_id]", false - assert_select "input[type=hidden][name=?]", "organization[organization_status_id]" - expect(rendered).not_to include("Does not match affiliations status") + assert_select "i.fa-triangle-exclamation" + expect(rendered).to include("Legacy organization status does not match affiliation status") end - it "shows the status select and the red mismatch hint when the status does not match the affiliation-calculated status" do - org = org_with_status("Pending") + it "shows no warning icon when the stored status matches the affiliation status" do + org = org_with_status("Active") create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) assign(:organization, org.reload) render - assert_select "select[name=?]", "organization[organization_status_id]" - assert_select "p.text-red-600", text: "Does not match affiliations status" + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + + # The mismatch is judged on program-status buckets, so a stored status that + # buckets the same way as the affiliations is not a mismatch, whatever its name. + it "warns when a stored 'Active' org has affiliations but none are facilitators" do + org = org_with_status("Active") + create(:affiliation, organization: org, person: create(:person), title: "Volunteer", inactive: false, end_date: nil) + assign(:organization, org.reload) + render + expect(rendered).to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for a stored 'Pending' org with no facilitator affiliations" do + assign(:organization, org_with_status("Pending")) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for stored 'Reinstate' with an active facilitator (both active)" do + org = org_with_status("Reinstate") + create(:affiliation, organization: org, person: create(:person), inactive: false, end_date: nil) + assign(:organization, org.reload) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + + it "shows no warning icon for stored 'Suspended' with only lapsed facilitators (both formerly active)" do + org = org_with_status("Suspended") + create(:affiliation, organization: org, person: create(:person), + start_date: 3.years.ago.to_date, end_date: 1.year.ago.to_date) + assign(:organization, org.reload) + render + expect(rendered).not_to include("Legacy organization status does not match affiliation status") + end + end + + describe "art program since" do + before(:each) { assign(:organization_statuses, OrganizationStatus.all) } + + around { |ex| travel_to(Date.new(2026, 8, 3)) { ex.run } } + + it "shows the earliest facilitator start (month precision), not the latest, wired for live updates" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2025, 8, 1), end_date: nil) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2026, 9, 1), end_date: nil) + assign(:organization, org.reload) + render + + assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Aug 2025/ + assert_select "[data-affiliation-dates-target='facilitatorSince']", text: /Sep 2026/, count: 0 + end + + # This form and the profile used to render "Art program since" two different + # ways — the profile as merged periods, this form as one earliest→latest span, + # which silently swallowed the gap. Both now render the one decorator value. + it "renders a lapse and a return as separate periods, matching the profile" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2015, 8, 1), end_date: Date.new(2018, 6, 1)) + create(:affiliation, organization: org, person: create(:person), title: "Facilitator", + start_date: Date.new(2024, 2, 1), end_date: nil) + assign(:organization, org.reload) + render + + expect(org.reload.decorate.program_since_display).to eq("Aug 2015 – Jun 2018, Feb 2024") + assert_select "[data-affiliation-dates-target='facilitatorSince']", + text: org.decorate.program_since_display + end + end + + describe "program status" do + it "renders a 'date · status · event' chip for each event the org is represented at" do + org = create(:organization, organization_status: OrganizationStatus.find_or_create_by!(name: "Active")) + person = create(:person) + create(:affiliation, organization: org, person: person, title: "Facilitator", + start_date: Date.new(2020, 1, 1), end_date: nil) + event = create(:event, title: "August Training", abbreviation: "PES205", start_date: Date.new(2026, 8, 1)) + + assign(:organization, org.reload) + assign(:organization_statuses, OrganizationStatus.all) + assign(:organization_events, Event.where(id: event.id)) + render + + expect(rendered).to include("Aug 2026 · Ongoing · PES205") + # Opens in a new tab, so the report needs the route back to this form — + # and the block needs the id that back-link anchors to. + expect(rendered).to include(CGI.escapeHTML( + participation_events_path(event_id: event.id, return_organization_id: org.id, return_to: "organization_edit") + )) + expect(rendered).to include("id=\"#{EventParticipationHelper::PROGRAM_STATUS_ANCHOR}\"") + end + + it "always shows the general status chip, even with no events" do + org = organization + org.update!(organization_status: OrganizationStatus.find_or_create_by!(name: "Unknown")) + assign(:organization, org.reload) + assign(:organization_statuses, OrganizationStatus.all) + assign(:organization_events, Event.none) + render + + assert_select "label", text: /Program status/ + expect(rendered).to include("Never active") end end diff --git a/spec/views/organizations/index.html.erb_spec.rb b/spec/views/organizations/index.html.erb_spec.rb index c31c7e7957..614478c0b5 100644 --- a/spec/views/organizations/index.html.erb_spec.rb +++ b/spec/views/organizations/index.html.erb_spec.rb @@ -14,9 +14,8 @@ before(:each) do assign(:organizations, paginated([ organization1, organization2 ])) assign(:organization_statuses, [ organization_status1, organization_status2, organization_status3 ]) - assign(:affiliated_since, {}) + assign(:program_since_display, {}) assign(:active_people_counts, {}) - assign(:program_statuses, {}) assign(:active_people_count, 0) assign(:organizations_count, 2) allow(view).to receive(:current_user).and_return(user) diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 40d35d9011..8f12321d5b 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -129,6 +129,7 @@ "app/views/events/participation.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/reports.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/scholarships.html.erb" => "admin-or-owner bg-blue-100", + "app/views/events/program_statuses.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/attendees.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/preview_reminder.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/confirm_reminder.html.erb" => "admin-or-owner bg-blue-100",