Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ab3104f
Treat a future-start facilitator affiliation as Upcoming, not Active
maebeale Aug 22, 2026
d719987
Link features entry to PR #2336
maebeale Aug 22, 2026
24e78e1
Give organizations a distinct "Upcoming" facilitator program status
maebeale Aug 23, 2026
35f3804
Include upcoming facilitators in the "Inactive" filter; document in A…
maebeale Aug 23, 2026
34b14df
Make the Upcoming badge date comparison timezone-proof
maebeale Aug 23, 2026
2047649
Seed a demo facilitator with a future (Upcoming) affiliation
maebeale Aug 23, 2026
400fc8f
Seed an Upcoming-status org in the facilitator demo
maebeale Aug 23, 2026
b9c0418
Color the Upcoming badge/chip blue, not amber
maebeale Aug 23, 2026
da80cf8
People index: "Facilitator since" column with a facilitator-status label
maebeale Aug 23, 2026
a715d7a
Affiliation rows: show an Inactive badge, plus Upcoming for future st…
maebeale Aug 23, 2026
7c28442
People directory: only active facilitators are publicly visible
maebeale Aug 23, 2026
0a1ede1
"Inactive" facilitator filter = everyone not a currently-active facil…
maebeale Aug 23, 2026
14eb2d9
Upcoming filter is independent of Active β€” both can match one person
maebeale Aug 23, 2026
29d2282
Org status filter reads like the people one
maebeale Aug 23, 2026
cd33879
Upcoming orgs aren't legacy-status drift, and one home for the Active…
maebeale Aug 23, 2026
4b255b6
An upcoming-only program doesn't move the org's stored status
maebeale Aug 24, 2026
01c6249
Keep upcoming affiliations visible where the row says "Upcoming"
maebeale Aug 24, 2026
2b46f83
"Facilitator since" is blank for someone who never facilitated
maebeale Aug 24, 2026
d90f453
Judge the affiliation editor's "today" in the server's zone
maebeale Aug 24, 2026
efab804
ADR-0001: the chip has four values, the filter has five
maebeale Aug 24, 2026
46be9c1
Shout-outs judge an affiliation by its dates, not just the cached flag
maebeale Aug 24, 2026
a89f2ff
Anchor the roster's program status on each row's own event
maebeale Aug 24, 2026
64d1820
Affiliation status stays "as of today", and it says so
maebeale Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -991,8 +991,9 @@ def scholarship_status_person_ids(value)
.select(:registrant_id)
end

# Person ids with at least one affiliation in the given status (Active / Pending
# / Inactive).
# Person ids with at least one affiliation in the given status (Affiliation::
# STATUSES β€” Active / Upcoming / Inactive), judged as of today to match the
# roster's Affiliation status column.
def person_affiliation_status_ids(status)
Affiliation.with_status(status).select(:person_id)
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def show
@workshop_variation_ideas = WorkshopVariationIdea.credited_to_person(@person).order(created_at: :desc).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/workshop_variation_ideas", locals: { person: @person, workshop_variation_ideas: @workshop_variation_ideas }
when "affiliations"
@affiliations = @person.affiliations.active.includes(organization: :logo_attachment).paginate(page: params[:page], per_page: per_page)
@affiliations = @person.affiliations.active_or_pending.includes(organization: :logo_attachment).paginate(page: params[:page], per_page: per_page)
render partial: "people/sections/affiliations", locals: { person: @person, affiliations: @affiliations }
end
end
Expand Down
59 changes: 41 additions & 18 deletions app/decorators/organization_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,21 @@ def affiliated_since_note
end

ORG_STATUS_BUCKET_LABELS = {
active: "Active", formerly_active: "Formerly active", never_active: "Never active"
active: "Active", upcoming: "Upcoming", formerly_active: "Formerly active", never_active: "Never active"
}.freeze
ORG_STATUS_BUCKET_THEMES = {
active: :org_active, formerly_active: :org_formerly_active, never_active: :org_never_active
active: :org_active, upcoming: :org_upcoming, formerly_active: :org_formerly_active, never_active: :org_never_active
}.freeze

# Derived purely from facilitator affiliations; the stored organization_status
# never feeds into this (ADR-0001 D3).
# never feeds into this (ADR-0001 D3). Upcoming (a facilitator scheduled but not
# started) is distinct from Active and from a lapsed Formerly active.
def organization_status_bucket
facilitators = object.affiliations.select(&:facilitator?)
return :never_active if facilitators.none?

