From baa5f11b3063db9353344df8d94d80b812976a3a Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 20:35:20 +0200 Subject: [PATCH] feat(goals/chart): y-axis labels + horizontal gridlines Chart had no value anchor on the y-axis; users had to read the target line label to know what amount the saved line represented. Add 3 right-aligned y-ticks ($0, $25K, $50K-style K/M shorthand) plus faint borderSubdued gridlines at the same y values. Left margin widens to 44 when room allows. Mobile (<320px chart inner-width) keeps the original tight 16px left margin and skips the y-axis entirely so the short-window readout stays uncluttered. Verified live: desktop reads $0/$20K/$40K + Target $50,000; 375px viewport drops the y-axis text + keeps target line + x-ticks only. --- .../goal_projection_chart_controller.js | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index ecbe052f3..e32a90a73 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -59,7 +59,10 @@ export default class extends Controller { const borderSubdued = isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.10)"; const containerBg = isDark ? "#0a0a0a" : "#ffffff"; - const margin = { top: 28, right: 24, bottom: 28, left: 16 }; + // Reserve gutter for y-axis labels when there's room. Mobile (< 320) + // keeps the tighter left margin and skips the y-axis entirely. + const yAxisVisible = width - 16 - 24 >= 320; + const margin = { top: 28, right: 24, bottom: 28, left: yAxisVisible ? 44 : 16 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; @@ -123,6 +126,28 @@ export default class extends Controller { gradient.append("stop").attr("offset", "0%").attr("stop-color", textPrimary).attr("stop-opacity", 0.22); gradient.append("stop").attr("offset", "100%").attr("stop-color", textPrimary).attr("stop-opacity", 0); + if (yAxisVisible) { + const yTicks = y.ticks(3); + yTicks.forEach((tickValue) => { + svg + .append("line") + .attr("x1", margin.left) + .attr("x2", margin.left + innerWidth) + .attr("y1", y(tickValue)) + .attr("y2", y(tickValue)) + .attr("stroke", borderSubdued) + .attr("stroke-width", 1); + svg + .append("text") + .attr("x", margin.left - 6) + .attr("y", y(tickValue) + 3) + .attr("text-anchor", "end") + .attr("font-size", 10) + .attr("fill", textSecondary) + .text(this._fmtMoneyShort(tickValue, data.currency)); + }); + } + if (targetAmount > 0) { svg .append("line")