mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* Add cumulative spending chart dashboard widget New dashboard section showing the selected month's running spending total against the previous month's full curve on a shared day-of-month axis, inspired by Copilot Money's Spending card. - IncomeStatement#daily_expense_series: per-day expense totals in family currency, same scoping as the other income statement totals (visible, posted, budget-included transactions; report-included accounts; daily exchange-rate conversion) - PagesController: spending_trend section with month picker (clamped like money_flow), cumulative series builder, delta vs. previous month - spending-chart Stimulus controller (D3): previous month in gray, current month in green with a today marker, gridlines with compact currency labels, shared tooltip - i18n (en) and model/controller tests * Address PR review: locale-safe axis labels, currency/rate-aware cache key - X-axis tick labels are now rendered server-side (I18n.l), one per axis day: when the previous month is longer than the selected one it owns the tail labels, so a tick can no longer roll past the selected month's end (e.g. day 31 of a February view showed "Mar 3"), and labels follow the app locale instead of D3's default English time-format locale. - IncomeStatement#daily_expense_series cache key now includes the family currency and the latest exchange-rate timestamp, since ExchangeRate:: Importer's upsert_all and currency changes leave entries/accounts untouched and previously served stale chart data. * Fix spending trend tests: empty-state month in axis test, dropped start_date key - Axis-label test picked a month pair with no transactions, so the widget rendered its empty state and there was no chart payload to parse; seed spending in both months under test. - The clamp test still asserted on the payload's removed start_date key; assert the clamped month via the current series' first point date instead.
328 lines
9.7 KiB
JavaScript
328 lines
9.7 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
import * as d3 from "d3";
|
|
import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip";
|
|
|
|
// Cumulative spending chart for the dashboard "spending" widget: the selected
|
|
// month's running total (green, ending today while the month is in progress)
|
|
// overlaid on the previous month's complete curve (gray), sharing one
|
|
// day-of-month axis. Lifecycle mirrors bar_chart/time_series_chart
|
|
// (install/teardown, ResizeObserver, turbo:load reinstall, page-relative
|
|
// tooltip positioning).
|
|
const CURRENT_COLOR = "var(--color-success)";
|
|
const PREVIOUS_COLOR = "var(--color-gray-400)";
|
|
|
|
export default class extends Controller {
|
|
static values = {
|
|
data: Object,
|
|
currency: { type: String, default: "USD" },
|
|
currentLabel: { type: String, default: "Current" },
|
|
previousLabel: { type: String, default: "Previous" },
|
|
};
|
|
|
|
_resizeObserver = null;
|
|
|
|
connect() {
|
|
this._install();
|
|
document.addEventListener("turbo:load", this._reinstall);
|
|
this._resizeObserver = new ResizeObserver(() => this._reinstall());
|
|
this._resizeObserver.observe(this.element);
|
|
}
|
|
|
|
disconnect() {
|
|
this._teardown();
|
|
document.removeEventListener("turbo:load", this._reinstall);
|
|
this._resizeObserver?.disconnect();
|
|
}
|
|
|
|
_reinstall = () => {
|
|
this._teardown();
|
|
this._install();
|
|
};
|
|
|
|
_teardown() {
|
|
d3.select(this.element).selectAll("*").remove();
|
|
}
|
|
|
|
_install() {
|
|
const width = this.element.clientWidth;
|
|
const height = this.element.clientHeight;
|
|
const {
|
|
days = 30,
|
|
axis_labels: axisLabels = [],
|
|
current = [],
|
|
previous = [],
|
|
} = this.dataValue || {};
|
|
|
|
if (width < 50 || height < 50) return;
|
|
if (current.length === 0 && previous.length === 0) return;
|
|
|
|
// Room on the right for the axis labels ($0, $1.5K, …), like the
|
|
// reference design; the curves run to the axis, not the card edge.
|
|
const margin = { top: 8, right: 48, bottom: 20, left: 4 };
|
|
const innerWidth = width - margin.left - margin.right;
|
|
const innerHeight = height - margin.top - margin.bottom;
|
|
|
|
const svg = d3
|
|
.select(this.element)
|
|
.append("svg")
|
|
.attr("width", width)
|
|
.attr("height", height)
|
|
.attr("viewBox", [0, 0, width, height]);
|
|
|
|
const group = svg
|
|
.append("g")
|
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
|
|
|
const maxValue = d3.max([...current, ...previous], (d) => d.value) || 0;
|
|
if (maxValue <= 0) return;
|
|
|
|
const x = d3.scaleLinear().domain([1, days]).range([0, innerWidth]);
|
|
const y = d3
|
|
.scaleLinear()
|
|
.domain([0, maxValue * 1.05])
|
|
.nice()
|
|
.range([innerHeight, 0]);
|
|
|
|
this._drawGridlines(group, y, innerWidth, innerHeight);
|
|
this._drawXAxis(group, x, days, innerHeight, axisLabels);
|
|
|
|
const line = d3
|
|
.line()
|
|
.x((d) => x(d.day))
|
|
.y((d) => y(d.value))
|
|
.curve(d3.curveMonotoneX);
|
|
|
|
// Previous month first so the current month always draws on top.
|
|
if (previous.length > 0) {
|
|
group
|
|
.append("path")
|
|
.datum(previous)
|
|
.attr("fill", "none")
|
|
.attr("stroke", PREVIOUS_COLOR)
|
|
.attr("stroke-width", 1.5)
|
|
.attr("stroke-linejoin", "round")
|
|
.attr("stroke-linecap", "round")
|
|
.attr("d", line);
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
group
|
|
.append("path")
|
|
.datum(current)
|
|
.attr("fill", "none")
|
|
.attr("stroke", CURRENT_COLOR)
|
|
.attr("stroke-width", 2)
|
|
.attr("stroke-linejoin", "round")
|
|
.attr("stroke-linecap", "round")
|
|
.attr("d", line);
|
|
|
|
// Endpoint dot marks "today" on the in-progress curve.
|
|
const last = current[current.length - 1];
|
|
group
|
|
.append("circle")
|
|
.attr("cx", x(last.day))
|
|
.attr("cy", y(last.value))
|
|
.attr("r", 3.5)
|
|
.attr("fill", CURRENT_COLOR);
|
|
}
|
|
|
|
this._installTooltip(
|
|
group,
|
|
x,
|
|
y,
|
|
current,
|
|
previous,
|
|
innerWidth,
|
|
innerHeight,
|
|
);
|
|
}
|
|
|
|
_drawGridlines(group, y, innerWidth, innerHeight) {
|
|
const ticks = y.ticks(4);
|
|
|
|
group
|
|
.append("g")
|
|
.selectAll("line")
|
|
.data(ticks)
|
|
.join("line")
|
|
.attr("x1", 0)
|
|
.attr("x2", innerWidth)
|
|
.attr("y1", (d) => y(d))
|
|
.attr("y2", (d) => y(d))
|
|
.attr("stroke", "var(--color-gray-300)")
|
|
.attr("stroke-dasharray", "4, 4")
|
|
.attr("stroke-opacity", 0.6);
|
|
|
|
group
|
|
.append("g")
|
|
.attr("transform", `translate(${innerWidth},0)`)
|
|
.call(
|
|
d3
|
|
.axisRight(y)
|
|
.tickValues(ticks)
|
|
.tickSize(0)
|
|
.tickPadding(8)
|
|
.tickFormat((d) => this._formatCompact(d)),
|
|
)
|
|
.call((g) => g.select(".domain").remove())
|
|
.selectAll("text")
|
|
.attr("class", "text-secondary fill-current")
|
|
.style("font-size", "12px")
|
|
.style("font-weight", "500");
|
|
}
|
|
|
|
_drawXAxis(group, x, days, innerHeight, axisLabels) {
|
|
const tickDays = [...new Set([1, Math.round((1 + days) / 2), days])];
|
|
|
|
group
|
|
.append("g")
|
|
.attr("transform", `translate(0,${innerHeight})`)
|
|
.call(
|
|
d3
|
|
.axisBottom(x)
|
|
.tickValues(tickDays)
|
|
.tickSize(0)
|
|
.tickPadding(8)
|
|
// Labels come pre-localized from the server (one per axis day), so
|
|
// ticks follow the app's locale instead of D3's default English
|
|
// one and never roll past the selected month's end.
|
|
.tickFormat((day) => axisLabels[day - 1] ?? String(day)),
|
|
)
|
|
.call((g) => g.select(".domain").remove())
|
|
.selectAll("text")
|
|
.attr("class", "text-secondary fill-current")
|
|
.style("font-size", "12px")
|
|
.style("font-weight", "500")
|
|
.attr("text-anchor", (d) =>
|
|
d === 1 ? "start" : d === days ? "end" : "middle",
|
|
);
|
|
}
|
|
|
|
_installTooltip(group, x, y, current, previous, innerWidth, innerHeight) {
|
|
const tooltip = d3
|
|
.select(this.element)
|
|
.append("div")
|
|
.attr("class", `${CHART_TOOLTIP_CLASSES} opacity-0 top-0`);
|
|
|
|
const currentByDay = new Map(current.map((d) => [d.day, d]));
|
|
const previousByDay = new Map(previous.map((d) => [d.day, d]));
|
|
const hoverDays = [
|
|
...new Set([...currentByDay.keys(), ...previousByDay.keys()]),
|
|
].sort((a, b) => a - b);
|
|
|
|
const bisectDay = d3.bisector((d) => d).center;
|
|
|
|
group
|
|
.append("rect")
|
|
.attr("width", innerWidth)
|
|
.attr("height", innerHeight)
|
|
.attr("fill", "none")
|
|
.attr("pointer-events", "all")
|
|
.on("mousemove", (event) => {
|
|
const [xPos] = d3.pointer(event);
|
|
const dayFloat = x.invert(xPos);
|
|
const i = bisectDay(hoverDays, dayFloat);
|
|
const day = hoverDays[Math.max(0, Math.min(i, hoverDays.length - 1))];
|
|
|
|
const currentPoint = currentByDay.get(day);
|
|
const previousPoint = previousByDay.get(day);
|
|
const labelPoint = currentPoint || previousPoint;
|
|
|
|
const estimatedTooltipWidth = 220;
|
|
const pageWidth = document.body.clientWidth;
|
|
const tooltipX = event.pageX + 10;
|
|
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth;
|
|
const adjustedX =
|
|
overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
|
|
|
|
group.selectAll(".guideline").remove();
|
|
group.selectAll(".data-point-circle").remove();
|
|
|
|
group
|
|
.append("line")
|
|
.attr("class", "guideline text-subdued")
|
|
.attr("x1", x(day))
|
|
.attr("y1", 0)
|
|
.attr("x2", x(day))
|
|
.attr("y2", innerHeight)
|
|
.attr("stroke", "currentColor")
|
|
.attr("stroke-dasharray", "4, 4");
|
|
|
|
for (const [point, color] of [
|
|
[currentPoint, CURRENT_COLOR],
|
|
[previousPoint, PREVIOUS_COLOR],
|
|
]) {
|
|
if (!point) continue;
|
|
group
|
|
.append("circle")
|
|
.attr("class", "data-point-circle")
|
|
.attr("cx", x(day))
|
|
.attr("cy", y(point.value))
|
|
.attr("r", 4)
|
|
.attr("fill", color)
|
|
.attr("pointer-events", "none");
|
|
}
|
|
|
|
tooltip
|
|
.html(this._tooltipTemplate(labelPoint, currentPoint, previousPoint))
|
|
.style("opacity", 1)
|
|
.style("left", `${adjustedX}px`)
|
|
.style("top", `${event.pageY - 10}px`);
|
|
})
|
|
.on("mouseout", (event) => {
|
|
const hoveringOnGuideline =
|
|
event.toElement?.classList.contains("guideline");
|
|
|
|
if (!hoveringOnGuideline) {
|
|
group.selectAll(".guideline").remove();
|
|
group.selectAll(".data-point-circle").remove();
|
|
tooltip.style("opacity", 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
_tooltipTemplate(labelPoint, currentPoint, previousPoint) {
|
|
const row = (point, color, label) => {
|
|
if (!point) return "";
|
|
return `
|
|
<div class="flex items-center gap-1.5 text-primary font-medium tabular-nums">
|
|
<span class="inline-block w-2 h-2 rounded-full" style="background-color: ${color};"></span>
|
|
${label}: ${this._formatCurrency(point.value)}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
return `
|
|
<div class="text-xs text-secondary mb-1">${labelPoint.date_formatted}</div>
|
|
<div class="space-y-1">
|
|
${row(currentPoint, CURRENT_COLOR, this.currentLabelValue)}
|
|
${row(previousPoint, PREVIOUS_COLOR, this.previousLabelValue)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_formatCurrency(value) {
|
|
try {
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: this.currencyValue,
|
|
}).format(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
_formatCompact(value) {
|
|
try {
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: this.currencyValue,
|
|
notation: "compact",
|
|
maximumFractionDigits: 1,
|
|
}).format(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
}
|