diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 2101ba228..b7ea15c28 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -508,15 +508,35 @@ class PagesController < ApplicationController current_period: current_period, previous_period: previous_period, days: axis_days, + current_days: month_end.day, axis_labels: spending_trend_axis_labels(month_start, previous_month_start, axis_days), current: current_series, previous: previous_series, current_total: Money.new(current_total, currency), previous_total: Money.new(previous_total, currency), - delta: Money.new(current_total - previous_total, currency) + delta: Money.new(current_total - previous_total, currency), + previous_label: I18n.l(previous_month_start, format: :month_year).capitalize, + date_range_short: spending_trend_compact_date_range(current_period) } end + # Compact range for narrow viewports ("Sep 01 - 6, 2026"). The period + # never spans months, so the end date only needs its day. + def spending_trend_compact_date_range(period) + date_range = period.date_range + + if date_range.begin == date_range.end + t("pages.dashboard.spending_trend.date_range_short_single", + date: I18n.l(date_range.begin, format: :short), + year: date_range.end.year) + else + t("pages.dashboard.spending_trend.date_range_short", + start_date: I18n.l(date_range.begin, format: :short), + end_day: date_range.end.day, + year: date_range.end.year) + end + end + # Localized tick labels, one per axis day. The selected month owns the # axis up to its length; when the previous month is longer, its dates # label the tail so a tick never rolls past month-end into the next month diff --git a/app/javascript/controllers/spending_chart_controller.js b/app/javascript/controllers/spending_chart_controller.js index cf9b263d3..6f1d761ca 100644 --- a/app/javascript/controllers/spending_chart_controller.js +++ b/app/javascript/controllers/spending_chart_controller.js @@ -48,6 +48,7 @@ export default class extends Controller { const height = this.element.clientHeight; const { days = 30, + current_days: currentDays = days, axis_labels: axisLabels = [], current = [], previous = [], @@ -84,7 +85,7 @@ export default class extends Controller { .range([innerHeight, 0]); this._drawGridlines(group, y, innerWidth, innerHeight); - this._drawXAxis(group, x, days, innerHeight, axisLabels); + this._drawXAxis(group, x, days, innerHeight, axisLabels, currentDays); const line = d3 .line() @@ -171,8 +172,10 @@ export default class extends Controller { .style("font-weight", "500"); } - _drawXAxis(group, x, days, innerHeight, axisLabels) { - const tickDays = [...new Set([1, Math.round((1 + days) / 2), days])]; + _drawXAxis(group, x, days, innerHeight, axisLabels, currentDays = days) { + const mobile = !window.matchMedia("(min-width: 640px)").matches; + const span = mobile ? Math.min(days, currentDays) : days; + const tickDays = [...new Set([1, Math.round((1 + span) / 2), span])]; group .append("g") diff --git a/app/views/pages/dashboard/_spending_trend.html.erb b/app/views/pages/dashboard/_spending_trend.html.erb index cd94701ed..45b25038d 100644 --- a/app/views/pages/dashboard/_spending_trend.html.erb +++ b/app/views/pages/dashboard/_spending_trend.html.erb @@ -6,16 +6,22 @@ has_data = data[:current].any? { |p| p[:value].positive? } || data[:previous].any? { |p| p[:value].positive? } chart_data = { days: data[:days], + current_days: data[:current_days], axis_labels: data[:axis_labels], current: data[:current], previous: data[:previous] } + previous_label = data[:previous_label] + date_range_short = data[:date_range_short] %>
-

+

+

+ <%= date_range_short %> +

<%= render DS::Tooltip.new(text: t(".period_scope_hint"), placement: "top-start") %> @@ -45,9 +51,9 @@

<%= I18n.l(data[:month], format: :month_year) %>

-

- <%= format_money data[:current_total] %> - "> +

+ <%= format_money data[:current_total] %> + "> <%= format_money data[:delta] %>

@@ -57,8 +63,8 @@
-

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

-

<%= format_money data[:previous_total] %>

+

<%= previous_label %>

+

<%= format_money data[:previous_total] %>

@@ -70,7 +76,7 @@ data-spending-chart-data-value="<%= chart_data.to_json %>" data-spending-chart-currency-value="<%= Current.family.currency %>" data-spending-chart-current-label-value="<%= I18n.l(data[:month], format: :month_year).capitalize %>" - data-spending-chart-previous-label-value="<%= t(".previous_month") %>"> + data-spending-chart-previous-label-value="<%= previous_label %>">
<% else %>
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 80579c5dd..3ff81fca3 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -121,7 +121,8 @@ en: spending_trend: title: "Spending" date_range: "%{start_date} to %{end_date}" + date_range_short: "%{start_date} - %{end_day}, %{year}" + date_range_short_single: "%{date}, %{year}" month_picker_aria_label: "Select month" period_scope_hint: "Shows the month you pick here — independent of the dashboard's time period at the top." - previous_month: "Previous month" no_data: "No spending data for this month" diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index f36cec015..8b307f6f3 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -382,6 +382,9 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_equal 75.0, current.last.fetch("value") assert_equal 200.0, previous.last.fetch("value") assert_equal [ selected_month.end_of_month.day, previous_month.end_of_month.day ].max, chart.fetch("days") + # The chart needs the selected month's own length to label only its days + # on narrow (mobile) widths. + assert_equal selected_month.end_of_month.day, chart.fetch("current_days") end test "dashboard spending trend widget caps an in-progress month at today" do @@ -424,6 +427,40 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_equal I18n.l(previous_month.end_of_month, format: :short), labels.last end + test "dashboard spending trend names the compared month in the comparison header" do + account = @family.accounts.create!(name: "Spending Trend Label Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + selected_month = 2.months.ago.beginning_of_month.to_date + previous_month = 3.months.ago.beginning_of_month.to_date + create_transaction(account: account, name: "Spend", amount: 10, date: selected_month) + + get root_path, params: { spending_month: selected_month.iso8601 } + + assert_response :ok + # The real month name replaces the generic "Previous month" label, which + # truncated on mobile ("Previous mon…"). + expected_label = I18n.l(previous_month, format: :month_year).capitalize + assert_select "#spending-trend-section p", text: expected_label + chart_element = css_select("[data-controller='spending-chart']").first + assert_equal expected_label, chart_element["data-spending-chart-previous-label-value"] + end + + test "dashboard spending trend renders a compact date range for mobile" do + account = @family.accounts.create!(name: "Spending Trend Range Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + selected_month = 2.months.ago.beginning_of_month.to_date + create_transaction(account: account, name: "Spend", amount: 10, date: selected_month) + + get root_path, params: { spending_month: selected_month.iso8601 } + + assert_response :ok + # Full form for wide viewports, compact form for narrow ones. + assert_select "p[class*='hidden sm:block']", + text: I18n.t("pages.dashboard.spending_trend.date_range", + start_date: I18n.l(selected_month, format: :long), + end_date: I18n.l(selected_month.end_of_month, format: :long)) + assert_select "p[class*='sm:hidden']", + text: "#{I18n.l(selected_month, format: :short)} - #{selected_month.end_of_month.day}, #{selected_month.year}" + end + test "dashboard spending trend widget clamps invalid and future month params" do account = @family.accounts.create!(name: "Spending Trend Clamp Checking", currency: @family.currency, balance: 0, accountable: Depository.new) create_transaction(account: account, name: "Today", amount: 10, date: Date.current)