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