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)