diff --git a/app/controllers/mailings_controller.rb b/app/controllers/mailings_controller.rb
index 62a9100..4a06b8a 100644
--- a/app/controllers/mailings_controller.rb
+++ b/app/controllers/mailings_controller.rb
@@ -16,7 +16,7 @@ class MailingsController < ApplicationController
@paused_mailings = @window_paused_mailings
@completed_today = @mailings.count { |mailing| mailing.sent? && mailing.completed_at&.to_date == Time.zone.today }
@failed_open = @mailings.sum(&:failed_count)
- @hourly_throughput = Mailings::HourlyThroughput.new(current_project)
+ @hourly_throughput = Mailings::HourlyThroughput.new(current_project, range: params[:history])
end
def new
diff --git a/app/services/mailings/hourly_throughput.rb b/app/services/mailings/hourly_throughput.rb
index f9aa9ca..f7e57a6 100644
--- a/app/services/mailings/hourly_throughput.rb
+++ b/app/services/mailings/hourly_throughput.rb
@@ -1,11 +1,28 @@
class Mailings::HourlyThroughput
HOUR = 1.hour
RECENT = 15.minutes
- HISTORY_HOURS = 12
+ DEFAULT_RANGE = "12h"
+ RANGES = {
+ "12h" => { grain: :hour, periods: 12, short: "12h", label: "ultime 12 ore" },
+ "24h" => { grain: :hour, periods: 24, short: "24h", label: "ultime 24 ore" },
+ "7d" => { grain: :day, periods: 7, short: "7g", label: "ultima settimana" }
+ }.freeze
- def initialize(project, now: Time.current)
+ def self.normalize_range(value)
+ key = value.to_s
+ RANGES.key?(key) ? key : DEFAULT_RANGE
+ end
+
+ def initialize(project, now: Time.current, range: DEFAULT_RANGE)
@project = project
@now = now
+ @range_key = self.class.normalize_range(range)
+ end
+
+ attr_reader :range_key
+
+ def range
+ RANGES.fetch(@range_key)
end
def sent
@@ -24,33 +41,54 @@ class Mailings::HourlyThroughput
sent + failed
end
- def hourly_history
- @hourly_history ||= begin
- from = (@now - (HISTORY_HOURS - 1).hours).beginning_of_hour
- keyed = recipients.sent
- .where(sent_at: from..@now)
- .pluck(:sent_at)
- .each_with_object(Hash.new(0)) do |sent_at, counts|
- counts[sent_at.in_time_zone.beginning_of_hour] += 1
- end
-
- HISTORY_HOURS.times.map do |index|
- at = from + index.hours
- { at: at, count: keyed[at] || 0 }
- end
- end
+ def history
+ @history ||= buckets_for_range
end
def history_total
- hourly_history.sum { |bucket| bucket[:count] }
+ history.sum { |bucket| bucket[:count] }
end
def history_max
- [hourly_history.map { |bucket| bucket[:count] }.max, 1].max
+ [history.map { |bucket| bucket[:count] }.max, 1].max
end
private
+ def buckets_for_range
+ from, step = history_origin
+ keyed = recipients.sent
+ .where(sent_at: from..@now)
+ .pluck(:sent_at)
+ .each_with_object(Hash.new(0)) do |sent_at, counts|
+ counts[bucket_at(sent_at)] += 1
+ end
+
+ range[:periods].times.map do |index|
+ at = from + (step * index)
+ { at: at, count: keyed[at] || 0, label: bucket_label(at) }
+ end
+ end
+
+ def history_origin
+ if range[:grain] == :day
+ from = (@now.to_date - (range[:periods] - 1)).in_time_zone.beginning_of_day
+ [from, 1.day]
+ else
+ from = (@now - (range[:periods] - 1).hours).beginning_of_hour
+ [from, 1.hour]
+ end
+ end
+
+ def bucket_at(sent_at)
+ time = sent_at.in_time_zone
+ range[:grain] == :day ? time.beginning_of_day : time.beginning_of_hour
+ end
+
+ def bucket_label(at)
+ range[:grain] == :day ? at.strftime("%d/%m") : at.strftime("%H:%M")
+ end
+
def sent_since(window)
recipients.sent.where(sent_at: (@now - window)..@now).count
end
diff --git a/app/views/mailings/dashboard.html.erb b/app/views/mailings/dashboard.html.erb
index 9438549..090bd1f 100644
--- a/app/views/mailings/dashboard.html.erb
+++ b/app/views/mailings/dashboard.html.erb
@@ -42,16 +42,27 @@
-
Storico invio
-
- <% @hourly_throughput.hourly_history.each do |bucket| %>
+
+
Storico invio
+
+ <% Mailings::HourlyThroughput::RANGES.each do |key, config| %>
+ <% if @hourly_throughput.range_key == key %>
+ <%= config[:short] %>
+ <% else %>
+ <%= link_to config[:short], dashboard_mailings_path(history: key), class: "rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800" %>
+ <% end %>
+ <% end %>
+
+
+
+ <% @hourly_throughput.history.each do |bucket| %>
<% pct = (bucket[:count].to_f / @hourly_throughput.history_max * 100).round %>
"
style="height: <%= bucket[:count].positive? ? [pct, 12].max : 8 %>%"
- title="<%= bucket[:at].strftime("%H:%M") %>: <%= bucket[:count] %>">
+ title="<%= bucket[:label] %>: <%= bucket[:count] %>">
<% end %>
-
<%= @hourly_throughput.history_total %> inviate · ultime 12 ore
+
<%= @hourly_throughput.history_total %> inviate · <%= @hourly_throughput.range[:label] %>
In pausa (fascia)
diff --git a/test/controllers/mailings_controller_test.rb b/test/controllers/mailings_controller_test.rb
index a46e75f..5a7d355 100644
--- a/test/controllers/mailings_controller_test.rb
+++ b/test/controllers/mailings_controller_test.rb
@@ -36,6 +36,30 @@ class MailingsControllerTest < ActionDispatch::IntegrationTest
assert_match(/1 ultimi 15 min/, response.body)
assert_select "div", text: "Storico invio"
assert_match(/1 inviate · ultime 12 ore/, response.body)
+ assert_select "span", text: "12h"
+ assert_select "a", text: "24h"
+ assert_select "a", text: "7g"
+ end
+
+ test "dashboard history range can switch to the last week" do
+ login_as users(:admin)
+ follow_redirect! if response.redirect?
+
+ mailing = create_mailing(identity: @identity, audience: "to_send")
+ org = organizations(:acme)
+ mailing.mailing_recipients.create!(
+ organization: org,
+ contact: org.contacts.first,
+ email: org.email,
+ status: "sent",
+ sent_at: 2.days.ago
+ )
+
+ get dashboard_mailings_path(project_code: @project.code, history: "7d")
+ assert_response :success
+ assert_match(/1 inviate · ultima settimana/, response.body)
+ assert_select "span", text: "7g"
+ assert_select "a[href=?]", dashboard_mailings_path(project_code: @project.code, history: "24h")
end
test "creates a draft and opens audience filters" do
diff --git a/test/services/mailings/hourly_throughput_test.rb b/test/services/mailings/hourly_throughput_test.rb
index 1d6312b..f441850 100644
--- a/test/services/mailings/hourly_throughput_test.rb
+++ b/test/services/mailings/hourly_throughput_test.rb
@@ -55,7 +55,7 @@ class Mailings::HourlyThroughputTest < ActiveSupport::TestCase
add_recipient status: "sent", sent_at: now - 13.hours, mailing: extra_mailing("Fuori finestra")
stats = Mailings::HourlyThroughput.new(@project, now: now)
- history = stats.hourly_history
+ history = stats.history
assert_equal 12, history.size
assert_equal now.beginning_of_hour, history.last[:at]
@@ -66,6 +66,45 @@ class Mailings::HourlyThroughputTest < ActiveSupport::TestCase
end
end
+ test "includes the previous day when the range is 24 hours" do
+ now = Time.zone.parse("2026-09-07 12:40")
+ travel_to now do
+ add_recipient status: "sent", sent_at: now - 20.hours
+
+ twelve = Mailings::HourlyThroughput.new(@project, now: now, range: "12h")
+ day = Mailings::HourlyThroughput.new(@project, now: now, range: "24h")
+
+ assert_equal 0, twelve.history_total
+ assert_equal 24, day.history.size
+ assert_equal 1, day.history_total
+ end
+ end
+
+ test "groups the last week by day" do
+ now = Time.zone.parse("2026-09-07 12:40")
+ travel_to now do
+ add_recipient status: "sent", sent_at: now - 3.days
+ add_recipient status: "sent", sent_at: now - 8.days, mailing: extra_mailing("Vecchia")
+
+ stats = Mailings::HourlyThroughput.new(@project, now: now, range: "7d")
+ history = stats.history
+
+ assert_equal 7, history.size
+ assert_equal now.beginning_of_day, history.last[:at]
+ assert_equal 1, history[-4][:count]
+ assert_equal 0, history.last[:count]
+ assert_equal 1, stats.history_total
+ assert_equal "7d", stats.range_key
+ end
+ end
+
+ test "falls back to 12 hours for an unknown range" do
+ stats = Mailings::HourlyThroughput.new(@project, range: "nope")
+
+ assert_equal "12h", stats.range_key
+ assert_equal 12, stats.history.size
+ end
+
private
def extra_mailing(name = "Altra campagna")