Skip to content
Merged
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
43 changes: 40 additions & 3 deletions app/services/form_response_aggregator.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# Rolls up every submission to one form into a per-question report the results
# page renders: selectable questions become chartable [ label, count ] tallies
# (with dynamic sector/age-group ids resolved to names and "specify" free text
# split out), free-text questions become lists of the actual answers, and file
# questions a simple answered count. Questions are reported in form order, so the
# page mirrors the form itself.
# split out), number questions become an average/total/range summary, free-text
# questions become lists of the actual answers, and file questions a simple
# answered count. Questions are reported in form order, so the page mirrors the
# form itself.
class FormResponseAggregator
# One question's rollup. `kind` picks which fields are populated:
# :select — rows, chart, multi, specify_rows
# :number — average, total, minimum, maximum, integer_valued
# :text — responses
# :file — answered_count only
FieldReport = Struct.new(
:field, :label, :kind, :answered_count,
:rows, :chart, :multi, :specify_rows, :responses,
:average, :total, :minimum, :maximum, :integer_valued,
keyword_init: true
)

Expand Down Expand Up @@ -88,10 +91,44 @@ def build_report(field)
return build_map_report(field, answers, :world_map) if COUNTRY_IDENTIFIERS.include?(field.field_identifier)
return build_select_report(field, answers) if field.selectable?
return build_file_report(field, answers) if field.file_upload?
return build_numeric_report(field, answers) if numeric_field?(field)

build_text_report(field, answers)
end

# Number-typed free-form fields hold a figure worth averaging and summing
# (counts served, percentages), not free text.
def numeric_field?(field)
field.number_integer? || field.number_decimal?
end

# An average / total / range summary of a number question. Non-numeric stray
# values are dropped rather than skewing the figures.
def build_numeric_report(field, answers)
values = answers.filter_map { |answer| numeric_value(field, answer.submitted_answer) }
count = values.size
total = values.sum

FieldReport.new(
field: field, label: field.name, kind: :number, answered_count: count,
total: total,
average: count.zero? ? nil : total.to_f / count,
minimum: values.min, maximum: values.max,
integer_valued: !field.number_decimal?
)
end

# Parses one submitted answer into a number, or nil when it isn't one. Integer
# fields take a plain whole number (no hex/underscore literals); decimal fields
# accept a float.
def numeric_value(field, raw)
text = raw.to_s.strip
return if text.blank?
return Float(text, exception: false) if field.number_decimal?

text.to_i if text.match?(/\A-?\d+\z/)
end

# A choropleth tally of a geographic field: counts per submitted place value,
# left as-is (the map matches state/country names itself; the legend lists the
# exact counts). Multi-value answers split like any other select.
Expand Down
30 changes: 30 additions & 0 deletions app/views/forms/_numeric_card.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<%# locals: (report:) %>
<%# A number question's rollup: average, total, and how many answered. Mirrors
the "Average / Total / Responses" figures on the funder summary. %>
<% decimals = report.integer_valued ? 0 : 1 %>
<div class="border-primary/10 rounded-2xl border bg-white p-5 shadow-sm">
<h3 class="font-semibold text-gray-900"><%= report.label %></h3>

<% if report.answered_count.zero? %>
<p class="mt-3 text-sm text-gray-400">No responses yet.</p>
<% else %>
<div class="mt-4 grid grid-cols-3 gap-3 text-center">
<div>
<div class="text-2xl font-bold text-gray-900 tabular-nums"><%= number_with_delimiter(report.average.round(decimals)) %></div>
<div class="text-xs font-semibold tracking-wide text-gray-500 uppercase">Average</div>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 tabular-nums"><%= number_with_delimiter(report.total.round(decimals)) %></div>
<div class="text-xs font-semibold tracking-wide text-gray-500 uppercase">Total</div>
</div>
<div>
<div class="text-2xl font-bold text-gray-900 tabular-nums"><%= number_with_delimiter(report.answered_count) %></div>
<div class="text-xs font-semibold tracking-wide text-gray-500 uppercase">Responses</div>
</div>
</div>