facilitators.any?(&:active?) ? :active : :formerly_active
return :active if facilitators.any?(&:active?)
return :upcoming if facilitators.any?(&:upcoming?)
:formerly_active
end

# Only used to flag where the legacy column disagrees with the affiliations.
Expand All @@ -138,12 +140,31 @@ def stored_status_bucket
end

# E.g. a stored "Active" on an org that never had a facilitator affiliation.
# The legacy column has no Upcoming of its own, so a scheduled-but-not-started
# program is fairly recorded as Pending (or blank/Unknown, which bucket with it).
def legacy_status_mismatch?
return stored_status_bucket != :never_active if organization_status_bucket == :upcoming

organization_status_bucket != stored_status_bucket
end

def organization_status_label
ORG_STATUS_BUCKET_LABELS.fetch(organization_status_bucket)
# Upcoming is an admin-only distinction. Everyone else sees a program that
# hasn't started as plain "Inactive", neutral like Never active (it has never
# facilitated), so a public-facing index reflects the state of the world today
# rather than a scheduled future one. See ADR-0001 D3.
PUBLIC_UPCOMING = { label: "Inactive", bucket: :never_active }.freeze

def self.display_bucket(bucket, admin:)
bucket == :upcoming && !admin ? PUBLIC_UPCOMING[:bucket] : bucket
end

def self.display_label(bucket, admin:)
return PUBLIC_UPCOMING[:label] if bucket == :upcoming && !admin
ORG_STATUS_BUCKET_LABELS.fetch(bucket)
end

def organization_status_label(admin: false)
self.class.display_label(organization_status_bucket, admin: admin)
end

def self.status_classes_for_bucket(bucket)
Expand All @@ -156,28 +177,30 @@ def self.status_classes_for_bucket(bucket)
end

# Lets the edit form's Stimulus controller re-render the chip live without
# hard-coding theme classes in JS.
def self.status_bucket_styles
# hard-coding theme classes in JS. Takes the same admin flag as the server-side
# chip so the live value can't disagree with the rendered one.
def self.status_bucket_styles(admin: false)
ORG_STATUS_BUCKET_LABELS.each_key.to_h do |bucket|
[ bucket, { label: ORG_STATUS_BUCKET_LABELS.fetch(bucket), classes: status_classes_for_bucket(bucket) } ]
[ bucket, { label: display_label(bucket, admin: admin),
classes: status_classes_for_bucket(display_bucket(bucket, admin: admin)) } ]
end
end

def organization_status_classes
self.class.status_classes_for_bucket(organization_status_bucket)
def organization_status_classes(admin: false)
self.class.status_classes_for_bucket(self.class.display_bucket(organization_status_bucket, admin: admin))
end

def organization_status_chip(data: {})
h.content_tag(:span, organization_status_label,
def organization_status_chip(data: {}, admin: false)
h.content_tag(:span, organization_status_label(admin: admin),
data: data,
class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}")
class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes(admin: admin)}")
end

# Facilitator years, coloured by the org's status; falls back to the status
# label when there are no facilitator years to show.
def program_since_chip(years = program_since_display)
h.content_tag(:span, years.presence || organization_status_label,
class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes}")
def program_since_chip(years = program_since_display, admin: false)
h.content_tag(:span, years.presence || organization_status_label(admin: admin),
class: "inline-flex items-center rounded-full text-xs font-medium border px-2.5 py-0.5 #{organization_status_classes(admin: admin)}")
end

# Reads the already-loaded affiliations, so a profile classifies many events
Expand Down
23 changes: 23 additions & 0 deletions app/decorators/person_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ def affiliated_since_date
@affiliated_since_date ||= affiliations.filter_map(&:start_date).min
end

# Facilitator-since year for list pages β€” the Ruby (no per-row query) twin of
# facilitator_since_date. Nil for someone who has never held a facilitator
# affiliation, so the column can't show an unrelated affiliation/membership year
# under a "Facilitator since" heading. For an actual facilitator whose rows carry
# no start date, falls back to the legacy member_since like the edit form.
def facilitator_since_year
facilitator_affiliations = affiliations.select(&:facilitator?)
return nil if facilitator_affiliations.none?

(facilitator_affiliations.filter_map(&:start_date).min || member_since)&.year
end

# The person's facilitator standing for list display: Active if any facilitator
# affiliation is current, Upcoming if one is scheduled but none active, otherwise
# Inactive β€” which covers both a lapsed facilitator and someone who was never a
# facilitator (neither is a currently-active facilitator).
def facilitator_status_label
facilitator_affiliations = affiliations.select(&:facilitator?)
return "Active" if facilitator_affiliations.any?(&:active?)
return "Upcoming" if facilitator_affiliations.any?(&:upcoming?)
"Inactive"
end

