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
83 changes: 63 additions & 20 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,11 @@ def scholarship_recipient_count
# standalone scholarship form, or both).
SCHOLARSHIP_ANSWER_IDENTIFIERS = FormBuilderService::SECTION_FIELD_IDENTIFIERS[:scholarship].freeze

# Professional-info answers shown in each recipient's header. The view falls
# back to the person's profile (sectors / age-range tags) when these aren't on
# file as form answers. Sector answers are normalized under the "sector" key
# (see #header_answers_by_applicant) so the view reads one key regardless of
# which sector field identifier the answer was stored under.
HEADER_ANSWER_IDENTIFIERS = (FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS + %w[primary_age_group]).freeze

# The key sector answers are filed under in the per-applicant header hash.
HEADER_SECTOR_KEY = "sector".freeze
# Non-sector professional-info answers shown in each recipient's header (just
# the primary age group today). Sectors are handled separately by
# #header_sectors_for, which combines the primary + additional sector answers.
# The view falls back to the person's profile tags when these aren't on file.
HEADER_ANSWER_IDENTIFIERS = %w[primary_age_group].freeze

# Active registrants who requested a scholarship for this event, as Person
# records sorted by display name. Sectors, age-range tags, and affiliations are
Expand All @@ -58,7 +54,7 @@ def scholarship_recipient_count
def scholarship_applicants
@scholarship_applicants ||= Person
.where(id: scholarship_applicant_ids)
.includes(:sectors, { categories: :category_type },
.includes(:sectors, { sectorable_items: :sector }, { categories: :category_type },
{ categorizable_items: { category: :category_type } },
{ affiliations: :organization })
.sort_by(&:name)
Expand All @@ -75,20 +71,30 @@ def scholarship_answers_by_applicant
.transform_values { |answers| dedupe_answers(answers) }
end

# Sector / primary_age_group answers for the recipients page header, keyed by
# Person id then by header key (one answer each). Sector answers (whichever
# sector field identifier they used) are filed under HEADER_SECTOR_KEY so the
# view reads a single, stable key. Same cross-submission gathering as
# scholarship_answers_by_applicant.
# Non-sector header answers (primary age group) for the recipients page, keyed
# by Person id then by field identifier (one answer each). Same cross-submission
# gathering as scholarship_answers_by_applicant.
def header_answers_by_applicant
@header_answers_by_applicant ||= applicant_answers_for(HEADER_ANSWER_IDENTIFIERS)
.transform_values { |answers| answers.index_by { |answer| header_answer_key(answer.form_field&.field_identifier) } }
.transform_values { |answers| answers.index_by { |answer| answer.form_field&.field_identifier } }
end

# Normalizes a header answer's field identifier to its lookup key: every sector
# field collapses to HEADER_SECTOR_KEY; other identifiers pass through.
def header_answer_key(identifier)
identifier.in?(FormField::SECTOR_FIELD_IDENTIFIERS) ? HEADER_SECTOR_KEY : identifier
# A recipient's sectors for the "Serves" header, as [ Sector, is_primary ] pairs
# with their primary sector first (starred) and additional sectors following
# (alphabetical), all uniqued. Read straight from this event's form answers β€”
# combining the single-select primary and multi-select additional sector fields
# β€” so it reflects what the registrant chose, independent of the profile's
# is_primary tags. Falls back to the person's profile sector tags when they
# named no sectors on a form.
def header_sectors_for(person)
ids = header_sector_ids_by_applicant[person.id]
return profile_sector_pairs(person) unless ids && (ids[:primary].any? || ids[:additional].any?)

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: Form answers are the authority here, not the profile is_primary tags β€” legacy/prod registrations were tagged all-primary by the old assign_tags, so reading tags would star every sector. Profile tags are only the fallback when the registrant named no sectors on a form.


primary = ids[:primary].filter_map { |id| [ header_sector_lookup[id], true ] if header_sector_lookup[id] }
additional = ids[:additional]
.filter_map { |id| [ header_sector_lookup[id], false ] if header_sector_lookup[id] }
.sort_by { |sector, _| sector.name.to_s.downcase }
primary + additional
end

# A "shout out": a registrant the admin opted in (shoutout on their
Expand Down Expand Up @@ -709,6 +715,43 @@ def applicant_submission_ids
.pluck(:id)
end

# Sector ids each applicant named on this event's forms, split into their
# single primary (the dropdown answer) and additional ones (the multi-select
# answer), keyed by Person id. A sector named in both collapses to the primary.
def header_sector_ids_by_applicant
@header_sector_ids_by_applicant ||=
applicant_answers_for(FormField::SECTOR_FIELD_IDENTIFIERS).transform_values do |answers|
primary = sector_ids_in(answers, FormField::PRIMARY_SECTOR_FIELD_IDENTIFIERS)
{ primary: primary, additional: sector_ids_in(answers, FormField::ADDITIONAL_SECTOR_FIELD_IDENTIFIERS) - primary }
end
end

# Sector records for every id named across all applicants, fetched in one query
# and keyed by id, so #header_sectors_for resolves names without an N+1.
def header_sector_lookup
@header_sector_lookup ||= begin
ids = header_sector_ids_by_applicant.values.flat_map { |split| split[:primary] + split[:additional] }.uniq
Sector.where(id: ids).index_by(&:id)
end
end

# The integer Sector ids in the given answers for the given field identifiers,
# de-duplicated. Submitted values are ", "-joined Sector ids; non-numeric
# tokens (e.g. an "Other: <text>" free-text answer) are dropped.
def sector_ids_in(answers, identifiers)
answers
.select { |answer| answer.form_field&.field_identifier.in?(identifiers) }
.flat_map { |answer| answer.submitted_answer.to_s.split(", ") }
.filter_map { |token| token.to_i if token.match?(/\A\d+\z/) }
.uniq
end

# The person's profile sector tags as [ Sector, is_primary ] pairs, primary
# first β€” the fallback when they named no sectors on a form.
def profile_sector_pairs(person)
person.sectorable_items_primary_first.filter_map { |item| [ item.sector, item.is_primary? ] if item.sector }
end

# Non-blank answers for the given field identifiers across the applicants'
# event submissions, grouped by Person id, with fields and submissions
# preloaded.
Expand Down
23 changes: 9 additions & 14 deletions app/views/events/recipients.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@
<% participant_slug = @dashboard.registration_slug_by_registrant[person.id] %>
<% answers = (answers_by_applicant[person.id] || []).reject { |a| a.form_field&.field_identifier == "scholarship_eligibility" } %>
<% header = header_answers[person.id] || {} %>
<% sector_answer = header[EventDashboard::HEADER_SECTOR_KEY] %>
<% age_group_answer = header["primary_age_group"] %>
<%# Prefer the registrant's form answers; fall back to their profile data. %>
<% sector_text = resolve_answer_text(sector_answer&.form_field, sector_answer&.submitted_answer).presence || person.sectors.map(&:name).join(", ").presence %>
<% age_group_text = resolve_answer_text(age_group_answer&.form_field, age_group_answer&.submitted_answer).presence || person.categories.select { |c| c.category_type&.name == "AgeRange" }.map(&:name).join(", ").presence %>
<%# The affiliation active at the time of the event, excluding facilitator roles. %>
<% recipient_affiliations = person.affiliations.select { |a|
Expand All @@ -76,10 +74,12 @@
the avatar stays blank). Calm palette: neutral name/org links, with
the sector chips as the single accent. %>
<% dp = person.decorate %>
<%# Primary sector first (rendered as a darker-green crowned chip),
then the additional sectors alphabetically. %>
<% sectorable_items = person.sectorable_items_primary_first %>
<% has_sectors = sectorable_items.any? %>
<%# The registrant's sectors as [ sector, is_primary ] pairs β€” primary
first (starred chip), then the additional sectors alphabetically β€”
combined and uniqued from their form answers (profile tags as a
fallback). %>
<% header_sectors = @dashboard.header_sectors_for(person) %>
<% has_sectors = header_sectors.any? %>
<%# Primary age groups first (starred amber chips), then the additional
ones β€” matching the sector chips above. Falls back to the form
answer / profile text only when the person carries no tagged age
Expand Down Expand Up @@ -125,20 +125,15 @@
</div>

<%# Serves / Ages β€” parallel with the name %>
<% if has_sectors || sector_text.present? || has_age_groups || age_group_text.present? %>
<% if has_sectors || has_age_groups || age_group_text.present? %>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<% if has_sectors %>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400">Serves</span>
<% sectorable_items.each do |si| %>
<%= render "sectors/tagging_label", sector: si.sector, is_primary: si.is_primary? %>
<% header_sectors.each do |sector, is_primary| %>
<%= render "sectors/tagging_label", sector: sector, is_primary: is_primary %>
<% end %>
</div>
<% elsif sector_text.present? %>
<div class="text-sm text-gray-600">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400">Serves</span>
<%= sector_text %>
</div>
<% end %>
<% if has_age_groups %>
<div class="flex flex-wrap items-center gap-2">
Expand Down
52 changes: 46 additions & 6 deletions spec/services/event_dashboard_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -456,19 +456,59 @@
expect(dashboard.scholarship_answers_by_applicant[embedded_applicant.id].size).to eq(1)
end

it "gathers header (sector / age group) answers keyed by applicant, sector answers under the normalized sector key" do
# Use a legacy "service area" identifier to confirm it still resolves under
# the normalized sector key alongside the current "sector" identifiers.
service_field = create(:form_field, form: registration_form, name: "Primary sector", field_identifier: "primary_service_area")
it "gathers the primary age group header answer keyed by applicant" do
age_field = create(:form_field, form: registration_form, name: "Primary age group", field_identifier: "primary_age_group")
reg_submission = FormSubmission.find_by(person: embedded_applicant, form: registration_form)
create(:form_answer, form_submission: reg_submission, form_field: service_field, submitted_answer: "5")
create(:form_answer, form_submission: reg_submission, form_field: age_field, submitted_answer: "5")

header = dashboard.header_answers_by_applicant

expect(header[embedded_applicant.id][EventDashboard::HEADER_SECTOR_KEY].submitted_answer).to eq("5")
expect(header[embedded_applicant.id]["primary_age_group"].submitted_answer).to eq("5")
expect(header).not_to have_key(non_applicant.id)
end

describe "#header_sectors_for" do
let(:health) { create(:sector, name: "Healthcare") }
let(:education) { create(:sector, name: "Education") }
let(:housing) { create(:sector, name: "Housing") }

def answer_sectors(primary_identifier:, additional_identifier:, primary:, additional:)
primary_field = create(:form_field, form: registration_form, name: "Primary sector",
field_identifier: primary_identifier, answer_type: :single_select_dropdown)
additional_field = create(:form_field, form: registration_form, name: "Additional sectors",
field_identifier: additional_identifier, answer_type: :multi_select_checkbox)
reg_submission = FormSubmission.find_by(person: embedded_applicant, form: registration_form)
create(:form_answer, form_submission: reg_submission, form_field: primary_field, submitted_answer: primary.id.to_s)
create(:form_answer, form_submission: reg_submission, form_field: additional_field,
submitted_answer: additional.map { |s| s.id }.join(", "))
end

it "returns the primary sector first (starred) then additional sectors, uniqued" do
# health is named both primary and additional; it must appear once, as primary.
answer_sectors(primary_identifier: "primary_sector_single", additional_identifier: "additional_sectors",
primary: health, additional: [ education, housing, health ])

expect(dashboard.header_sectors_for(embedded_applicant))
.to eq([ [ health, true ], [ education, false ], [ housing, false ] ])
end

it "still resolves the legacy service-area identifiers" do
answer_sectors(primary_identifier: "primary_service_area_single", additional_identifier: "primary_service_area",
primary: health, additional: [ education ])

expect(dashboard.header_sectors_for(embedded_applicant))
.to eq([ [ health, true ], [ education, false ] ])
end

it "falls back to the person's profile sector tags when no sector answers exist" do
embedded_applicant.sectorable_items.create!(sector: health, is_primary: true)
embedded_applicant.sectorable_items.create!(sector: education, is_primary: false)

expect(dashboard.header_sectors_for(embedded_applicant))
.to eq([ [ health, true ], [ education, false ] ])
end
end

describe "shout outs" do
let(:org) { create(:organization, name: "New Economics for Women") }

Expand Down