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 %>