<p class="mt-3 text-center text-xs text-gray-400">
Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %>
</p>
<% end %>
</div>
2 changes: 2 additions & 0 deletions app/views/forms/results.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
chart: report.chart, palette: palette, map_color: "#475569",
empty_message: "No responses yet.",
footer: report.answered_count.positive? ? render("forms/view_all_responses", form: @form, count: report.answered_count) : nil %>
<% when :number %>
<%= render "forms/numeric_card", report: report %>
<% when :text %>
<%= render "forms/text_responses_card", report: report, form: @form %>
<% when :file %>
Expand Down
12 changes: 12 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3036,3 +3036,15 @@
- "Set the parent from the “Parent organization” picker on the org edit page."
- "The profile shows a “Part of …” link up to the parent and a “Nested organizations” list down to the children."
- "Deleting a parent org un-nests its children rather than deleting them."

- name: "Averages and totals on form results"
area: reporting
display_status: admin_facing
released_on: 2026-09-01
pr_number: 2477
summary: >-
The form results page now summarizes number questions with their average,
total, and number of responses, instead of listing every raw figure — so a
count question like "individuals served" reads as a running total.
pro_tips:
- "This covers any question set to a number input type. Text and choice questions are unaffected."
18 changes: 18 additions & 0 deletions spec/requests/forms_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,24 @@
expect(response.body).to include("Loved it")
end

it "summarizes a number question with average, total, and responses" do
form = create(:form, :standalone, name: "Survey")
served = create(:form_field, form: form, name: "Adults served",
answer_type: :free_form_input_one_line, input_type: :number_integer)
[ "20", "40", "60" ].each do |value|
submission = create(:form_submission, form: form)
create(:form_answer, form_submission: submission, form_field: served, submitted_answer: value)
end

get results_form_path(form)

expect(response).to have_http_status(:success)
expect(response.body).to include("Adults served")
expect(response.body).to include("Average")
expect(response.body).to include("Total")
expect(response.body).to include("120")
end

it "shows an empty state when the form has no submissions" do
form = create(:form, :standalone, name: "Untouched")
get results_form_path(form)
Expand Down
58 changes: 58 additions & 0 deletions spec/services/form_response_aggregator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,64 @@ def answer(submission, field, value)
end
end

describe "numeric questions" do
def number_field(name)
create(:form_field, form: form, name: name,
answer_type: :free_form_input_one_line, input_type: :number_integer)
end

it "averages and totals a whole-number field as an open count" do
field = number_field("Adults served")
answer(create(:form_submission, form: form), field, "10")
answer(create(:form_submission, form: form), field, "25")

report = described_class.new(form).field_reports.first

expect(report.kind).to eq(:number)
expect(report.answered_count).to eq(2)
expect(report.total).to eq(35)
expect(report.average).to eq(17.5)
expect(report.minimum).to eq(10)
expect(report.maximum).to eq(25)
expect(report.integer_valued).to be(true)
end

it "drops blank and non-numeric answers from the figures" do
field = number_field("Teens served")
answer(create(:form_submission, form: form), field, "50")
answer(create(:form_submission, form: form), field, "")
answer(create(:form_submission, form: form), field, "n/a")

report = described_class.new(form).field_reports.first

expect(report.answered_count).to eq(1)
expect(report.total).to eq(50)
end

it "reports zero answered with a nil average before anyone responds" do
number_field("Elders served")

report = described_class.new(form).field_reports.first

expect(report.kind).to eq(:number)
expect(report.answered_count).to eq(0)
expect(report.average).to be_nil
end

it "keeps decimals for a number_decimal field" do
field = create(:form_field, form: form, name: "Average rating",
answer_type: :free_form_input_one_line, input_type: :number_decimal)
answer(create(:form_submission, form: form), field, "4.5")
answer(create(:form_submission, form: form), field, "3.0")

report = described_class.new(form).field_reports.first

expect(report.total).to eq(7.5)
expect(report.average).to eq(3.75)
expect(report.integer_valued).to be(false)
end
end

it "reports questions in form order" do
create(:form_field, form: form, name: "Second", answer_type: :single_select_radio, position: 2)
create(:form_field, form: form, name: "First", answer_type: :free_form_input_one_line, position: 1)
Expand Down