diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index bd5bee7636..eacf2c8714 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
@@ -202,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)
@@ -224,6 +230,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/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/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/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 f89af602b9..5627886c24 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:
@@ -1958,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/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
diff --git a/spec/requests/reports_controller_spec.rb b/spec/requests/reports_controller_spec.rb
index 74f521556c..cc12be7794 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
@@ -771,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
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
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