diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js deleted file mode 100644 index e182f3f2..00000000 --- a/app/javascript/controllers/line_chart_controller.js +++ /dev/null @@ -1,270 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import tailwindColors from "@maybe/tailwindcolors"; -import * as d3 from "d3"; - -// Connects to data-controller="line-chart" -export default class extends Controller { - static values = { series: Object }; - - connect() { - this.renderChart(this.seriesValue); - document.addEventListener("turbo:load", this.renderChart); - } - - disconnect() { - document.removeEventListener("turbo:load", this.renderChart); - } - - renderChart = () => { - const data = this.prepareData(this.seriesValue); - this.drawChart(data); - }; - - trendStyles(trendDirection) { - return { - up: { - icon: "↑", - color: tailwindColors.success, - }, - down: { - icon: "↓", - color: tailwindColors.error, - }, - flat: { - icon: "→", - color: tailwindColors.gray[500], - }, - }[trendDirection]; - } - - prepareData(series) { - return series.values.map((b) => ({ - date: new Date(b.date + "T00:00:00"), - value: +b.value.amount, - styles: this.trendStyles(b.trend.direction), - trend: b.trend, - formatted: { - value: Intl.NumberFormat(undefined, { - style: "currency", - currency: b.value.currency || "USD", - }).format(b.value.amount), - change: Intl.NumberFormat(undefined, { - style: "currency", - currency: b.value.currency || "USD", - signDisplay: "always", - }).format(b.trend.value.amount), - }, - })); - } - - drawChart(data) { - const chartContainer = d3.select(this.element); - - // Clear any existing chart - chartContainer.selectAll("svg").remove(); - - const initialDimensions = { - width: chartContainer.node().clientWidth, - height: chartContainer.node().clientHeight, - }; - - const svg = chartContainer - .append("svg") - .attr("width", initialDimensions.width) - .attr("height", initialDimensions.height) - .attr("viewBox", [ - 0, - 0, - initialDimensions.width, - initialDimensions.height, - ]) - .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); - - if (data.length === 1) { - this.renderEmpty(svg, initialDimensions); - return; - } - - const margin = { top: 20, right: 1, bottom: 30, left: 1 }, - width = +svg.attr("width") - margin.left - margin.right, - height = +svg.attr("height") - margin.top - margin.bottom, - g = svg - .append("g") - .attr("transform", `translate(${margin.left},${margin.top})`); - - // X-Axis - const x = d3 - .scaleTime() - .rangeRound([0, width]) - .domain(d3.extent(data, (d) => d.date)); - - const PADDING = 0.15; // 15% padding on top and bottom of data - const dataMin = d3.min(data, (d) => d.value); - const dataMax = d3.max(data, (d) => d.value); - const padding = (dataMax - dataMin) * PADDING; - - // Y-Axis - const y = d3 - .scaleLinear() - .rangeRound([height, 0]) - .domain([dataMin - padding, dataMax + padding]); - - // X-Axis labels - g.append("g") - .attr("transform", `translate(0,${height})`) - .call( - d3 - .axisBottom(x) - .tickValues([data[0].date, data[data.length - 1].date]) - .tickSize(0) - .tickFormat(d3.timeFormat("%b %Y")) - ) - .select(".domain") - .remove(); - - g.selectAll(".tick text") - .style("fill", tailwindColors.gray[500]) - .style("font-size", "12px") - .style("font-weight", "500") - .attr("text-anchor", "middle") - .attr("dx", (d, i) => { - // We know we only have 2 values - return i === 0 ? "5em" : "-5em"; - }) - .attr("dy", "0em"); - - // Line - const line = d3 - .line() - .x((d) => x(d.date)) - .y((d) => y(d.value)); - - g.append("path") - .datum(data) - .attr("fill", "none") - .attr("stroke", tailwindColors.green[500]) - .attr("stroke-linejoin", "round") - .attr("stroke-linecap", "round") - .attr("stroke-width", 1.5) - .attr("class", "line-chart-path") - .attr("d", line); - - const tooltip = d3 - .select("#lineChart") - .append("div") - .style("position", "absolute") - .style("padding", "8px") - .style("font", "14px Inter, sans-serif") - .style("background", tailwindColors.white) - .style("border", `1px solid ${tailwindColors["alpha-black"][100]}`) - .style("border-radius", "10px") - .style("pointer-events", "none") - .style("opacity", 0); // Starts as hidden - - // Helper to find the closest data point to the mouse - const bisectDate = d3.bisector(function (d) { - return d.date; - }).left; - - // Create an invisible rectangle that captures mouse events (regular SVG elements don't capture mouse events by default) - g.append("rect") - .attr("width", width) - .attr("height", height) - .attr("fill", "none") - .attr("pointer-events", "all") - // When user hovers over the chart, show the tooltip and a circle at the closest data point - .on("mousemove", (event) => { - tooltip.style("opacity", 1); - - const tooltipWidth = 250; // Estimate or dynamically calculate the tooltip width - const pageWidth = document.body.clientWidth; - const tooltipX = event.pageX + 10; - const overflowX = tooltipX + tooltipWidth - pageWidth; - - const [xPos] = d3.pointer(event); - - const x0 = bisectDate(data, x.invert(xPos), 1); - const d0 = data[x0 - 1]; - const d1 = data[x0]; - const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0; - - // Adjust tooltip position based on overflow - const adjustedX = - overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX; - - g.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time - g.append("circle") - .attr("class", "data-point-circle") - .attr("cx", x(d.date)) - .attr("cy", y(d.value)) - .attr("r", 8) - .attr("fill", tailwindColors.green[500]) - .attr("fill-opacity", "0.1") - .attr("pointer-events", "none"); - - g.append("circle") - .attr("class", "data-point-circle") - .attr("cx", x(d.date)) - .attr("cy", y(d.value)) - .attr("r", 3) - .attr("fill", tailwindColors.green[500]) - .attr("pointer-events", "none"); - - tooltip - .html( - `
${d3.timeFormat("%b %d, %Y")(d.date)}
-
- - - - ${d.formatted.value} ${d.formatted.change} (${d.trend.percent}%) -
` - ) - .style("left", adjustedX + "px") - .style("top", event.pageY - 10 + "px"); - - g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time - g.append("line") - .attr("class", "guideline") - .attr("x1", x(d.date)) - .attr("y1", 0) - .attr("x2", x(d.date)) - .attr("y2", height) - .attr("stroke", tailwindColors.gray[300]) - .attr("stroke-dasharray", "4, 4"); - }) - .on("mouseout", () => { - g.selectAll(".guideline").remove(); - g.selectAll(".data-point-circle").remove(); - tooltip.style("opacity", 0); - }); - } - - // Dot in middle of chart as placeholder for empty chart - renderEmpty(svg, { width, height }) { - svg - .append("line") - .attr("x1", width / 2) - .attr("y1", 0) - .attr("x2", width / 2) - .attr("y2", height) - .attr("stroke", tailwindColors.gray[300]) - .attr("stroke-dasharray", "4, 4"); - - svg - .append("circle") - .attr("cx", width / 2) - .attr("cy", height / 2) - .attr("r", 4) - .style("fill", tailwindColors.gray[400]); - - svg.selectAll(".tick").remove(); - svg.selectAll(".domain").remove(); - } -} diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js new file mode 100644 index 00000000..a429ae99 --- /dev/null +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -0,0 +1,518 @@ +import { Controller } from "@hotwired/stimulus" +import tailwindColors from "@maybe/tailwindcolors" +import * as d3 from "d3" + +export default class extends Controller { + static values = { + data: Object, + useLabels: { type: Boolean, default: true }, + useTooltip: { type: Boolean, default: true }, + usePercentSign: Boolean + } + + #d3SvgMemo = null + #d3GroupMemo = null + #d3Tooltip = null + #d3InitialContainerWidth = 0 + #d3InitialContainerHeight = 0 + #normalDataPoints = [] + + connect() { + this.#install() + document.addEventListener("turbo:load", this.#reinstall) + } + + disconnect() { + this.#teardown() + document.removeEventListener("turbo:load", this.#reinstall) + } + + + #reinstall = () => { + this.#teardown() + this.#install() + } + + #teardown() { + this.#d3SvgMemo = null + this.#d3GroupMemo = null + this.#d3Tooltip = null + this.#normalDataPoints = [] + + this.#d3Container.selectAll("*").remove() + } + + #install() { + this.#normalizeDataPoints() + this.#rememberInitialContainerSize() + this.#draw() + } + + + #normalizeDataPoints() { + this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({ + ...d, + date: new Date(d.date), + value: d.value.amount ? +d.value.amount : +d.value, + currency: d.value.currency + })) + } + + + #rememberInitialContainerSize() { + this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth + this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight + } + + + #draw() { + if (this.#normalDataPoints.length < 2) { + this.#drawEmpty() + } else { + this.#drawChart() + } + } + + + #drawEmpty() { + this.#d3Svg.selectAll(".tick").remove() + this.#d3Svg.selectAll(".domain").remove() + + this.#drawDashedLineEmptyState() + this.#drawCenteredCircleEmptyState() + } + + #drawDashedLineEmptyState() { + this.#d3Svg + .append("line") + .attr("x1", this.#d3InitialContainerWidth / 2) + .attr("y1", 0) + .attr("x2", this.#d3InitialContainerWidth / 2) + .attr("y2", this.#d3InitialContainerHeight) + .attr("stroke", tailwindColors.gray[300]) + .attr("stroke-dasharray", "4, 4") + } + + #drawCenteredCircleEmptyState() { + this.#d3Svg + .append("circle") + .attr("cx", this.#d3InitialContainerWidth / 2) + .attr("cy", this.#d3InitialContainerHeight / 2) + .attr("r", 4) + .style("fill", tailwindColors.gray[400]) + } + + + #drawChart() { + this.#drawTrendline() + + if (this.useLabelsValue) { + this.#drawXAxisLabels() + this.#drawGradientBelowTrendline() + } + + if (this.useTooltipValue) { + this.#drawTooltip() + this.#trackMouseForShowingTooltip() + } + } + + #drawTrendline() { + this.#installTrendlineSplit() + + this.#d3Group + .append("path") + .datum(this.#normalDataPoints) + .attr("fill", "none") + .attr("stroke", `url(#${this.element.id}-split-gradient)`) + .attr("d", this.#d3Line) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("stroke-width", 2) + } + + #installTrendlineSplit() { + const gradient = this.#d3Svg + .append("defs") + .append("linearGradient") + .attr("id", `${this.element.id}-split-gradient`) + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", this.#d3XScale.range()[0]) + .attr("x2", this.#d3XScale.range()[1]) + + gradient.append("stop") + .attr("class", "start-color") + .attr("offset", "0%") + .attr("stop-color", this.#trendColor) + + gradient.append("stop") + .attr("class", "middle-color") + .attr("offset", "100%") + .attr("stop-color", this.#trendColor) + + gradient.append("stop") + .attr("class", "end-color") + .attr("offset", "100%") + .attr("stop-color", tailwindColors.gray[300]) + } + + #setTrendlineSplitAt(percent) { + this.#d3Svg + .select(`#${this.element.id}-split-gradient`) + .select(".middle-color") + .attr("offset", `${percent * 100}%`) + + this.#d3Svg + .select(`#${this.element.id}-split-gradient`) + .select(".end-color") + .attr("offset", `${percent * 100}%`) + + this.#d3Svg + .select(`#${this.element.id}-trendline-gradient-rect`) + .attr("width", this.#d3ContainerWidth * percent) + } + + #drawXAxisLabels() { + // Add ticks + this.#d3Group + .append("g") + .attr("transform", `translate(0,${this.#d3ContainerHeight})`) + .call( + d3 + .axisBottom(this.#d3XScale) + .tickValues([ this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date ]) + .tickSize(0) + .tickFormat(d3.timeFormat("%d %b %Y")) + ) + .select(".domain") + .remove() + + // Style ticks + this.#d3Group.selectAll(".tick text") + .style("fill", tailwindColors.gray[500]) + .style("font-size", "12px") + .style("font-weight", "500") + .attr("text-anchor", "middle") + .attr("dx", (_d, i) => { + // We know we only have 2 values + return i === 0 ? "5em" : "-5em" + }) + .attr("dy", "0em") + } + + #drawGradientBelowTrendline() { + // Define gradient + const gradient = this.#d3Group + .append("defs") + .append("linearGradient") + .attr("id", `${this.element.id}-trendline-gradient`) + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", 0) + .attr("x2", 0) + .attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value))) + .attr("y2", this.#d3ContainerHeight) + + gradient + .append("stop") + .attr("offset", 0) + .attr("stop-color", this.#trendColor) + .attr("stop-opacity", 0.06) + + gradient + .append("stop") + .attr("offset", 0.5) + .attr("stop-color", this.#trendColor) + .attr("stop-opacity", 0) + + // Clip path makes gradient start at the trendline + this.#d3Group + .append("clipPath") + .attr("id", `${this.element.id}-clip-below-trendline`) + .append("path") + .datum(this.#normalDataPoints) + .attr("d", d3.area() + .x(d => this.#d3XScale(d.date)) + .y0(this.#d3ContainerHeight) + .y1(d => this.#d3YScale(d.value)) + ) + + // Apply the gradient + clip path + this.#d3Group + .append("rect") + .attr("id", `${this.element.id}-trendline-gradient-rect`) + .attr("width", this.#d3ContainerWidth) + .attr("height", this.#d3ContainerHeight) + .attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`) + .style("fill", `url(#${this.element.id}-trendline-gradient)`) + } + + + #drawTooltip() { + this.#d3Tooltip = d3 + .select(`#${this.element.id}`) + .append("div") + .style("position", "absolute") + .style("padding", "8px") + .style("font", "14px Inter, sans-serif") + .style("background", tailwindColors.white) + .style("border", `1px solid ${tailwindColors["alpha-black"][100]}`) + .style("border-radius", "10px") + .style("pointer-events", "none") + .style("opacity", 0) // Starts as hidden + } + + #trackMouseForShowingTooltip() { + const bisectDate = d3.bisector(d => d.date).left + + this.#d3Group + .append("rect") + .attr("width", this.#d3ContainerWidth) + .attr("height", this.#d3ContainerHeight) + .attr("fill", "none") + .attr("pointer-events", "all") + .on("mousemove", (event) => { + const estimatedTooltipWidth = 250 + const pageWidth = document.body.clientWidth + const tooltipX = event.pageX + 10 + const overflowX = tooltipX + estimatedTooltipWidth - pageWidth + const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX + + const [xPos] = d3.pointer(event) + const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1) + const d0 = this.#normalDataPoints[x0 - 1] + const d1 = this.#normalDataPoints[x0] + const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0 + const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth + + this.#setTrendlineSplitAt(xPercent) + + // Reset + this.#d3Group.selectAll(".data-point-circle").remove() + this.#d3Group.selectAll(".guideline").remove() + + // Guideline + this.#d3Group + .append("line") + .attr("class", "guideline") + .attr("x1", this.#d3XScale(d.date)) + .attr("y1", 0) + .attr("x2", this.#d3XScale(d.date)) + .attr("y2", this.#d3ContainerHeight) + .attr("stroke", tailwindColors.gray[300]) + .attr("stroke-dasharray", "4, 4") + + // Big circle + this.#d3Group + .append("circle") + .attr("class", "data-point-circle") + .attr("cx", this.#d3XScale(d.date)) + .attr("cy", this.#d3YScale(d.value)) + .attr("r", 8) + .attr("fill", this.#trendColor) + .attr("fill-opacity", "0.1") + .attr("pointer-events", "none") + + // Small circle + this.#d3Group + .append("circle") + .attr("class", "data-point-circle") + .attr("cx", this.#d3XScale(d.date)) + .attr("cy", this.#d3YScale(d.value)) + .attr("r", 3) + .attr("fill", this.#trendColor) + .attr("pointer-events", "none") + + // Render tooltip + this.#d3Tooltip + .html(this.#tooltipTemplate(d)) + .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) { + this.#d3Group.selectAll(".guideline").remove() + this.#d3Group.selectAll(".data-point-circle").remove() + this.#d3Tooltip.style("opacity", 0) + + this.#setTrendlineSplitAt(1) + } + }) + } + + #tooltipTemplate(datum) { + return(` +
+ ${d3.timeFormat("%b %d, %Y")(datum.date)} +
+ +
+
+ + + + + ${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""} +
+ + ${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? ` + + ` : ` + + ${this.#tooltipChange(datum)} (${datum.trend.percent}%) + + `} +
+ `) + } + + #tooltipTrendColor(datum) { + return { + up: tailwindColors.success, + down: tailwindColors.error, + flat: tailwindColors.gray[500], + }[datum.trend.direction] + } + + #tooltipValue(datum) { + if (datum.currency) { + return this.#currencyValue(datum) + } else { + return datum.value + } + } + + #tooltipChange(datum) { + if (datum.currency) { + return this.#currencyChange(datum) + } else { + return this.#decimalChange(datum) + } + } + + #currencyValue(datum) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency: datum.currency, + }).format(datum.value) + } + + #currencyChange(datum) { + return Intl.NumberFormat(undefined, { + style: "currency", + currency: datum.currency, + signDisplay: "always", + }).format(datum.trend.value.amount) + } + + #decimalChange(datum) { + return Intl.NumberFormat(undefined, { + style: "decimal", + signDisplay: "always", + }).format(datum.trend.value) + } + + + #createMainSvg() { + return this.#d3Container + .append("svg") + .attr("width", this.#d3InitialContainerWidth) + .attr("height", this.#d3InitialContainerHeight) + .attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ]) + } + + #createMainGroup() { + return this.#d3Svg + .append("g") + .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`) + } + + + get #d3Svg() { + if (this.#d3SvgMemo) { + return this.#d3SvgMemo + } else { + return this.#d3SvgMemo = this.#createMainSvg() + } + } + + get #d3Group() { + if (this.#d3GroupMemo) { + return this.#d3GroupMemo + } else { + return this.#d3GroupMemo = this.#createMainGroup() + } + } + + get #margin() { + if (this.useLabelsValue) { + return { top: 20, right: 0, bottom: 30, left: 0 } + } else { + return { top: 0, right: 0, bottom: 0, left: 0 } + } + } + + get #d3ContainerWidth() { + return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right + } + + get #d3ContainerHeight() { + return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom + } + + get #d3Container() { + return d3.select(this.element) + } + + get #trendColor() { + if (this.#trendDirection === "flat") { + return tailwindColors.gray[500] + } else if (this.#trendDirection === this.#favorableDirection) { + return tailwindColors.green[500] + } else { + return tailwindColors.error + } + } + + get #trendDirection() { + return this.dataValue.trend.direction + } + + get #favorableDirection() { + return this.dataValue.trend.favorable_direction + } + + get #d3Line() { + return d3 + .line() + .x(d => this.#d3XScale(d.date)) + .y(d => this.#d3YScale(d.value)) + } + + get #d3XScale() { + return d3 + .scaleTime() + .rangeRound([ 0, this.#d3ContainerWidth ]) + .domain(d3.extent(this.#normalDataPoints, d => d.date)) + } + + get #d3YScale() { + const reductionPercent = this.useLabelsValue ? 0.15 : 0.05 + const dataMin = d3.min(this.#normalDataPoints, d => d.value) + const dataMax = d3.max(this.#normalDataPoints, d => d.value) + const padding = (dataMax - dataMin) * reductionPercent + + return d3 + .scaleLinear() + .rangeRound([ this.#d3ContainerHeight, 0 ]) + .domain([ dataMin - padding, dataMax + padding ]) + } +} diff --git a/app/javascript/controllers/trendline_controller.js b/app/javascript/controllers/trendline_controller.js deleted file mode 100644 index 3b2bb29e..00000000 --- a/app/javascript/controllers/trendline_controller.js +++ /dev/null @@ -1,97 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; -import tailwindColors from "@maybe/tailwindcolors"; -import * as d3 from "d3"; - -export default class extends Controller { - static values = { series: Object }; - - connect() { - this.renderChart(this.seriesValue); - document.addEventListener("turbo:load", this.renderChart); - } - - disconnect() { - document.removeEventListener("turbo:load", this.renderChart); - } - - renderChart = () => { - const data = this.prepareData(this.seriesValue); - this.drawChart(data); - }; - - prepareData(series) { - return series.values.map((d) => ({ - date: new Date(d.date + "T00:00:00"), - value: d.value.amount ? +d.value.amount : +d.value, - })); - } - - drawChart(data) { - const chartContainer = d3.select(this.element); - chartContainer.selectAll("*").remove(); - const initialDimensions = { - width: chartContainer.node().clientWidth, - height: chartContainer.node().clientHeight, - }; - - const svg = chartContainer - .append("svg") - .attr("width", initialDimensions.width) - .attr("height", initialDimensions.height) - .attr("viewBox", [ - 0, - 0, - initialDimensions.width, - initialDimensions.height, - ]) - .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); - - const margin = { top: 0, right: 0, bottom: 0, left: 0 }; - const width = initialDimensions.width - margin.left - margin.right; - const height = initialDimensions.height - margin.top - margin.bottom; - - const isLiability = this.classificationValue === "liability"; - const trendDirection = data[data.length - 1].value - data[0].value; - let lineColor; - - if (trendDirection > 0) { - lineColor = isLiability - ? tailwindColors.error - : tailwindColors.green[500]; - } else if (trendDirection < 0) { - lineColor = isLiability - ? tailwindColors.green[500] - : tailwindColors.error; - } else { - lineColor = tailwindColors.gray[500]; - } - - const xScale = d3 - .scaleTime() - .rangeRound([0, width]) - .domain(d3.extent(data, (d) => d.date)); - - const PADDING = 0.05; - const dataMin = d3.min(data, (d) => d.value); - const dataMax = d3.max(data, (d) => d.value); - const padding = (dataMax - dataMin) * PADDING; - - const yScale = d3 - .scaleLinear() - .rangeRound([height, 0]) - .domain([dataMin - padding, dataMax + padding]); - - const line = d3 - .line() - .x((d) => xScale(d.date)) - .y((d) => yScale(d.value)); - - svg - .append("path") - .datum(data) - .attr("fill", "none") - .attr("stroke", lineColor) - .attr("stroke-width", 2) - .attr("d", line); - } -} diff --git a/app/models/time_series.rb b/app/models/time_series.rb index 14342a13..9610eb5d 100644 --- a/app/models/time_series.rb +++ b/app/models/time_series.rb @@ -15,7 +15,7 @@ class TimeSeries def initialize(data, favorable_direction: "up") @favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry - @values = initialize_values data + @values = initialize_values data.sort_by { |d| d[:date] } end def first diff --git a/app/models/time_series/value.rb b/app/models/time_series/value.rb index e691159d..c19c3c97 100644 --- a/app/models/time_series/value.rb +++ b/app/models/time_series/value.rb @@ -22,7 +22,7 @@ class TimeSeries::Value def as_json { - date: date, + date: date.iso8601, value: value.as_json, trend: trend.as_json } diff --git a/app/views/accounts/summary.html.erb b/app/views/accounts/summary.html.erb index 892ba587..ed519094 100644 --- a/app/views/accounts/summary.html.erb +++ b/app/views/accounts/summary.html.erb @@ -17,11 +17,11 @@ } %>
+ id="assetsChart" + class="h-full w-2/5" + data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @asset_series.to_json %>" + data-time-series-chart-use-labels-value="false">
@@ -34,11 +34,11 @@ } %>
+ id="liabilitiesChart" + class="h-full w-2/5" + data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @liability_series.to_json %>" + data-time-series-chart-use-labels-value="false">
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 6aaee6b9..fa6e16a9 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -43,10 +43,11 @@ } %>
+ data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @income_series.to_json %>" + data-time-series-chart-use-labels-value="false">
@@ -60,10 +61,11 @@ } %>
+ data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @spending_series.to_json %>" + data-time-series-chart-use-labels-value="false">
@@ -78,10 +80,11 @@ } %>
+ data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @savings_rate_series.to_json %>" + data-time-series-chart-use-labels-value="false">
@@ -95,10 +98,11 @@ } %>
+ data-controller="time-series-chart" + data-time-series-chart-data-value="<%= @investing_series.to_json %>" + data-time-series-chart-use-labels-value="false"> diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index 72c43d61..c6c04d55 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -1,6 +1,10 @@ <%# locals: (series:) %> <% if series %> -
+
<% else %>

No data available for the selected period.

diff --git a/app/views/shared/_line_chart.html.erb b/app/views/shared/_line_chart.html.erb index 72c43d61..d72f5564 100644 --- a/app/views/shared/_line_chart.html.erb +++ b/app/views/shared/_line_chart.html.erb @@ -1,6 +1,10 @@ <%# locals: (series:) %> <% if series %> -
+
<% else %>

No data available for the selected period.