diff --git a/app/services/form_response_aggregator.rb b/app/services/form_response_aggregator.rb
index c3ed017722..529c5f2387 100644
--- a/app/services/form_response_aggregator.rb
+++ b/app/services/form_response_aggregator.rb
@@ -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
)
@@ -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.
diff --git a/app/views/forms/_numeric_card.html.erb b/app/views/forms/_numeric_card.html.erb
new file mode 100644
index 0000000000..8373ddf175
--- /dev/null
+++ b/app/views/forms/_numeric_card.html.erb
@@ -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 %>
+
+
<%= report.label %>
+
+ <% if report.answered_count.zero? %>
+
No responses yet.
+ <% else %>
+
+
+
<%= number_with_delimiter(report.average.round(decimals)) %>
+
Average
+
+
+
<%= number_with_delimiter(report.total.round(decimals)) %>
+
Total
+
+
+
<%= number_with_delimiter(report.answered_count) %>
+
Responses
+
+
+
+
+ Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %>
+
+ <% end %>
+
diff --git a/app/views/forms/results.html.erb b/app/views/forms/results.html.erb
index 78db74ebc9..016604b528 100644
--- a/app/views/forms/results.html.erb
+++ b/app/views/forms/results.html.erb
@@ -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 %>
diff --git a/config/features.yml b/config/features.yml
index 2ae227941e..b94a49b0d8 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -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."
diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb
index bbd32233f3..ecac7efa1a 100644
--- a/spec/requests/forms_spec.rb
+++ b/spec/requests/forms_spec.rb
@@ -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)
diff --git a/spec/services/form_response_aggregator_spec.rb b/spec/services/form_response_aggregator_spec.rb
index 20f9898d13..6b20c4042d 100644
--- a/spec/services/form_response_aggregator_spec.rb
+++ b/spec/services/form_response_aggregator_spec.rb
@@ -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)