def facilitator_since_range
date_range_display(facilitator_since_date, facilitation_end_date, ended_title: "No active facilitator affiliations")
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,17 @@ export default class extends Controller {

// Mirrors OrganizationDecorator#organization_status_bucket.
if (this.hasProgramStatusTarget) {
const started = a => !a.startDate || new Date(a.startDate) <= today
const notEnded = a => !a.endDate || new Date(a.endDate) >= today
let bucket
if (facilitatorAffiliations.length === 0) {
bucket = "never_active"
} else if (facilitatorAffiliations.some(a => started(a) && notEnded(a))) {
bucket = "active"
} else if (facilitatorAffiliations.some(a => !started(a) && notEnded(a))) {
bucket = "upcoming"
} else {
bucket = allFacInactive ? "formerly_active" : "active"
bucket = "formerly_active"
}
this.updateProgramStatus(bucket)
}
Expand Down Expand Up @@ -208,7 +214,7 @@ export default class extends Controller {
if (!target) return
let html = ""
if (endDate) {
html += '<i class="fa-solid fa-circle-xmark text-red-400 mr-1" title="No active affiliations"></i>'
html += '<i class="fa-solid fa-circle-xmark mr-1 text-red-400" title="No active affiliations"></i>'
}
html += sinceDate ? this.formatDate(sinceDate) : "β€”"
if (endDate) html += ` – ${this.formatDate(endDate)}`
Expand Down
40 changes: 36 additions & 4 deletions app/frontend/javascript/controllers/inactive_toggle_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { isFacilitatorTitle } from "../lib/affiliation";
// Live styling for the affiliation editor row as you edit, before saving. Four
// states by colour: role is the hue (facilitator = purple, else blue) and status
// is the saturation (active = full, inactive = super-light). Inactive rows also
// strike their fields (.aff-ended).
// strike their fields (.aff-ended). A not-yet-started row (future start date, not
// ended) additionally shows an "Upcoming" badge.
export default class extends Controller {
static targets = ["endDate", "title", "row", "accentBar", "valueField"]
static values = { expired: Boolean }
static targets = ["endDate", "title", "row", "accentBar", "valueField", "startDate", "upcomingBadge", "inactiveBadge"]
static values = { expired: Boolean, today: String }

connect() {
if (this.hasTitleTarget) this.updateBorder();
Expand Down Expand Up @@ -36,9 +37,40 @@ export default class extends Controller {
this.updateRowBackground();
this.styleTitle();
this.paintFields();
this.updateBadges();
this.rowTarget.classList.toggle("aff-ended", this.isPast());
}

// "Inactive" whenever the affiliation isn't currently active (ended, flagged,
// or not-yet-started); "Upcoming" additionally for a future start β€” so an
// upcoming row shows both and an ended one shows only Inactive.
updateBadges() {
const notActive = this.isPast() || this.isUpcoming();
if (this.hasInactiveBadgeTarget) this.inactiveBadgeTarget.classList.toggle("hidden", !notActive);
if (this.hasUpcomingBadgeTarget) this.upcomingBadgeTarget.classList.toggle("hidden", !this.isUpcoming());
}

isUpcoming() {
if (this.isPast()) return false;
const value = this.hasStartDateTarget ? this.startDateTarget.value : "";
if (!value) return false;
return value > this.todayISO();
}

// "Today" as YYYY-MM-DD, compared against the date inputs' own YYYY-MM-DD values
// as strings β€” no cross-timezone Date parsing. It comes from the server, because
// the ERB badges are rendered against the app's Date.current: a browser whose
// local date is a day behind (e.g. US evening under a UTC app zone) would
// otherwise call a row starting today "Upcoming" while the server didn't.
todayISO() {
return this.todayValue || this.browserToday();
}

browserToday() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

styleTitle() {
if (!this.hasTitleTarget) return;
const t = this.titleTarget;
Expand Down Expand Up @@ -89,7 +121,7 @@ export default class extends Controller {
// server's inactive flag, so trust the server-rendered `expired` value.
isPast() {
const value = this.hasEndDateTarget ? this.endDateTarget.value : "";
if (value) return new Date(value) < new Date(new Date().toDateString());
if (value) return value < this.todayISO();
return this.expiredValue;
}

Expand Down
42 changes: 34 additions & 8 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,19 @@ def communications_email

# Not flagged inactive and not past its end date. Includes affiliations whose
# start_date is still in the future (e.g. a Facilitator affiliation dated to an
# upcoming training) β€” they are "pending" but counted here.
# upcoming training) β€” they are "pending" but counted here. Use this for
# "active or not-yet-started" checks (e.g. registration dedup); use `active` for
# genuinely-current rows.
scope :active_or_pending, -> {
where(inactive: false)
.where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", Date.current)
}

scope :active, -> { active_or_pending }
# Genuinely active *now*: not flagged inactive, already started (no future
# start), and not past its end date. A future-start row is Upcoming, not Active.
# Delegates to with_status so the SQL lives in one place, the way #active?
# delegates to #status_on.
scope :active, -> { with_status("Active") }

# Affiliations that overlapped a given date, judged purely by their start/end
# dates rather than the cached `inactive` flag (which reflects "now"). Use this
Expand Down Expand Up @@ -100,11 +106,26 @@ def facilitator?
title.to_s.strip == FACILITATOR_TITLE
end

# Current: not flagged inactive and not past its end date. Mirrors the `active`
# scope so already-loaded affiliations can be filtered in Ruby without another
# query (e.g. on list pages that preload affiliations).
# Genuinely active now β€” the in-memory twin of the `active` scope and of
# status_on == "Active", so already-loaded affiliations can be filtered in Ruby
# without another query (e.g. on list pages that preload affiliations). A
# future-start row is Upcoming, not Active.
def active?
!inactive? && (end_date.nil? || end_date >= Date.current)
status_on == "Active"
end

# Not yet started: a future start date, not flagged inactive and not ended.
# The in-memory twin of status_on == "Upcoming".
def upcoming?
status_on == "Upcoming"
end

# Ended or flagged, as of a date β€” the in-memory twin of status_on == "Inactive".
# Prefer this to the raw `inactive` column when judging a row for display: the
# column is only re-derived on save (set_inactive_from_dates), so a term that
# simply lapsed still reads `inactive: false` until something touches it.
def inactive_on?(date = Date.current)
status_on(date) == "Inactive"
end

# This affiliation's status as of a date: Inactive (flagged or ended), Upcoming
Expand Down Expand Up @@ -199,10 +220,15 @@ def sync_organization_affiliation_dates

# Org status tracks active *Facilitator* affiliations specifically (mirroring the
# form's status indicator) β€” a non-facilitator affiliation does not keep an org active.
# An upcoming-only program is left alone in both directions: a facilitator dated
# to a future training must not stamp the org Inactive (nothing re-runs this when
# the start date arrives), but it hasn't started, so it must not stamp it Active
# either β€” either way the stored value would read as drift on the edit form.
def sync_organization_status_with_affiliations
if organization.affiliations.facilitators.active.exists?
facilitators = organization.affiliations.facilitators
if facilitators.active.exists?
reactivate_organization_if_inactive
else
elsif facilitators.with_status("Upcoming").none?
deactivate_organization_if_no_active_people
end
end
Expand Down
16 changes: 15 additions & 1 deletion app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,29 @@ def self.awbw
scope :program_status, ->(bucket) {
fac_ids = Affiliation.facilitators.select(:organization_id)
active_fac_ids = Affiliation.facilitators.active.select(:organization_id)
upcoming_fac_ids = Affiliation.facilitators.with_status("Upcoming").select(:organization_id)
case bucket.to_s
when "active" then where(id: active_fac_ids)
when "formerly_active" then where(id: fac_ids).where.not(id: active_fac_ids)
when "upcoming" then where(id: upcoming_fac_ids).where.not(id: active_fac_ids)
when "formerly_active" then where(id: fac_ids).where.not(id: active_fac_ids).where.not(id: upcoming_fac_ids)
when "never_active" then where.not(id: fac_ids)
when "formerly_or_never" then where.not(id: active_fac_ids)
else all
end
}

# The index's Program status dropdown, in display order. "Inactive" is the
# not-active umbrella (formerly + never + upcoming) with the two narrower
# buckets after it, mirroring Person::FACILITATOR_STATUS_FILTER_OPTIONS so the
# two indexes read the same way.
PROGRAM_STATUS_FILTER_OPTIONS = [
[ "Active", "active" ],
[ "Inactive", "formerly_or_never" ],
[ "Upcoming", "upcoming" ],
[ "Formerly active", "formerly_active" ],
[ "Never active", "never_active" ]
].freeze

# Matches a tag on the org itself OR an affiliated person's PRIMARY tag β€”
# mirroring the aggregate the index/profile columns show.
scope :sector_name_including_people, ->(name) {
Expand Down
Loading