From 85a582f5bf34eda8f3e45304dc5d16b558ddec50 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Fri, 24 Apr 2026 14:26:32 +0100 Subject: [PATCH 1/4] Add new settings for total submissions Add two new settings for use when calculating the total submissions. total_submissions_baseline is number of submissions before the baseline cut off date. To calculate a total submission figure, over the lifetime of the service, we need baseline to start from. This is for a few reasons: - CloudWatch only retains data for a maximum of 15 months so we can't query it for all data. - We are only using the new CloudWatch metric name. The old name will pass through the retention window in July so it doesn't seem worth including in our stats. - We haven't always used CloudWatch so we need to add in the stats collected before it was available. These are settings because that seemed like the easiest way for us to store and update. The setting is not scoped per environment so it should only be set to the value for production. This is a limitation we might want to change in the future. --- config/settings.yml | 4 ++++ config/settings/test.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/config/settings.yml b/config/settings.yml index 3b05dc6d28..8b0e6dd89d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -109,5 +109,9 @@ act_as_user_enabled: false cloudwatch_metrics_enabled: true +# Total form submissions recorded before the cutoff date. +total_submissions_baseline: 0 +total_submissions_baseline_cutoff_date: "2025-02-01" + reports: forms_api_forms_per_request_page: 100 diff --git a/config/settings/test.yml b/config/settings/test.yml index 6a3b430b1d..a365e3c685 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -25,6 +25,8 @@ mailchimp: mou_signers_list: list-2 cloudwatch_metrics_enabled: false +total_submissions_baseline: 0 +total_submissions_baseline_cutoff_date: "2025-02-01" reports: forms_api_forms_per_request_page: 3 From e633cfd93cdd0b75c06bb43afa9f33976050bddc Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Fri, 24 Apr 2026 14:34:10 +0100 Subject: [PATCH 2/4] Add Report service for submission data To show submission metrics we query CloudWatch for all submission data between the start of the baseline period and the current time. For this service to work, we need to ensure that we have permission to run access cloudwatch:GetMetricData. This can be set in the ECS Iam policy alongside `cloudwatch:GetMetricStatistics`. We get back an array of datapoints, one for each day in the period. We then use these values to calculate daily, weekly, monthly and yearly values. --- .../total_submissions_cloud_watch_service.rb | 134 ++++++++++ ...al_submissions_cloud_watch_service_spec.rb | 237 ++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 app/services/reports/total_submissions_cloud_watch_service.rb create mode 100644 spec/services/reports/total_submissions_cloud_watch_service_spec.rb diff --git a/app/services/reports/total_submissions_cloud_watch_service.rb b/app/services/reports/total_submissions_cloud_watch_service.rb new file mode 100644 index 0000000000..fa424b9884 --- /dev/null +++ b/app/services/reports/total_submissions_cloud_watch_service.rb @@ -0,0 +1,134 @@ +class Reports::TotalSubmissionsCloudWatchService + REGION = "eu-west-2".freeze + + def submissions_data + return nil unless Settings.cloudwatch_metrics_enabled + return nil if Settings.total_submissions_baseline_cutoff_date.blank? + + datapoints = fetch_daily_datapoints + cloudwatch_total = datapoints.values.sum + baseline = Settings.total_submissions_baseline.to_i + + { + all_time: { total: baseline + cloudwatch_total }, + year: { + in_progress: year_in_progress_bucket(datapoints), + }, + month: month_buckets(datapoints), + week: week_buckets(datapoints), + day: day_buckets(datapoints), + weekly_breakdown: weekly_breakdown(datapoints), + monthly_breakdown: monthly_breakdown(datapoints), + } + rescue Aws::CloudWatch::Errors::ServiceError, + Aws::Errors::MissingCredentialsError, + ArgumentError => e + Sentry.capture_exception(e) + nil + end + +private + + def fetch_daily_datapoints + env = Settings.forms_env.downcase + expression = "SUM(SEARCH('{Forms,Environment,FormId} MetricName=\"Submitted\" Environment=\"#{env}\"', 'Sum', 86400))" + + start_time = Date.iso8601(Settings.total_submissions_baseline_cutoff_date).beginning_of_day.utc + + response = Aws::CloudWatch::Client.new(region: REGION).get_metric_data({ + metric_data_queries: [ + { + id: "total_submissions", + expression:, + label: "Total Submissions", + }, + ], + start_time: start_time, + end_time: Time.zone.now.utc, + }) + + result = response.metric_data_results.find { |r| r.id == "total_submissions" } + return {} unless result + + result.timestamps.zip(result.values).each_with_object({}) do |(timestamp, value), hash| + hash[timestamp.to_date.iso8601] = value.to_i + end + end + + def sum_dates(datapoints, start_date, end_date) + (start_date..end_date).sum { |date| datapoints[date.iso8601] || 0 } + end + + def today + Time.zone.today + end + + def day_buckets(datapoints) + yesterday = today - 1.day + { + completed: { label: yesterday.strftime("%-d %b %Y"), total: datapoints[yesterday.iso8601] || 0 }, + in_progress: { label: today.strftime("%-d %b %Y"), total: datapoints[today.iso8601] || 0 }, + } + end + + def week_buckets(datapoints) + this_week_start = today.beginning_of_week(:monday) + last_week_end = this_week_start - 1.day + last_week_start = last_week_end.beginning_of_week(:monday) + + { + completed: { label: week_label(last_week_start, last_week_end), total: sum_dates(datapoints, last_week_start, last_week_end) }, + in_progress: { label: week_label(this_week_start, today), total: sum_dates(datapoints, this_week_start, today) }, + } + end + + def month_buckets(datapoints) + this_month_start = today.beginning_of_month + last_month_end = this_month_start - 1.day + last_month_start = last_month_end.beginning_of_month + + { + completed: { label: last_month_start.strftime("%B %Y"), total: sum_dates(datapoints, last_month_start, last_month_end) }, + in_progress: { label: this_month_start.strftime("%B %Y"), total: sum_dates(datapoints, this_month_start, today) }, + } + end + + def year_in_progress_bucket(datapoints) + this_year_start = Date.new(today.year, 1, 1) + + { + label: this_year_start.year.to_s, + total: sum_dates(datapoints, this_year_start, today), + } + end + + def weekly_breakdown(datapoints) + this_week_start = today.beginning_of_week(:monday) + last_week_end = this_week_start - 1.day + + (0...52).map do |i| + week_end = last_week_end - (i * 7) + week_start = week_end - 6.days + { label: week_label(week_start, week_end), total: sum_dates(datapoints, week_start, week_end) } + end + end + + def monthly_breakdown(datapoints) + this_month_start = today.beginning_of_month + (1..12).map do |i| + month_start = this_month_start - i.months + month_end = month_start.end_of_month + { label: month_start.strftime("%B %Y"), total: sum_dates(datapoints, month_start, month_end) } + end + end + + def week_label(start_date, end_date) + if start_date.year != end_date.year + "#{start_date.strftime('%-d %b %Y')}–#{end_date.strftime('%-d %b %Y')}" + elsif start_date.month != end_date.month + "#{start_date.strftime('%-d %b')}–#{end_date.strftime('%-d %b %Y')}" + else + "#{start_date.day}–#{end_date.strftime('%-d %b %Y')}" + end + end +end diff --git a/spec/services/reports/total_submissions_cloud_watch_service_spec.rb b/spec/services/reports/total_submissions_cloud_watch_service_spec.rb new file mode 100644 index 0000000000..9dcabbee7d --- /dev/null +++ b/spec/services/reports/total_submissions_cloud_watch_service_spec.rb @@ -0,0 +1,237 @@ +require "rails_helper" + +describe Reports::TotalSubmissionsCloudWatchService do + subject(:service) { described_class.new } + + # Travel to a known Thursday so all date calculations are deterministic. + # today = 2026-04-23 (Thu) + # yesterday = 2026-04-22 (Wed) — completed day + # this week = Mon 2026-04-20 … Thu 2026-04-23 + # last week = Mon 2026-04-13 … Sun 2026-04-19 — completed week + # this month = Apr 2026 + # last month = Mar 2026 — completed month + # this year = 2026 + # last year = 2025 — completed year + around do |example| + travel_to(Time.zone.local(2026, 4, 23, 10, 0, 0)) { example.run } + end + + let(:cloud_watch_client) { Aws::CloudWatch::Client.new(stub_responses: true) } + + # The cutoff date for the baseline. All CloudWatch queries will start from this date. + let(:baseline_cutoff_date) { "2025-02-01" } # Chosen to be within the 14/15-month CloudWatch retention period from our test date. + + # Fixture datapoints. The hash below is what get_metric_data returns. + # These datapoints are now all *after* the baseline_cutoff_date. + # dates and their counts: + # 2026-04-23 => 5 (today) + # 2026-04-22 => 10 (yesterday / completed day) + # 2026-04-19 => 7 (Sun, last week end) + # 2026-04-13 => 7 (Mon, last week start) + # 2026-03-15 => 30 (in March / completed month) + # cloudwatch_total = 5+10+7+7+30 = 59 + # all_time = 100 (baseline) + 59 = 159 + + let(:fixture_timestamps) do + [ + Time.utc(2026, 4, 23), + Time.utc(2026, 4, 22), + Time.utc(2026, 4, 19), + Time.utc(2026, 4, 13), + Time.utc(2026, 3, 15), + ] + end + + let(:fixture_values) { [5.0, 10.0, 7.0, 7.0, 30.0] } + + before do + allow(Settings).to receive_messages( + cloudwatch_metrics_enabled: true, + forms_env: "test", + total_submissions_baseline_cutoff_date: baseline_cutoff_date, + total_submissions_baseline: 100, + ) + + allow(Aws::CloudWatch::Client).to receive(:new).with(region: "eu-west-2").and_return(cloud_watch_client) + allow(cloud_watch_client).to receive(:get_metric_data) + end + + def stub_cloudwatch(timestamps: fixture_timestamps, values: fixture_values) + response = cloud_watch_client.stub_data(:get_metric_data, metric_data_results: [ + { + id: "total_submissions", + label: "Total Submissions", + timestamps:, + values:, + status_code: "Complete", + }, + ]) + allow(cloud_watch_client).to receive(:get_metric_data).and_return(response) + end + + describe "#submissions_data" do + context "when cloudwatch_metrics_enabled is false" do + before { allow(Settings).to receive(:cloudwatch_metrics_enabled).and_return(false) } + + it "returns nil without calling CloudWatch" do + expect(service.submissions_data).to be_nil + expect(cloud_watch_client).not_to have_received(:get_metric_data) + end + end + + context "when the baseline cutoff date setting is missing" do + before { allow(Settings).to receive(:total_submissions_baseline_cutoff_date).and_return(nil) } + + it "returns nil" do + expect(service.submissions_data).to be_nil + expect(cloud_watch_client).not_to have_received(:get_metric_data) + end + end + + context "when CloudWatch raises a ServiceError" do + before do + allow(cloud_watch_client).to receive(:get_metric_data) + .and_raise(Aws::CloudWatch::Errors::ServiceError.new(nil, "CloudWatch error")) + end + + it "captures the exception to Sentry and returns nil" do + expect(Sentry).to receive(:capture_exception) + expect(service.submissions_data).to be_nil + end + end + + context "when CloudWatch raises MissingCredentialsError" do + before do + allow(cloud_watch_client).to receive(:get_metric_data) + .and_raise(Aws::Errors::MissingCredentialsError) + end + + it "captures the exception to Sentry and returns nil" do + expect(Sentry).to receive(:capture_exception) + expect(service.submissions_data).to be_nil + end + end + + context "with fixture datapoints" do + before { stub_cloudwatch } + + it "uses the correct SEARCH expression with a fixed start_time from settings" do + service.submissions_data + + expected_start_time = Date.iso8601(baseline_cutoff_date).beginning_of_day.utc + expect(cloud_watch_client).to have_received(:get_metric_data).with( + hash_including( + metric_data_queries: [ + hash_including( + id: "total_submissions", + expression: satisfy do |e| + e.include?("Forms") && + e.include?('"Submitted"') && + e.include?('Environment="test"') + end, + ), + ], + start_time: expected_start_time, + ), + ) + end + + it "returns the all_time total as baseline plus CloudWatch sum" do + result = service.submissions_data + expect(result[:all_time][:total]).to eq 159 # 100 baseline + 59 cloudwatch + end + + describe "day buckets" do + it "returns yesterday as the completed day" do + result = service.submissions_data + expect(result[:day][:completed]).to eq({ label: "22 Apr 2026", total: 10 }) + end + + it "returns today as the in-progress day" do + result = service.submissions_data + expect(result[:day][:in_progress]).to eq({ label: "23 Apr 2026", total: 5 }) + end + end + + describe "week buckets" do + it "returns last Mon–Sun as the completed week" do + result = service.submissions_data + expect(result[:week][:completed]).to eq({ label: "13–19 Apr 2026", total: 14 }) + end + + it "returns this Mon–today as the in-progress week" do + result = service.submissions_data + # Apr 20 and Apr 21 have no data (0), Apr 22=10, Apr 23=5 + expect(result[:week][:in_progress]).to eq({ label: "20–23 Apr 2026", total: 15 }) + end + end + + describe "month buckets" do + it "returns last calendar month as completed" do + result = service.submissions_data + expect(result[:month][:completed]).to eq({ label: "March 2026", total: 30 }) + end + + it "returns this calendar month as in-progress" do + result = service.submissions_data + # Apr 13=7, Apr 19=7, Apr 22=10, Apr 23=5 (Apr 1-12, 14-18, 20-21 have no data) + expect(result[:month][:in_progress]).to eq({ label: "April 2026", total: 29 }) + end + end + + describe "year buckets" do + it "returns this calendar year as in-progress" do + result = service.submissions_data + # Mar 15=30, Apr 13=7, Apr 19=7, Apr 22=10, Apr 23=5 + expect(result[:year][:in_progress]).to eq({ label: "2026", total: 59 }) + end + end + + describe "weekly_breakdown" do + it "returns 52 entries" do + result = service.submissions_data + expect(result[:weekly_breakdown].length).to eq 52 + end + + it "puts the most recent completed week first" do + result = service.submissions_data + expect(result[:weekly_breakdown].first).to eq({ label: "13–19 Apr 2026", total: 14 }) + end + + it "puts the oldest completed week last" do + result = service.submissions_data + # 52nd week end = Apr 19 - 51*7 days = Apr 19 - 357 days = Apr 27, 2025 + expect(result[:weekly_breakdown].last[:label]).to eq "21–27 Apr 2025" + end + end + + describe "monthly_breakdown" do + it "returns 12 entries" do + result = service.submissions_data + expect(result[:monthly_breakdown].length).to eq 12 + end + + it "puts the most recent completed month first" do + result = service.submissions_data + expect(result[:monthly_breakdown].first).to eq({ label: "March 2026", total: 30 }) + end + + it "puts the oldest completed month last" do + result = service.submissions_data + expect(result[:monthly_breakdown].last[:label]).to eq "April 2025" + end + end + + context "when no datapoints are returned" do + before { stub_cloudwatch(timestamps: [], values: []) } + + it "returns zeros for all totals" do + result = service.submissions_data + expect(result[:all_time][:total]).to eq 100 # baseline only + expect(result[:day][:completed][:total]).to eq 0 + expect(result[:week][:completed][:total]).to eq 0 + end + end + end + end +end From b6ab3aee3a955bef89e259fa7a1483987ee01245 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Fri, 24 Apr 2026 14:57:27 +0100 Subject: [PATCH 3/4] Add total submissions feature report Add a new value to the features report for the live-or-archived tag. It shows the total number of submissions. --- app/controllers/reports_controller.rb | 13 ++++- app/views/reports/features.html.erb | 6 +++ config/locales/en.yml | 2 + spec/requests/reports_controller_spec.rb | 54 ++++++++++++++++++++ spec/views/reports/features.html.erb_spec.rb | 3 +- 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index bd5bee7636..6d4edea2b1 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -8,8 +8,9 @@ def features tag = params[:tag] forms = Reports::FormDocumentsService.form_documents(tag:) data = Reports::FeatureReportService.new(forms).report + total_submissions = total_submissions_for_features(tag) - render template: "reports/features", locals: { tag:, data: } + render template: "reports/features", locals: { tag:, data:, total_submissions: } end def questions_with_answer_type @@ -224,6 +225,16 @@ def forms_feature_report(tag, report, forms, type: :forms) end end + def total_submissions_for_features(tag) + return nil unless tag == "live-or-archived" + + submissions_data = Reports::TotalSubmissionsCloudWatchService.new.submissions_data + { + total: submissions_data&.dig(:all_time, :total), + available: !submissions_data.nil?, + } + end + def check_user_has_permission authorize :report, :can_view_reports? end diff --git a/app/views/reports/features.html.erb b/app/views/reports/features.html.erb index 2d9a91984b..0321284445 100644 --- a/app/views/reports/features.html.erb +++ b/app/views/reports/features.html.erb @@ -10,6 +10,12 @@ <%= row.with_key(text: t(".features.total_forms", tag: tag_label(tag)).upcase_first) %> <%= row.with_value(text: data[:total_forms]) %> <% end %> + <% if total_submissions %> + <%= summary_list.with_row do |row| %> + <%= row.with_key(text: t(".features.total_submissions").upcase_first) %> + <%= row.with_value(text: total_submissions[:available] ? total_submissions[:total] : t(".features.total_submissions_unavailable")) %> + <% end %> + <% end %> <%= summary_list.with_row do |row| %> <%= row.with_key(text: t(".features.copied_forms", tag: tag_label(tag)).upcase_first) %> <%= row.with_value(text: govuk_link_to(data[:copied_forms], report_forms_that_are_copies_path, no_visited_state: true)) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index f89af602b9..ebdc758e89 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1836,6 +1836,8 @@ en: forms_with_welsh_translation: "%{tag} forms with Welsh translation" heading: Feature usage total_forms: Total %{tag} forms + total_submissions: Total submissions (all time) + total_submissions_unavailable: Data unavailable title: Feature and answer type usage in %{tag} forms form_or_questions_list_table: headings: diff --git a/spec/requests/reports_controller_spec.rb b/spec/requests/reports_controller_spec.rb index 74f521556c..6edaed4ae4 100644 --- a/spec/requests/reports_controller_spec.rb +++ b/spec/requests/reports_controller_spec.rb @@ -64,6 +64,60 @@ expect(node).to have_xpath "//dl/div[1]/dd", text: "4" end end + + context "when the tag is live-or-archived and CloudWatch data is available" do + let(:path) { report_features_path(tag: "live-or-archived") } + + before do + login_as_super_admin_user + allow(Reports::TotalSubmissionsCloudWatchService).to receive(:new).and_return( + instance_double( + Reports::TotalSubmissionsCloudWatchService, + submissions_data: { all_time: { total: 42_000 } }, + ), + ) + get path + end + + it "includes a total submissions row with the all-time total" do + expect(response).to have_http_status(:ok) + node = Capybara.string(response.body) + expect(node).to have_text I18n.t("reports.features.features.total_submissions") + expect(node).to have_text "42000" + end + end + + context "when the tag is live-or-archived and CloudWatch data is unavailable" do + let(:path) { report_features_path(tag: "live-or-archived") } + + before do + login_as_super_admin_user + allow(Reports::TotalSubmissionsCloudWatchService).to receive(:new).and_return( + instance_double(Reports::TotalSubmissionsCloudWatchService, submissions_data: nil), + ) + get path + end + + it "shows Data unavailable in the total submissions row" do + node = Capybara.string(response.body) + expect(node).to have_text I18n.t("reports.features.features.total_submissions") + expect(node).to have_text I18n.t("reports.features.features.total_submissions_unavailable") + end + end + + context "when the tag is live" do + let(:path) { report_features_path(tag: :live) } + + before do + login_as_super_admin_user + get path + end + + it "does not include a total submissions row" do + node = Capybara.string(response.body) + expect(node).not_to have_text I18n.t("reports.features.features.total_submissions") + end + end end describe "#questions_with_answer_type" do diff --git a/spec/views/reports/features.html.erb_spec.rb b/spec/views/reports/features.html.erb_spec.rb index 9bce6cf019..b3686ae1d0 100644 --- a/spec/views/reports/features.html.erb_spec.rb +++ b/spec/views/reports/features.html.erb_spec.rb @@ -40,11 +40,12 @@ } end let(:tag) { "live" } + let(:total_submissions) { nil } before do controller.request.path_parameters[:tag] = tag - render template: "reports/features", locals: { tag:, data: report } + render template: "reports/features", locals: { tag:, data: report, total_submissions: } end describe "page title" do From f92387ad1472cf806e4025d129eebf9d02b74e0b Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Fri, 24 Apr 2026 14:59:09 +0100 Subject: [PATCH 4/4] Add new total submissions report Add a new report which shows stats for submissions. --- app/controllers/reports_controller.rb | 5 ++ app/views/reports/index.html.erb | 1 + app/views/reports/total_submissions.html.erb | 86 ++++++++++++++++++++ config/locales/en.yml | 17 ++++ config/routes.rb | 1 + spec/requests/reports_controller_spec.rb | 71 ++++++++++++++++ spec/routing/reports_routing_spec.rb | 6 ++ 7 files changed, 187 insertions(+) create mode 100644 app/views/reports/total_submissions.html.erb diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 6d4edea2b1..eacf2c8714 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -203,6 +203,11 @@ def contact_for_research render locals: { data: } end + def total_submissions + data = Reports::TotalSubmissionsCloudWatchService.new.submissions_data + render locals: { data: } + end + private def questions_feature_report(tag, report, questions, type: :questions) diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index c56b8d809f..8f176f60ca 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -12,6 +12,7 @@
  • <%= govuk_link_to t("reports.last_signed_in_at.title"), report_last_signed_in_at_path %>
  • <%= govuk_link_to t("reports.csv_downloads.title"), report_csv_downloads_path %>
  • <%= govuk_link_to t("reports.contact_for_research.title"), report_contact_for_research_path %>
  • +
  • <%= govuk_link_to t("reports.total_submissions.title"), report_total_submissions_path %>
  • diff --git a/app/views/reports/total_submissions.html.erb b/app/views/reports/total_submissions.html.erb new file mode 100644 index 0000000000..5569dd29f0 --- /dev/null +++ b/app/views/reports/total_submissions.html.erb @@ -0,0 +1,86 @@ +<% set_page_title(t(".title")) %> +<% content_for :back_link, govuk_back_link_to(reports_path, t("reports.back_link")) %> +
    +
    +

    <%= t(".title") %>

    + + <% if data.nil? %> +
    + <%= t(".error_loading_data") %> +
    + <% else %> + <%= govuk_table do |table| %> + <%= table.with_caption(size: "m", text: t(".summary.heading")) %> + <%= table.with_head do |head| %> + <%= head.with_row do |row| %> + <%= row.with_cell(text: t(".summary.period")) %> + <%= row.with_cell(text: t(".summary.count"), numeric: true) %> + <% end %> + <% end %> + <%= table.with_body do |body| %> + <% [:day, :year].each do |period| %> + <%= body.with_row do |row| %> + <%= row.with_cell(header: true, text: "#{data[period][:in_progress][:label]} #{t(".summary.in_progress_suffix")}") %> + <%= row.with_cell(text: data[period][:in_progress][:total], numeric: true) %> + <% end %> + <% if data[period][:completed].present? %> + <%= body.with_row do |row| %> + <%= row.with_cell(header: true, text: data[period][:completed][:label]) %> + <%= row.with_cell(text: data[period][:completed][:total], numeric: true) %> + <% end %> + <% end %> + <% end %> + <%= body.with_row do |row| %> + <%= row.with_cell(header: true, text: t(".summary.all_time")) %> + <%= row.with_cell(text: data[:all_time][:total], numeric: true) %> + <% end %> + <% end %> + <% end %> + + <%= govuk_table do |table| %> + <%= table.with_caption(size: "m", text: t(".weekly_breakdown.heading")) %> + <%= table.with_head do |head| %> + <%= head.with_row do |row| %> + <%= row.with_cell(text: t(".weekly_breakdown.week")) %> + <%= row.with_cell(text: t(".weekly_breakdown.count"), numeric: true) %> + <% end %> + <% end %> + <%= table.with_body do |body| %> + <%= body.with_row do |row| %> + <%= row.with_cell(header: true, text: "#{data[:week][:in_progress][:label]} #{t(".summary.in_progress_suffix")}") %> + <%= row.with_cell(text: data[:week][:in_progress][:total], numeric: true) %> + <% end %> + + <% data[:weekly_breakdown].each do |week| %> + <%= body.with_row do |row| %> + <%= row.with_cell(text: week[:label]) %> + <%= row.with_cell(text: week[:total], numeric: true) %> + <% end %> + <% end %> + <% end %> + <% end %> + + <%= govuk_table do |table| %> + <%= table.with_caption(size: "m", text: t(".monthly_breakdown.heading")) %> + <%= table.with_head do |head| %> + <%= head.with_row do |row| %> + <%= row.with_cell(text: t(".monthly_breakdown.month")) %> + <%= row.with_cell(text: t(".monthly_breakdown.count"), numeric: true) %> + <% end %> + <% end %> + <%= table.with_body do |body| %> + <%= body.with_row do |row| %> + <%= row.with_cell(header: true, text: "#{data[:month][:in_progress][:label]} #{t(".summary.in_progress_suffix")}") %> + <%= row.with_cell(text: data[:month][:in_progress][:total], numeric: true) %> + <% end %> + <% data[:monthly_breakdown].each do |month| %> + <%= body.with_row do |row| %> + <%= row.with_cell(text: month[:label]) %> + <%= row.with_cell(text: month[:total], numeric: true) %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +
    +
    diff --git a/config/locales/en.yml b/config/locales/en.yml index ebdc758e89..5627886c24 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1960,6 +1960,23 @@ en: draft: draft live: live live-or-archived: live or archived + total_submissions: + error_loading_data: Sorry, the total submissions data could not be loaded. Please try again later. + monthly_breakdown: + count: Total submissions + heading: Monthly breakdown + month: Month + summary: + all_time: All time + count: Total submissions + heading: Submission totals + in_progress_suffix: "(so far)" + period: Period + title: Total form submissions + weekly_breakdown: + count: Total submissions + heading: Weekly breakdown + week: Week users: heading: Number of users per organisation table_headings: diff --git a/config/routes.rb b/config/routes.rb index 349a903565..c9f8dc7d71 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -279,6 +279,7 @@ get "live-forms-csv", to: "reports#live_forms_csv", as: :report_live_forms_csv get "live-questions-csv", to: "reports#live_questions_csv", as: :report_live_questions_csv get "contact-for-research", to: "reports#contact_for_research", as: :report_contact_for_research + get "total-submissions", to: "reports#total_submissions", as: :report_total_submissions end scope "api/v2", as: "api_v2" do diff --git a/spec/requests/reports_controller_spec.rb b/spec/requests/reports_controller_spec.rb index 6edaed4ae4..cc12be7794 100644 --- a/spec/requests/reports_controller_spec.rb +++ b/spec/requests/reports_controller_spec.rb @@ -825,4 +825,75 @@ end end end + + describe "#total_submissions" do + let(:path) { report_total_submissions_path } + + include_examples "unauthorized user is forbidden" + + context "when the user is a super admin" do + before { login_as_super_admin_user } + + context "when CloudWatch data is available" do + let(:submissions_data) do + { + all_time: { total: 14_800 }, + year: { + completed: { label: "2025", total: 12_000 }, + in_progress: { label: "2026", total: 2_800 }, + }, + month: { + completed: { label: "March 2026", total: 1_200 }, + in_progress: { label: "April 2026", total: 300 }, + }, + week: { + completed: { label: "14–20 Apr 2026", total: 310 }, + in_progress: { label: "21–27 Apr 2026", total: 42 }, + }, + day: { + completed: { label: "22 Apr 2026", total: 85 }, + in_progress: { label: "23 Apr 2026", total: 12 }, + }, + weekly_breakdown: Array.new(52) { { label: "1–7 Apr 2026", total: 10 } }, + monthly_breakdown: Array.new(12) { { label: "March 2026", total: 100 } }, + } + end + + before do + allow(Reports::TotalSubmissionsCloudWatchService).to receive(:new).and_return( + instance_double(Reports::TotalSubmissionsCloudWatchService, submissions_data:), + ) + get path + end + + it "returns 200 and renders the total_submissions template" do + expect(response).to have_http_status(:ok) + expect(response).to render_template("reports/total_submissions") + end + + it "shows the all-time total" do + expect(response.body).to include "14800" + end + + it "shows the completed day label" do + expect(response.body).to include "22 Apr 2026" + end + end + + context "when CloudWatch data is unavailable" do + before do + allow(Reports::TotalSubmissionsCloudWatchService).to receive(:new).and_return( + instance_double(Reports::TotalSubmissionsCloudWatchService, submissions_data: nil), + ) + get path + end + + it "returns 200 and shows an error message" do + expect(response).to have_http_status(:ok) + expect(response).to render_template("reports/total_submissions") + expect(response.body).to include I18n.t("reports.total_submissions.error_loading_data") + end + end + end + end end diff --git a/spec/routing/reports_routing_spec.rb b/spec/routing/reports_routing_spec.rb index a724e3086e..a21de759f7 100644 --- a/spec/routing/reports_routing_spec.rb +++ b/spec/routing/reports_routing_spec.rb @@ -130,4 +130,10 @@ end end end + + describe "total submissions report" do + it "routes GET /reports/total-submissions to reports#total_submissions" do + expect(get: "/reports/total-submissions").to route_to("reports#total_submissions") + end + end end