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

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