Skip to content
Draft
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
4 changes: 3 additions & 1 deletion app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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, :addresses,
{ windows_type: { categorizable_items: { category: :category_type } } },
{ categorizable_items: { category: :category_type } },

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: Eager-loads the windows type's age-range categories so windows_audience_unreflected_by_age_tags? (which reads categorizable_items in Ruby) doesn't N+1 across the 25 rows per page.

logo_attachment: :blob
))
Expand Down Expand Up @@ -189,6 +190,7 @@ def set_form_variables

def set_index_variables
@organization_statuses = OrganizationStatus.all
@age_range_categories = Category.age_ranges.published.ordered_by_position_and_name
end

def populations_served
Expand Down
33 changes: 32 additions & 1 deletion app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,34 @@ class Organization < ApplicationRecord
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 }
# Filters by AgeRange category name(s), powering the unified "Age range" column
# on the index. An org matches a selected age range when EITHER it (or an
# affiliated person) is tagged with that age range, OR its windows audience
# semantically covers it. The windows-type leg keeps orgs that carry a windows
# audience but no age-range tags β€” or tags that disagree with it β€” in results.
scope :age_range_names, ->(names) do
return all if names.blank?
parsed = Array(names).flat_map { |n| n.to_s.split("--") }.map(&:strip).reject(&:blank?).map(&:downcase)
return all if parsed.empty?

category_ids = Category.age_ranges.where("LOWER(categories.name) IN (?)", parsed).select(:id)
org_tagged = CategorizableItem.where(categorizable_type: "Organization", category_id: category_ids).select(:categorizable_id)
person_tagged = CategorizableItem.where(categorizable_type: "Person", category_id: category_ids).select(:categorizable_id)
via_affiliations = Affiliation.where(person_id: person_tagged).select(:organization_id)
windows_type_ids = WindowsType.joins(:categorizable_items)
.where(categorizable_items: { category_id: category_ids })
.select(:id)

