feat(charts): give sankey and goal tooltips their missing context rows

Chart tooltips answer three stacked questions: context (what am I
looking at), value (how much), relation (vs what). Time-series already
had all three; the other two were missing rows.

- Sankey links showed a bare "$X (Y%)" with no indication of which
  flow was hovered. Links now lead with a swatch-dotted
  "Source → Target" context line; nodes get the same dot + name
  treatment, tying the card to the ribbon color. The context builder
  escapes node names centrally (they're user-named categories).
- Goal projection adds a tertiary relation line — "52% of $20K
  target" — computed from the payload the chart already carries, for
  both the saved and projected segments. Hidden when the goal has no
  positive target. Template is i18n-wired like the existing tooltip
  strings (goals.show.projection.tooltip_target_relation).

Verified with Playwright against the running app: all three surfaces
pass computed-style and content assertions.
This commit is contained in:
Guillem Arias
2026-06-05 09:40:13 +02:00
parent 7b0799668c
commit 1011db2883
4 changed files with 68 additions and 16 deletions

View File

@@ -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";

View File

@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 = `<span class="inline-block shrink-0 rounded-full" style="width: 8px; height: 8px; background: ${this.#esc(color || "var(--color-gray-400)")};"></span>`;
return `<div class="flex items-center gap-1.5 text-xs text-secondary mb-1">${dot}<span class="min-w-0 truncate">${label}</span></div>`;
}
#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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
const valueLine = `<span class="font-mono font-medium tabular-nums">${this.#formatCurrency(value)}</span> <span class="text-secondary">(${percentage || 0}%)</span>`;
const content = title
? `<div class="text-xs text-secondary mb-1">${esc(title)}</div><div>${valueLine}</div>`
const content = contextHtml
? `${contextHtml}<div>${valueLine}</div>`
: valueLine;
const isInDialog = !!this.element.closest("dialog");