1(function (global) {
2 function buildSpendingSeries(transactions) {
3 const totals = new Map();
4 transactions.forEach((transaction) => {
5 if (Number(transaction.amount) >= 0) return;
6 const date = transaction.date;
7 totals.set(date, (totals.get(date) || 0) + Math.abs(Number(transaction.amount)));
8 });
9
10 let running = 0;
11 return Array.from(totals.entries())
12 .sort(([a], [b]) => a.localeCompare(b))
13 .map(([date, amount]) => {
14 running += amount;
15 return { date, amount: roundMoney(amount), cumulative: roundMoney(running) };
16 });
17 }
18
19 function roundMoney(value) {
20 return Math.round((Number(value) + Number.EPSILON) * 100) / 100;
21 }
22
23 function drawSpendingChart(canvas, series, theme) {
24 if (!canvas || !canvas.getContext) return;
25 const ctx = canvas.getContext("2d");
26 const width = canvas.width;
27 const height = canvas.height;
28 const palette = theme === "dark"
29 ? { bg: "#101828", grid: "#344054", line: "#7dd3fc", text: "#d0d5dd", fill: "rgba(125, 211, 252, 0.18)" }
30 : { bg: "#ffffff", grid: "#e4e7ec", line: "#2563eb", text: "#475467", fill: "rgba(37, 99, 235, 0.12)" };
31
32 ctx.clearRect(0, 0, width, height);
33 ctx.fillStyle = palette.bg;
34 ctx.fillRect(0, 0, width, height);
35
36 const padding = { top: 22, right: 24, bottom: 42, left: 58 };
37 const plotWidth = width - padding.left - padding.right;
38 const plotHeight = height - padding.top - padding.bottom;
39
40 ctx.strokeStyle = palette.grid;
41 ctx.lineWidth = 1;
42 ctx.fillStyle = palette.text;
43 ctx.font = "12px Inter, system-ui, sans-serif";
44
45 for (let i = 0; i <= 4; i += 1) {
46 const y = padding.top + (plotHeight / 4) * i;
47 ctx.beginPath();
48 ctx.moveTo(padding.left, y);
49 ctx.lineTo(width - padding.right, y);
50 ctx.stroke();
51 }
52
53 if (!series.length) {
54 ctx.fillText("No spending data yet", padding.left, height / 2);
55 return;
56 }
57
58 const max = Math.max(...series.map((point) => point.cumulative), 1);
59 const xFor = (index) => padding.left + (series.length === 1 ? plotWidth / 2 : (plotWidth / (series.length - 1)) * index);
60 const yFor = (value) => padding.top + plotHeight - (value / max) * plotHeight;
61
62 ctx.beginPath();
63 series.forEach((point, index) => {
64 const x = xFor(index);
65 const y = yFor(point.cumulative);
66 if (index === 0) ctx.moveTo(x, y);
67 else ctx.lineTo(x, y);
68 });
69 ctx.strokeStyle = palette.line;
70 ctx.lineWidth = 3;
71 ctx.stroke();
72
73 ctx.lineTo(xFor(series.length - 1), padding.top + plotHeight);
74 ctx.lineTo(xFor(0), padding.top + plotHeight);
75 ctx.closePath();
76 ctx.fillStyle = palette.fill;
77 ctx.fill();
78
79 ctx.fillStyle = palette.text;
80 ctx.fillText("$0", 14, padding.top + plotHeight);
81 ctx.fillText(`$${Math.round(max).toLocaleString()}`, 14, padding.top + 8);
82 ctx.fillText(series[0].date.slice(5), padding.left, height - 16);
83 ctx.fillText(series[series.length - 1].date.slice(5), width - padding.right - 34, height - 16);
84 }
85
86 const api = { buildSpendingSeries, drawSpendingChart, roundMoney };
87 global.FinanceCharts = api;
88 if (typeof module !== "undefined") module.exports = api;
89})(typeof window !== "undefined" ? window : globalThis);
90
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.