diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 3caaf4121..34ebb3d1d 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -23,6 +23,7 @@ export default class extends Controller { todayLabel: { type: String, default: "Today" }, projectedTemplate: { type: String, default: "Projected: {amount}" }, savedTemplate: { type: String, default: "Saved: {amount}" }, + targetRelationTemplate: { type: String, default: "{percent}% of {target} target" }, }; connect() { @@ -452,7 +453,25 @@ export default class extends Controller { tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES; const tooltipValue = document.createElement("div"); tooltipValue.className = CHART_TOOLTIP_VALUE_CLASSES; - tooltip.replaceChildren(tooltipDate, tooltipValue); + // Relation line: where this value sits against the goal target. Tertiary + // so the hierarchy stays date < value > relation; hidden when the goal + // has no positive target to compare against. + const tooltipRelation = document.createElement("div"); + tooltipRelation.className = "text-xs text-subdued mt-0.5"; + tooltip.replaceChildren(tooltipDate, tooltipValue, tooltipRelation); + + const setRelation = (amount) => { + const target = Number(data.target_amount) || 0; + if (target <= 0 || !data.target_amount_short_label) { + tooltipRelation.style.display = "none"; + return; + } + const percent = Math.round((amount / target) * 100); + tooltipRelation.textContent = this.targetRelationTemplateValue + .replace("{percent}", percent) + .replace("{target}", data.target_amount_short_label); + tooltipRelation.style.display = ""; + }; const overlay = svg .append("rect") @@ -505,6 +524,7 @@ export default class extends Controller { hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null); hoverSavedDot.style("display", "none"); tooltipValue.textContent = this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency)); + setRelation(projValue); } else { // Saved segment: hoverDate is already snapped to nearest savedSeries // entry above, so reuse that entry directly instead of running @@ -513,6 +533,7 @@ export default class extends Controller { hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null); hoverProjDot.style("display", "none"); tooltipValue.textContent = this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency)); + setRelation(savedPoint.value); } tooltip.style.display = "block"; diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index 8b0d4376e..4cffaa938 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -445,7 +445,17 @@ export default class extends Controller { linkPaths .on("mouseenter", (event, d) => { applyHover([d]); - this.#showTooltip(event, d.value, d.percentage); + // A link is a flow between two named nodes — without the names the + // value floats context-free (the old tooltip showed only "$X (Y%)"). + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext( + d.source.color, + `${this.#esc(d.source.name)} → ${this.#esc(d.target.name)}`, + ), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("mouseleave", () => { @@ -466,7 +476,12 @@ export default class extends Controller { (l) => l.source === d || l.target === d, ); applyHover(connectedLinks); - this.#showTooltip(event, d.value, d.percentage, d.name); + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext(d.color, this.#esc(d.name)), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("click", (event, d) => { @@ -490,7 +505,12 @@ export default class extends Controller { (l) => l.source === d || l.target === d, ); applyHover(connectedLinks); - this.#showTooltip(event, d.value, d.percentage, d.name); + this.#showTooltip( + event, + d.value, + d.percentage, + this.#tooltipContext(d.color, this.#esc(d.name)), + ); }) .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("click", (event, d) => { @@ -517,20 +537,29 @@ export default class extends Controller { .style("pointer-events", "none"); } - #showTooltip(event, value, percentage, title = null) { + // Node names are user-named categories; escape anything interpolated into + // .html() (the previous code injected them raw). + #esc(s) { + return String(s).replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + } + + // Context line shared by node and link tooltips: a swatch dot tying the + // card to the hovered ribbon/node color, plus the (escaped) name(s). + #tooltipContext(color, label) { + const dot = ``; + return `
${dot}${label}
`; + } + + #showTooltip(event, value, percentage, contextHtml = null) { if (!this.tooltip) this.#createTooltip(); - // Node titles are user-named categories; escape them since this goes - // through .html() (the previous interpolation injected them raw). - const esc = (s) => - String(s).replace( - /[&<>"']/g, - (c) => - ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], - ); const valueLine = `${this.#formatCurrency(value)} (${percentage || 0}%)`; - const content = title - ? `
${esc(title)}
${valueLine}
` + const content = contextHtml + ? `${contextHtml}
${valueLine}
` : valueLine; const isInDialog = !!this.element.closest("dialog"); diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 21bc8ec22..92a482526 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -231,7 +231,8 @@ data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>" data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>" data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>" - data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"> + data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>" + data-goal-projection-chart-target-relation-template-value="<%= t("goals.show.projection.tooltip_target_relation", percent: "{percent}", target: "{target}") %>"> <% if @goal.target_date.nil? %>
<%= icon("calendar-plus", size: "sm") %> diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 031633272..737ddc4c0 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -152,6 +152,7 @@ en: today_marker: Today tooltip_projected: "Projected: %{amount}" tooltip_saved: "Saved: %{amount}" + tooltip_target_relation: "%{percent}% of %{target} target" catch_up: title: "Save %{amount}/mo more to catch up" body: "Current pace %{avg}/mo · required %{required}/mo to hit your target."