diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 5bd3ec0cc..2a2574794 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -473,6 +473,12 @@ class PagesController < ApplicationController { date: month_start, label: I18n.l(month_start, format: :short_month_year), + # Fallback for the axis when the full label does not fit its band. + # `short_month_year` is only short in some locales — "Mar 2026" in + # English, but "Mar de 2026" in ca/es/pt, which is wide enough to + # collide with its neighbours on a phone. The bar chart measures the + # rendered labels and drops to this when they overlap. + short_label: I18n.l(month_start, format: "%b"), income: totals.income_money.amount.to_f.round(2), expense: totals.expense_money.amount.to_f.round(2), highlighted: month_start == selected_month, diff --git a/app/javascript/controllers/bar_chart_controller.js b/app/javascript/controllers/bar_chart_controller.js index 673030ad5..cbe96848b 100644 --- a/app/javascript/controllers/bar_chart_controller.js +++ b/app/javascript/controllers/bar_chart_controller.js @@ -7,6 +7,10 @@ import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; // Modeled after time_series_chart_controller's lifecycle (install/teardown, // ResizeObserver, turbo:load reinstall, page-relative tooltip positioning) // but with scaleBand/scaleLinear instead of a line. + +// Breathing room between neighbouring month labels before they read as touching. +const LABEL_GAP_PX = 8; + export default class extends Controller { static values = { data: Array, @@ -120,7 +124,7 @@ export default class extends Controller { .on("mousemove", (event, d) => showTooltip(event, d.month, d.key)) .on("mouseleave", hideTooltip); - group + const axisLabels = group .append("g") .attr("transform", `translate(0,${innerHeight})`) .call(d3.axisBottom(x0).tickSize(0)) @@ -129,6 +133,35 @@ export default class extends Controller { .attr("class", (_d, i) => (data[i].highlighted ? "text-primary fill-current" : "text-secondary fill-current")) .style("font-size", "12px") .style("font-weight", (_d, i) => (data[i].highlighted ? 600 : 500)); + + this._fitAxisLabels(axisLabels, data, x0.step()); + } + + // The month labels are sized by the locale, not by the chart: "Mar 2026" fits + // a phone, "Mar de 2026" (ca/es/pt) does not and overlaps its neighbours. + // Measure what actually rendered and step down until it fits — full label, + // then the abbreviated month, then every other tick. Measuring beats guessing + // at a character width, which varies by locale, font and zoom. + _fitAxisLabels(labels, data, step) { + if (labels.empty()) return; + + const widest = () => d3.max(labels.nodes(), (node) => node.getComputedTextLength()) || 0; + const fits = () => widest() <= step - LABEL_GAP_PX; + + if (fits()) return; + + labels.text((_d, i) => data[i].short_label ?? data[i].label); + if (fits()) return; + + // Still too wide (a very narrow column): thin out rather than overlap. + // The parity is taken from the highlighted month rather than fixed at even, + // so that month survives without being an exception to the pattern — it + // sits last (build_money_flow_data counts down to the selected month), and + // keeping it on top of every even index left the final two labels one step + // apart, the very spacing that had just been measured as too tight. + const highlightedIndex = data.findIndex((d) => d.highlighted); + const keepParity = highlightedIndex >= 0 ? highlightedIndex % 2 : 0; + labels.style("display", (_d, i) => (i % 2 === keepParity ? null : "none")); } _tooltipTemplate(month, key) { diff --git a/app/views/pages/dashboard/_money_flow.html.erb b/app/views/pages/dashboard/_money_flow.html.erb index fb1b5de49..1d8bd490b 100644 --- a/app/views/pages/dashboard/_money_flow.html.erb +++ b/app/views/pages/dashboard/_money_flow.html.erb @@ -114,8 +114,12 @@ <%= t(".income") %> + <%# text-sm to match the row's own label and the equivalent figure in the + outflows widget. Without it these inherited the 16px base, which is + off the scale the dashboard uses (sm for rows, lg for a total, 3xl + for a hero) and left the amount larger than the label beside it. %> - <%= format_money money_flow_data[:income] %> + <%= format_money money_flow_data[:income] %> <%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %> <% end %> @@ -128,7 +132,7 @@ <%= t(".expenses") %> - <%= format_money money_flow_data[:expense] %> + <%= format_money money_flow_data[:expense] %> <%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %> <% end %>