where(id: org_tagged).or(where(id: via_affiliations)).or(where(windows_type_id: windows_type_ids))

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: Three-legged OR is the heart of the filter: org-own tag, affiliated-person tag (mirrors the column's aggregation), or windows-audience semantic coverage. All three are plain wheres on organizations with no joins, so .or stays structurally compatible.

end

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.address(params[:address]) if params[:address].present?
organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present?
organizations = organizations.age_range_names(params[:age_range_name]) if params[:age_range_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
Expand Down Expand Up @@ -265,6 +285,17 @@ def all_additional_age_groups
collect_age_groups(:additional_age_groups) - all_primary_age_groups
end

# Whether the windows audience adds age information the org's age-range tags
# don't already convey β€” drives the fallback windows-type badge in the index's
# age range column. True when the org has a windows type and either carries no
# age-range tags at all, or its tags omit an age range that windows type covers.
def windows_audience_unreflected_by_age_tags?
return false unless windows_type
tagged = all_primary_age_groups + all_additional_age_groups
return true if tagged.empty?

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: Empty tags β†’ true is intentional: an org with a windows audience but no age tags is exactly the case we want to surface the badge for. Otherwise we only show it when the audience covers a range the tags omit.

windows_type.tagged_age_range_categories.any? { |category| tagged.exclude?(category) }
end

remote_searchable_by :name

# Returns the website as a clickable, scheme-qualified URL β€” prepending
Expand Down
8 changes: 8 additions & 0 deletions app/models/windows_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,12 @@ class WindowsType < ApplicationRecord

validates :name, presence: true
validates :short_name, presence: true

# AgeRange categories tagged on this windows type, read from already-loaded
# categorizable_items (no query) so the organizations index can compare them
# against an org's age-range tags without an N+1. Use the :age_ranges
# association when you want them ordered and don't already have the items loaded.
def tagged_age_range_categories
categorizable_items.map(&:category).compact.select { |category| category.category_type&.name == "AgeRange" }
end
end
12 changes: 6 additions & 6 deletions app/views/organizations/_search_boxes.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
</div>
<div>
<%= label_tag :windows_type_name, "Windows audience", class: "text-sm font-medium text-gray-700 mb-1 block" %>
<%= select_tag :windows_type_name,
options_for_select(WindowsType::TYPES.map { |wt| [wt, wt] },
params[:windows_type_name]),
include_blank: "All types",
class: "w-40 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
<%= label_tag :age_range_name, "Age range", class: "text-sm font-medium text-gray-700 mb-1 block" %>
<%= select_tag :age_range_name,
options_for_select(@age_range_categories.map { |category| [ category.name, category.name ] },
params[:age_range_name]),
include_blank: "All age ranges",
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" %>
</div>
<div>
Expand Down
21 changes: 13 additions & 8 deletions app/views/organizations/organization_results.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@

<td class="px-4 py-2 text-sm">
<div class="flex flex-wrap items-center gap-1">
<% if organization.windows_type %>
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 border border-purple-200">
<%= organization.windows_type.short_name.titleize %>
</span>
<% end %>

<% organization.sectors.each do |sector| %>
<%= render "sectors/tagging_label", sector: sector %>
<% end %>
Expand All @@ -53,8 +47,19 @@
<td class="px-4 py-2 text-sm">
<% primary_age = organization.all_primary_age_groups %>
<% additional_age = organization.all_additional_age_groups %>
<% if primary_age.any? || additional_age.any? %>
<%= render "shared/age_group_tags", primary: primary_age, additional: additional_age, compact: true %>
<% show_windows_audience = organization.windows_audience_unreflected_by_age_tags? %>
<% if primary_age.any? || additional_age.any? || show_windows_audience %>
<div class="flex flex-wrap items-center gap-1.5">
<% if primary_age.any? || additional_age.any? %>
<%= render "shared/age_group_tags", primary: primary_age, additional: additional_age, compact: true %>
<% end %>
<% if show_windows_audience %>
<span class="inline-flex items-center whitespace-nowrap rounded-full border border-purple-200 bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800"
title="Windows audience">
<%= organization.windows_type.short_name.titleize %>
</span>
<% end %>
</div>
<% else %>
<span class="text-gray-400">--</span>
<% end %>
Expand Down
70 changes: 70 additions & 0 deletions spec/models/organization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,76 @@
expect(organization.all_additional_age_groups).to be_empty
end
end

describe "age-range filtering and windows-audience fallback" do
let(:age_type) { create(:category_type, name: "AgeRange", published: true) }
let!(:children) { create(:category, :published, category_type: age_type, name: "Children (0-12)") }
let!(:teens) { create(:category, :published, category_type: age_type, name: "Teens (13-17)") }
let!(:adults) { create(:category, :published, category_type: age_type, name: "Adults (18+)") }

# Windows audiences with their semantic age-range coverage.
let(:children_audience) { create(:windows_type, :children).tap { |wt| wt.categories = [ children, teens ] } }
let(:adult_audience) { create(:windows_type, :adult).tap { |wt| wt.categories = [ adults ] } }

describe ".age_range_names" do
it "matches orgs tagged with the age range on the org itself" do
org = create(:organization)
org.tag_age_groups(primary_ids: [ teens.id ], additional_ids: [])
other = create(:organization)

results = Organization.age_range_names("Teens (13-17)")
expect(results).to include(org)
expect(results).not_to include(other)
end

it "matches orgs tagged with the age range through an affiliated person" do
org = create(:organization)
person = create(:person)
create(:affiliation, organization: org, person: person)
person.tag_age_groups(primary_ids: [ children.id ], additional_ids: [])

expect(Organization.age_range_names("Children (0-12)")).to include(org)
end

it "matches orgs whose windows audience covers the age range even with no age tags" do
org = create(:organization, windows_type: adult_audience)

expect(org.all_primary_age_groups).to be_empty
expect(Organization.age_range_names("Adults (18+)")).to include(org)
end

it "excludes orgs whose windows audience does not cover the age range and that lack the tag" do
org = create(:organization, windows_type: adult_audience)

expect(Organization.age_range_names("Children (0-12)")).not_to include(org)
end
end

describe "#windows_audience_unreflected_by_age_tags?" do
it "is false when the org has no windows type" do
expect(create(:organization, windows_type: nil).windows_audience_unreflected_by_age_tags?).to be(false)
end

it "is true when the org has a windows type but no age tags" do
org = create(:organization, windows_type: adult_audience)
expect(org.windows_audience_unreflected_by_age_tags?).to be(true)
end

it "is true when the age tags omit an age range the windows audience covers" do
org = create(:organization, windows_type: children_audience)
org.tag_age_groups(primary_ids: [ children.id ], additional_ids: [])

expect(org.windows_audience_unreflected_by_age_tags?).to be(true)
end

it "is false when the age tags already reflect every age range the windows audience covers" do
org = create(:organization, windows_type: adult_audience)
org.tag_age_groups(primary_ids: [ adults.id ], additional_ids: [])

expect(org.windows_audience_unreflected_by_age_tags?).to be(false)
end
end
end
end

RSpec.describe Organization, "scholarship index helpers" do
Expand Down
Loading