Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ end

### Business Logic

- `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods and formats them as year-based ranges for the "Affiliated since" display (e.g. "2010-2012, 2026"); mirrored client-side in `affiliation_dates_controller.js`
- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
- `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, projected CE, chart series) for the CEO revenue page
- `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view)
Expand Down
8 changes: 3 additions & 5 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def index
if turbo_frame_request?
per_page = params[:number_of_items_per_page].presence || 25
base_scope = authorized_scope(Organization.includes(
:windows_type, :organization_status, :sectors, :addresses,
:windows_type, :organization_status, :sectors, :addresses, :affiliations,
{ categorizable_items: { category: :category_type } },
logo_attachment: :blob
))
Expand All @@ -17,15 +17,13 @@ def index
@active_people_count = Affiliation.active.where(organization_id: filtered.select(:id)).count("DISTINCT person_id, organization_id")
@organizations = filtered.paginate(page: params[:page], per_page: per_page)
org_ids = @organizations.map(&:id)
@affiliated_since = Affiliation.where(organization_id: org_ids)
.group(:organization_id)
.minimum(:start_date)
# Merged-period "Affiliated since" label per org, from the preloaded affiliations.
@affiliated_since_display = @organizations.to_h { |org| [ org.id, org.decorate.affiliated_since_display ] }
@active_people_counts = Affiliation.active
.where(organization_id: org_ids)
.group(:organization_id)
.distinct
.count(:person_id)
@program_statuses = Organization.program_statuses_by_id(org_ids)

render :organizations_results
else
Expand Down
8 changes: 8 additions & 0 deletions app/decorators/organization_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ def affiliation_end_date
affiliations.maximum(:end_date)
end

# "Affiliated since" display: affiliation history as merged year-based periods
# (see AffiliationPeriods), falling back to the org's own start_date, then to a
# blank string. Pass a preloaded affiliations collection on list pages to avoid
# an N+1.
def affiliated_since_display(affiliations = object.affiliations)
AffiliationPeriods.label(affiliations) || object.start_date&.strftime("%b %Y") || ""
end

def facilitator_since_date
@facilitator_since_date ||= affiliations.facilitators.minimum(:start_date)
end
Expand Down
82 changes: 63 additions & 19 deletions app/frontend/javascript/controllers/affiliation_dates_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer"]
// Organizations show "Affiliated since" as merged year-based periods; the person
// form leaves it off and keeps the single Mon YYYY – Mon YYYY range.
static values = { periods: Boolean }

initialize() {
this.boundRecalculate = () => this.recalculate()
Expand Down Expand Up @@ -38,25 +41,30 @@ export default class extends Controller {

recalculate() {
const affiliations = this.getVisibleAffiliations()

// Affiliated since = min start_date of all affiliations
const allStartDates = affiliations.map(a => a.startDate).filter(Boolean)
const affiliatedSince = allStartDates.length
? new Date(Math.min(...allStartDates.map(d => new Date(d))))
: null

// Affiliated end = only if ALL affiliations are inactive (end_date in the past)
const now = new Date()
const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
const allInactive = affiliations.length > 0 &&
affiliations.every(a => a.endDate && new Date(a.endDate) < today)
const affiliatedEnd = allInactive
? new Date(Math.max(...affiliations.map(a => new Date(a.endDate))))
: null

// Facilitator since/end — same logic filtered by title. Mirror
// Affiliation#facilitator?: an exact, case-sensitive match on "Facilitator"
// (trimmed), so the live figure matches what the server will render.
// Affiliated since — merged periods (orgs) or a single range (person form).
if (this.hasAffiliatedSinceTarget) {
if (this.periodsValue) {
this.affiliatedSinceTarget.textContent = this.affiliatedSinceLabel(affiliations, today) || "—"
} else {
const startDates = affiliations.map(a => a.startDate).filter(Boolean)
const affiliatedSince = startDates.length
? new Date(Math.min(...startDates.map(d => new Date(d))))
: null
const allInactive = affiliations.length > 0 &&
affiliations.every(a => a.endDate && new Date(a.endDate) < today)
const affiliatedEnd = allInactive
? new Date(Math.max(...affiliations.map(a => new Date(a.endDate))))
: null
this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd)
}
}

// Facilitations/program since — unchanged single-range display, filtered by
// title. Mirror Affiliation#facilitator?: an exact, case-sensitive match on
// "Facilitator" (trimmed), so the live figure matches the server render.
const facilitatorAffiliations = affiliations.filter(a =>
a.title.trim() === "Facilitator"
)
Expand All @@ -70,14 +78,50 @@ export default class extends Controller {
? new Date(Math.max(...facilitatorAffiliations.map(a => new Date(a.endDate))))
: null

if (this.hasAffiliatedSinceTarget) {
this.updateDisplay(this.affiliatedSinceTarget, affiliatedSince, affiliatedEnd)
}
if (this.hasFacilitatorSinceTarget) {
this.updateDisplay(this.facilitatorSinceTarget, facilitatorSince, facilitatorEnd)
}
}

// Merge affiliation intervals into periods and format them as year-based ranges
// — the client-side mirror of app/services/affiliation_periods.rb.
affiliatedSinceLabel(affiliations, today) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: This is a hand-kept mirror of AffiliationPeriods (app/services/affiliation_periods.rb) so the edit-form live preview matches the server render. If the Ruby formatting rules change, change both.

const intervals = affiliations
.filter(a => a.startDate)
.map(a => ({ start: new Date(a.startDate), end: a.endDate ? new Date(a.endDate) : null }))
.sort((a, b) => a.start - b.start)
if (!intervals.length) return ""

const periods = []
for (const iv of intervals) {
const last = periods[periods.length - 1]
if (last && (last.end === null || iv.start <= last.end)) {
last.end = last.end === null || iv.end === null ? null : new Date(Math.max(last.end, iv.end))
} else {
periods.push({ start: iv.start, end: iv.end })
}
}

const ongoing = end => end === null || end >= today
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

// A single ongoing period is a fresh org — worth the month's precision.
if (periods.length === 1 && ongoing(periods[0].end)) {
const s = periods[0].start
return s.getUTCFullYear() === today.getUTCFullYear()
? `${months[s.getUTCMonth()]} ${s.getUTCFullYear()}`
: `${s.getUTCFullYear()}`
}

return periods
.map(p => {
const startYear = p.start.getUTCFullYear()
if (ongoing(p.end) || startYear === p.end.getUTCFullYear()) return `${startYear}`
return `${startYear}-${p.end.getUTCFullYear()}`
})
.join(", ")
}

getVisibleAffiliations() {
if (!this.hasAffiliationsContainerTarget) return []
const fields = this.affiliationsContainerTarget.querySelectorAll(".nested-fields")
Expand Down
20 changes: 10 additions & 10 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,29 +128,29 @@ def sync_organization_status_with_affiliations
end

def deactivate_organization_if_no_active_people
inactive_status = OrganizationStatus.find_by(name: "Inactive")
return unless inactive_status
return if organization.organization_status_id == inactive_status.id
formerly_active_status = OrganizationStatus.find_by(name: "Formerly active")
return unless formerly_active_status
return if organization.organization_status_id == formerly_active_status.id

organization.update_column(:organization_status_id, inactive_status.id)
organization.update_column(:organization_status_id, formerly_active_status.id)

Ahoy::Tracker.new(user: Current.user).track(
"autochange.organization",
resource_type: "Organization",
resource_id: organization.id,
resource_title: organization.name,
change: "status_set_to_inactive",
change: "status_set_to_formerly_active",
reason: "no_active_affiliations"
)
end

# Only flip back from "Inactive" — the status the deactivation callback sets.
# Leave Pending/Reinstate/Unknown (and Active) untouched.
# Only flip back from "Formerly active" — the status the deactivation callback
# sets. Leave Unknown (and Active) untouched.
def reactivate_organization_if_inactive
inactive_status = OrganizationStatus.find_by(name: "Inactive")
formerly_active_status = OrganizationStatus.find_by(name: "Formerly active")
active_status = OrganizationStatus.find_by(name: "Active")
return unless inactive_status && active_status
return unless organization.organization_status_id == inactive_status.id
return unless formerly_active_status && active_status
return unless organization.organization_status_id == formerly_active_status.id

organization.update_column(:organization_status_id, active_status.id)

Expand Down
15 changes: 0 additions & 15 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,21 +206,6 @@ def program_status(recipient = nil)
prior.any? ? "Ongoing" : "New"
end

# Bulk program status (:new / :ongoing / :reinstated) for the given org ids,
# keyed by id — the recipient-less form of #program_status, computed with
# aggregate queries so list pages avoid loading each org's affiliations. An org
# with no facilitator affiliations is :new; with facilitators but none
# currently active it is :reinstated; otherwise :ongoing.
def self.program_statuses_by_id(org_ids)
facilitator_scope = Affiliation.facilitators.where(organization_id: org_ids)
with_facilitators = facilitator_scope.distinct.pluck(:organization_id).to_set
with_active = facilitator_scope.active.distinct.pluck(:organization_id).to_set
org_ids.index_with do |id|
next :new if with_facilitators.exclude?(id)
with_active.include?(id) ? :ongoing : :reinstated
end
end

def type_name
"#{name} #{ " (#{windows_type.short_name})" if windows_type}"
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/organization_status.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class OrganizationStatus < ApplicationRecord
ORGANIZATION_STATUSES = [ "Active", "Inactive", "Pending", "Reinstate", "Suspended", "Unknown" ]
ORGANIZATION_STATUSES = [ "Active", "Formerly active", "Unknown" ]

has_many :organizations

Expand Down
83 changes: 83 additions & 0 deletions app/services/affiliation_periods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Formats an organization's affiliation history as merged, year-based periods for
# the "Affiliated since" display. Each affiliation is a [start_date, end_date]
# interval (a nil end = ongoing); overlapping or touching intervals collapse into
# one period, and a real gap starts a new one.
#
# Formatting:
# * A lone ongoing period (a fresh org) shows "Mon YYYY" when it began this year
# (e.g. "Jul 2026"), otherwise just its start year — no end.
# * In any multi-period list, every period is year-only for consistency: an
# ongoing period is its start year, a closed period is "YYYY" (same-year) or
# "YYYY-YYYY". Periods join chronologically with ", " (e.g. "2010-2012, 2026").
#
# Returns nil when no affiliation carries a start date, so callers can fall back
# to the organization's own start_date.
class AffiliationPeriods
def self.label(affiliations, today: Date.current)
new(affiliations, today: today).label
end

def initialize(affiliations, today: Date.current)
@today = today
@intervals = affiliations
.filter_map { |affiliation| interval_for(affiliation) }
.sort_by { |start, _finish| start }
end

def label
return nil if @intervals.empty?

periods = merged
# A single ongoing period is a fresh org — worth the month's precision.
if periods.one? && ongoing?(periods.first[1])
return year_or_month(periods.first[0])
end

periods.map { |period| format_period(period) }.join(", ")
end

private

# Affiliations without a start date can't be placed on the timeline.
def interval_for(affiliation)
return nil if affiliation.start_date.blank?

[ affiliation.start_date.to_date, affiliation.end_date&.to_date ]
end

def merged
@intervals.each_with_object([]) do |(start, finish), periods|
last = periods.last
if last && overlaps?(last, start)
last[1] = later_end(last[1], finish)
else
periods << [ start, finish ]
end
end
end

# A nil end is ongoing and swallows every later interval.
def overlaps?(period, next_start)
period[1].nil? || next_start <= period[1]
end

def later_end(current, other)
return nil if current.nil? || other.nil?

[ current, other ].max
end

def ongoing?(finish)
finish.nil? || finish >= @today
end

def format_period((start, finish))
return start.year.to_s if ongoing?(finish) || start.year == finish.year

"#{start.year}-#{finish.year}"
end

def year_or_month(date)
date.year == @today.year ? date.strftime("%b %Y") : date.year.to_s
end
end
20 changes: 4 additions & 16 deletions app/views/organizations/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning" } }) do |f| %>
<%= simple_form_for(@organization, html: { data: { controller: "affiliation-dates affiliation-facilitator-warning", affiliation_dates_periods_value: true } }) do |f| %>
<%= render 'shared/errors', resource: @organization if @organization.errors.any? %>
<%= render "duplicate_organizations_warning" %>
<% has_affiliations = f.object.persisted? && f.object.affiliations.any? %>
Expand Down Expand Up @@ -156,7 +156,7 @@
</div>
<% end %>

<% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Inactive" %>
<% facilitator_status_name = f.object.affiliations.facilitators.active.exists? ? "Active" : "Formerly active" %>
<% status_matches_affiliations = f.object.organization_status&.name == facilitator_status_name %>
<% show_status_select = allowed_to?(:manage?, Organization) &&
(params[:admin] || (f.object.persisted? && !status_matches_affiliations)) %>
Expand Down Expand Up @@ -274,7 +274,7 @@
</label>
<div class="hidden group-hover:block absolute z-50 left-0 bottom-full mb-1 bg-blue-100 text-gray-700 text-xs rounded-lg shadow-lg ring-1 ring-blue-200 p-3 w-64 whitespace-normal">
<% if has_affiliations && org_earliest_aff.present? %>
<p>Auto-managed from affiliation records. Earliest affiliation start date: <%= org_earliest_aff.strftime('%b %Y') %>.</p>
<p>Auto-managed from affiliation records — shown as merged year-based periods (e.g. "2010-2012, 2026").</p>
<% if f.object.start_date.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %>
<p class="mt-2 text-gray-500"><i class="fa-solid fa-circle-info mr-1"></i>Organization start_date (<%= f.object.start_date.strftime('%b %Y') %>) differs from earliest affiliation.</p>
<% end %>
Expand All @@ -290,7 +290,7 @@
<% end %>
</div>
<div class="pt-1">
<span class="text-gray-900 font-medium" data-affiliation-dates-target="affiliatedSince"><% if org_aff_ended || (f.object.end_date.present? && !has_affiliations) %><i class="fa-solid fa-circle-xmark text-red-400 mr-1" title="Affiliation ended"></i><% end %><%= (org_earliest_aff || f.object.start_date)&.strftime('%b %Y') || "—" %><%= " – #{org_end_date.strftime('%b %Y')}" if org_end_date.present? %></span>
<span class="text-gray-900 font-medium" data-affiliation-dates-target="affiliatedSince"><%= org_decorated.affiliated_since_display.presence || "—" %></span>
<% if has_affiliations && org_earliest_aff.nil? %>
<i class="fa-solid fa-triangle-exclamation text-amber-500 ml-1"></i>
<% elsif f.object.start_date.present? && org_earliest_aff.present? && f.object.start_date.beginning_of_month != org_earliest_aff.beginning_of_month %>
Expand Down Expand Up @@ -319,18 +319,6 @@
<span class="text-gray-900 font-medium" data-affiliation-dates-target="facilitatorSince"><% if org_fac_ended %><i class="fa-solid fa-circle-xmark text-red-400 mr-1" title="No active facilitator affiliations"></i><% end %><%= org_fac_start&.strftime("%b %Y") || "—" %><%= " – #{org_fac_ended.strftime('%b %Y')}" if org_fac_ended %></span>
</div>
</div>
<div class="shrink-0 relative group cursor-help">
<label class="inline-flex items-center gap-1 text-md font-medium text-gray-700 mb-1">
Program status <i class="fa-solid fa-circle-info text-gray-400 text-xs"></i>
</label>
<div class="hidden group-hover:block absolute z-50 left-0 bottom-full mb-1 bg-blue-100 text-gray-700 text-xs rounded-lg shadow-lg ring-1 ring-blue-200 p-3 w-64 whitespace-normal">
<p><span class="font-medium">New</span> — no facilitator affiliations yet. <span class="font-medium">Ongoing</span> — has an active facilitator. <span class="font-medium">Reinstate</span> — had facilitators but all have ended.</p>
</div>
<div class="pt-1 flex items-center gap-2">
<%= org_decorated.program_status_badge %>
<span class="text-gray-900 font-medium"><%= f.object.program_status %></span>
</div>
</div>
</div>
<% if allowed_to?(:manage?, Organization) %>
<div class="p-3" data-controller="paginated-fields" data-paginated-fields-per-page-value="10" data-affiliation-dates-target="affiliationsContainer">
Expand Down
Loading