From 73abe473b7878cb6e8c5fd11246ca66552a4c1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sun, 6 Sep 2026 02:18:22 +0200 Subject: [PATCH] Add cumulative spending chart dashboard widget (#3404) * 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. --- app/controllers/pages_controller.rb | 89 +++++ .../controllers/spending_chart_controller.js | 327 ++++++++++++++++++ app/models/income_statement.rb | 21 ++ .../income_statement/daily_expense_totals.rb | 109 ++++++ .../pages/dashboard/_spending_trend.html.erb | 80 +++++ config/locales/views/pages/en.yml | 7 + test/controllers/pages_controller_test.rb | 94 +++++ .../daily_expense_totals_test.rb | 81 +++++ test/models/income_statement_test.rb | 27 ++ 9 files changed, 835 insertions(+) create mode 100644 app/javascript/controllers/spending_chart_controller.js create mode 100644 app/models/income_statement/daily_expense_totals.rb create mode 100644 app/views/pages/dashboard/_spending_trend.html.erb create mode 100644 test/models/income_statement/daily_expense_totals_test.rb diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 6a8f62d66..2101ba228 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -15,6 +15,7 @@ class PagesController < ApplicationController "insights_feed" => { col_span: "full", grow: false, min_height: 0, width_toggle: true }, "cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true }, "money_flow" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }, + "spending_trend" => { col_span: "single", grow: true, min_height: 208, width_toggle: true }, "outflows_donut" => { col_span: "single", grow: false, min_height: 0 }, "investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }, "net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true }, @@ -64,6 +65,9 @@ class PagesController < ApplicationController @money_flow_account_ids = money_flow_account_ids_param @money_flow_data = build_money_flow_data(income_statement, @money_flow_month, @money_flow_account_ids) + @spending_trend_month = spending_trend_month_param + @spending_trend_data = build_spending_trend_data(income_statement, @spending_trend_month) + @dashboard_sections = build_dashboard_sections @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.dashboard"), nil ] ] @@ -165,6 +169,15 @@ class PagesController < ApplicationController visible: @accounts.any?, collapsible: true }, + { + key: "spending_trend", + title: "pages.dashboard.spending_trend.title", + partial: "pages/dashboard/spending_trend", + layout: section_layout("spending_trend"), + locals: { spending_trend_data: @spending_trend_data }, + visible: @accounts.any?, + collapsible: true + }, { key: "outflows_donut", title: "pages.dashboard.outflows_donut.title", @@ -456,6 +469,82 @@ class PagesController < ApplicationController ids.presence end + def spending_trend_month_param + current_month = Date.current.beginning_of_month + month = Date.strptime(params[:spending_month], "%Y-%m-%d").beginning_of_month + # Same clamp as money_flow: a future month's period would end before it + # starts once capped at Date.current, which Period.custom rejects. + month > current_month ? current_month : month + rescue ArgumentError, TypeError + current_month + end + + # Cumulative daily spending for the selected month (capped at today while + # the month is in progress) against the previous month's full curve, so + # the two lines share one day-of-month axis. + def build_spending_trend_data(income_statement, selected_month) + month_start = selected_month.beginning_of_month + month_end = month_start.end_of_month + current_period = Period.custom(start_date: month_start, end_date: [ month_end, Date.current ].min) + + previous_month_start = (month_start - 1.month).beginning_of_month + previous_period = Period.custom(start_date: previous_month_start, end_date: previous_month_start.end_of_month) + + current_daily = income_statement.daily_expense_series(period: current_period).index_by(&:date) + previous_daily = income_statement.daily_expense_series(period: previous_period).index_by(&:date) + + current_series = cumulative_spending_series(current_period, current_daily) + previous_series = cumulative_spending_series(previous_period, previous_daily) + + current_total = current_series.last&.fetch(:value) || 0 + previous_total = previous_series.last&.fetch(:value) || 0 + currency = income_statement.family.currency + + # The axis spans the longer of the two months so both curves share it. + axis_days = [ month_end.day, previous_period.end_date.day ].max + + { + month: month_start, + current_period: current_period, + previous_period: previous_period, + days: axis_days, + axis_labels: spending_trend_axis_labels(month_start, previous_month_start, axis_days), + current: current_series, + previous: previous_series, + current_total: Money.new(current_total, currency), + previous_total: Money.new(previous_total, currency), + delta: Money.new(current_total - previous_total, currency) + } + end + + # Localized tick labels, one per axis day. The selected month owns the + # axis up to its length; when the previous month is longer, its dates + # label the tail so a tick never rolls past month-end into the next month + # (e.g. day 31 of a February view is "Jan 31", not "Mar 3"). + def spending_trend_axis_labels(month_start, previous_month_start, days) + month_length = month_start.end_of_month.day + + (1..days).map do |day| + date = day <= month_length ? month_start + (day - 1) : previous_month_start + (day - 1) + I18n.l(date, format: :short) + end + end + + # One point per day (spend-free days included) so flat stretches render + # flat instead of being interpolated away. + def cumulative_spending_series(period, daily_totals) + cumulative = 0.to_d + period.date_range.map do |date| + cumulative += daily_totals[date] ? daily_totals[date].total.to_d : 0 + { + day: (date - period.start_date).to_i + 1, + value: cumulative.to_f.round(2), + date: date.iso8601, + date_formatted: I18n.l(date, format: :short) + } + end + end + def build_money_flow_data(income_statement, selected_month, account_ids) months = (MONEY_FLOW_CHART_MONTHS - 1).downto(0).map { |i| selected_month - i.months } diff --git a/app/javascript/controllers/spending_chart_controller.js b/app/javascript/controllers/spending_chart_controller.js new file mode 100644 index 000000000..cf9b263d3 --- /dev/null +++ b/app/javascript/controllers/spending_chart_controller.js @@ -0,0 +1,327 @@ +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 ` +
+ + ${label}: ${this._formatCurrency(point.value)} +
+ `; + }; + + return ` +
${labelPoint.date_formatted}
+
+ ${row(currentPoint, CURRENT_COLOR, this.currentLabelValue)} + ${row(previousPoint, PREVIOUS_COLOR, this.previousLabelValue)} +
+ `; + } + + _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; + } + } +} diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index 03efb043a..73961dcf5 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -117,6 +117,27 @@ class IncomeStatement # `income_totals`/`expense_totals`, this isn't memoized per-period since # callers (e.g. a monthly bar chart) typically query several distinct # periods and account combinations in one request. + # Per-day expense totals (in family currency) across a period, powering the + # dashboard's cumulative spending chart. Same scoping as `expense_totals`. + def daily_expense_series(period:) + Rails.cache.fetch([ + "income_statement", "daily_expense_series", family.id, user&.id, + included_account_ids_hash, period.start_date, period.end_date, + family.entries_cache_version, family.accounts.maximum(:updated_at)&.to_i, + # Rates change via ExchangeRate::Importer's upsert_all and the target + # currency via settings; neither touches entries/accounts, so both must + # be part of the key to keep the chart from going stale. + family.currency, ExchangeRate.maximum(:updated_at)&.to_i + ]) do + DailyExpenseTotals.new( + family, + transactions_scope: family.transactions.visible.excluding_pending.in_period(period), + date_range: period.date_range, + included_account_ids: included_account_ids + ).call + end + end + def totals_for(period, account_ids: nil) scope = family.transactions.visible.excluding_pending.in_period(period) scope = scope.where(entries: { account_id: account_ids }) if account_ids.present? diff --git a/app/models/income_statement/daily_expense_totals.rb b/app/models/income_statement/daily_expense_totals.rb new file mode 100644 index 000000000..96620d1ff --- /dev/null +++ b/app/models/income_statement/daily_expense_totals.rb @@ -0,0 +1,109 @@ +# Per-day expense totals (in the family's currency) for a period, used by the +# dashboard's cumulative spending chart. Follows the same scoping rules as +# IncomeStatement::Totals (visible, posted, budget-included transactions in +# report-included accounts, converted at the day's exchange rate) so the +# series always agrees with the totals shown elsewhere on the dashboard. +class IncomeStatement::DailyExpenseTotals + def initialize(family, transactions_scope:, date_range:, included_account_ids: nil) + @family = family + @transactions_scope = transactions_scope + @date_range = date_range + @included_account_ids = included_account_ids + + validate_date_range! + end + + def call + # No finance accounts means no transactions to report + return [] if @included_account_ids&.empty? + + ActiveRecord::Base.connection.select_all(query_sql).map do |row| + DailyTotal.new(date: row["day"].to_date, total: row["total"]) + end + end + + private + DailyTotal = Data.define(:date, :total) + + def query_sql + ActiveRecord::Base.sanitize_sql_array([ query_sql_body, sql_params ]) + end + + # Mirrors IncomeStatement::Totals' transactions subquery, but groups by + # entry date instead of category and keeps only the expense rows. The + # classification CASE is repeated in the GROUP BY (rather than referenced + # by alias) because only some databases accept aliases there. + def query_sql_body + <<~SQL + SELECT day, total FROM ( + SELECT + ae.date as day, + CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, + ABS(SUM(CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE ae.amount * COALESCE(er.rate, 1) END)) as total + FROM (#{@transactions_scope.to_sql}) at + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' + JOIN accounts a ON a.id = ae.account_id + LEFT JOIN exchange_rates er ON ( + er.date = ae.date AND + er.from_currency = ae.currency AND + er.to_currency = :target_currency + ) + WHERE at.kind NOT IN (#{budget_excluded_kinds_sql}) + AND ( + at.investment_activity_label IS NULL + OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') + ) + AND ae.excluded = false + AND a.family_id = :family_id + AND a.status IN ('draft', 'active') + AND a.exclude_from_reports = false + #{exclude_tax_advantaged_sql} + #{include_finance_accounts_sql} + GROUP BY ae.date, CASE WHEN at.kind IN ('investment_contribution', 'loan_payment') THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END + ) daily + WHERE classification = 'expense' + ORDER BY day + SQL + end + + def sql_params + params = { + target_currency: @family.currency, + family_id: @family.id, + start_date: @date_range.begin, + end_date: @date_range.end + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params[:included_account_ids] = @included_account_ids if @included_account_ids + + params + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + + def include_finance_accounts_sql + return "" if @included_account_ids.nil? + "AND a.id IN (:included_account_ids)" + end + + def budget_excluded_kinds_sql + @budget_excluded_kinds_sql ||= Transaction::BUDGET_EXCLUDED_KINDS.map { |k| "'#{k}'" }.join(", ") + end + + def validate_date_range! + unless @date_range.is_a?(Range) + raise ArgumentError, "date_range must be a Range, got #{@date_range.class}" + end + + unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date) + raise ArgumentError, "date_range must contain date-like objects" + end + end +end diff --git a/app/views/pages/dashboard/_spending_trend.html.erb b/app/views/pages/dashboard/_spending_trend.html.erb new file mode 100644 index 000000000..cd94701ed --- /dev/null +++ b/app/views/pages/dashboard/_spending_trend.html.erb @@ -0,0 +1,80 @@ +<%# locals: (spending_trend_data:) %> +<% + data = spending_trend_data + period = data[:current_period] + pill_button_class = "inline-flex items-center gap-1.5 bg-container border border-secondary font-medium rounded-lg pl-3 pr-2 py-2 text-sm cursor-pointer text-primary hover:bg-container-inset-hover focus:outline-hidden focus:ring-0 whitespace-nowrap" + has_data = data[:current].any? { |p| p[:value].positive? } || data[:previous].any? { |p| p[:value].positive? } + chart_data = { + days: data[:days], + axis_labels: data[:axis_labels], + current: data[:current], + previous: data[:previous] + } +%> +
+
+

+ <%= t(".date_range", start_date: l(period.date_range.begin, format: :long), end_date: l(period.date_range.end, format: :long)) %> +

+ +
+ <%= render DS::Tooltip.new(text: t(".period_scope_hint"), placement: "top-start") %> + + <%= render DS::Menu.new(variant: :button, placement: "bottom-end", max_height: "18rem") do |menu| %> + <% menu.with_button(type: "button", class: pill_button_class, aria: { label: t(".month_picker_aria_label") }) do %> + <%= I18n.l(data[:month], format: :month_year) %> + <%= icon("chevron-down", size: "sm") %> + <% end %> + + <% (0..11).each do |i| %> + <% month = Date.current.beginning_of_month - i.months %> + <% menu.with_item( + variant: :link, + text: I18n.l(month, format: :month_year).capitalize, + href: root_path({ spending_month: month.iso8601 }), + frame: "dashboard_sections", + selected: month == data[:month] + ) %> + <% end %> + <% end %> +
+
+ +
+
+ +
+

<%= I18n.l(data[:month], format: :month_year) %>

+

+ <%= format_money data[:current_total] %> + "> + <%= format_money data[:delta] %> + +

+
+
+ +
+ +
+

<%= t(".previous_month") %>

+

<%= format_money data[:previous_total] %>

+
+
+
+ + <% if has_data %> +
"> +
+ <% else %> +
+

<%= t(".no_data") %>

+
+ <% end %> +
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 6d5876284..80579c5dd 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -118,3 +118,10 @@ en: chart_span: "Last %{count} months" income: "Income" expenses: "Expenses" + spending_trend: + title: "Spending" + date_range: "%{start_date} to %{end_date}" + month_picker_aria_label: "Select month" + period_scope_hint: "Shows the month you pick here — independent of the dashboard's time period at the top." + previous_month: "Previous month" + no_data: "No spending data for this month" diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 757cf9227..f36cec015 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -348,8 +348,102 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_select "[data-breadcrumbs]", text: /Feedback/ end + test "dashboard renders spending trend widget" do + get root_path + + assert_response :ok + assert_select "#spending-trend-section" + end + + test "dashboard spending trend widget accumulates the selected month against the previous one" do + account = @family.accounts.create!(name: "Spending Trend Test Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + # A fully past month: fixture transactions are dated relative to today and + # would otherwise leak into the expected totals. + selected_month = 2.months.ago.beginning_of_month.to_date + previous_month = 3.months.ago.beginning_of_month.to_date + + create_transaction(account: account, name: "Selected month", amount: 50, date: selected_month) + create_transaction(account: account, name: "Selected month again", amount: 25, date: selected_month + 1.day) + create_transaction(account: account, name: "Previous month", amount: 200, date: previous_month) + + get root_path, params: { spending_month: selected_month.iso8601 } + + assert_response :ok + chart = spending_trend_chart_data + + current = chart.fetch("current") + previous = chart.fetch("previous") + + # Both months are past, so both curves run their full length. + assert_equal selected_month.end_of_month.day, current.size + assert_equal previous_month.end_of_month.day, previous.size + + # Cumulative: each month's final point carries the month's total. + assert_equal 75.0, current.last.fetch("value") + assert_equal 200.0, previous.last.fetch("value") + assert_equal [ selected_month.end_of_month.day, previous_month.end_of_month.day ].max, chart.fetch("days") + end + + test "dashboard spending trend widget caps an in-progress month at today" do + account = @family.accounts.create!(name: "Spending Trend Current Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + create_transaction(account: account, name: "Today", amount: 10, date: Date.current) + + get root_path, params: { spending_month: Date.current.beginning_of_month.iso8601 } + + assert_response :ok + chart = spending_trend_chart_data + + assert_equal Date.current.day, chart.fetch("current").size + assert chart.fetch("days") >= Date.current.day + end + + test "dashboard spending trend axis labels follow the month that owns each day" do + account = @family.accounts.create!(name: "Spending Trend Axis Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + + # Find a recent past month whose previous month is longer (e.g. February + # after January), so the axis has tail days owned by the previous month. + selected_month = (1..11).map { |i| i.months.ago.beginning_of_month.to_date } + .find { |m| (m - 1.month).end_of_month.day > m.end_of_month.day } + previous_month = (selected_month - 1.month).beginning_of_month + + # Spending in both months so the widget renders the chart, not the empty state. + create_transaction(account: account, name: "Spend", amount: 10, date: selected_month) + create_transaction(account: account, name: "Prior spend", amount: 10, date: previous_month) + + get root_path, params: { spending_month: selected_month.iso8601 } + + assert_response :ok + chart = spending_trend_chart_data + labels = chart.fetch("axis_labels") + + assert_equal previous_month.end_of_month.day, chart.fetch("days") + assert_equal chart.fetch("days"), labels.size + assert_equal I18n.l(selected_month, format: :short), labels.first + # The tail day belongs to the previous, longer month - not a date rolled + # past the selected month's end (e.g. "Jan 31", not "Mar 3"). + assert_equal I18n.l(previous_month.end_of_month, format: :short), labels.last + end + + test "dashboard spending trend widget clamps invalid and future month params" do + account = @family.accounts.create!(name: "Spending Trend Clamp Checking", currency: @family.currency, balance: 0, accountable: Depository.new) + create_transaction(account: account, name: "Today", amount: 10, date: Date.current) + + get root_path, params: { spending_month: "not-a-date" } + assert_response :ok + + get root_path, params: { spending_month: 2.months.from_now.to_date.iso8601 } + assert_response :ok + + chart = spending_trend_chart_data + assert_equal Date.current.beginning_of_month.iso8601, chart.fetch("current").first.fetch("date") + end + private def money_flow_bars JSON.parse(css_select("[data-controller='bar-chart']").first["data-bar-chart-data-value"]) end + + def spending_trend_chart_data + JSON.parse(css_select("[data-controller='spending-chart']").first["data-spending-chart-data-value"]) + end end diff --git a/test/models/income_statement/daily_expense_totals_test.rb b/test/models/income_statement/daily_expense_totals_test.rb new file mode 100644 index 000000000..1d93687b0 --- /dev/null +++ b/test/models/income_statement/daily_expense_totals_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class IncomeStatement::DailyExpenseTotalsTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:empty) + @checking = @family.accounts.create! name: "Checking", currency: @family.currency, balance: 5000, accountable: Depository.new + @period = Period.custom(start_date: 9.days.ago.to_date, end_date: Date.current) + end + + test "groups expense totals by day" do + create_transaction(account: @checking, amount: 100, date: 2.days.ago.to_date) + create_transaction(account: @checking, amount: 50, date: 2.days.ago.to_date) + create_transaction(account: @checking, amount: 25, date: Date.current) + + series = daily_series + by_date = series.index_by(&:date) + + assert_equal 2, series.size + assert_equal 150, by_date[2.days.ago.to_date].total + assert_equal 25, by_date[Date.current].total + end + + test "excludes income, budget-excluded kinds, and pending transactions" do + create_transaction(account: @checking, amount: -500, date: Date.current) # income + create_transaction(account: @checking, amount: 40, date: Date.current, kind: "funds_movement") + create_transaction(account: @checking, amount: 30, date: Date.current, kind: "cc_payment") + pending_entry = create_transaction(account: @checking, amount: 20, date: Date.current) + pending_entry.entryable.update!(extra: { "simplefin" => { "pending" => true } }) + create_transaction(account: @checking, amount: 10, date: Date.current) + + series = daily_series + + assert_equal 1, series.size + assert_equal 10, series.first.total + end + + test "excludes entries marked as excluded" do + create_transaction(account: @checking, amount: 100, date: Date.current, excluded: true) + create_transaction(account: @checking, amount: 10, date: Date.current) + + assert_equal 10, daily_series.first.total + end + + test "counts loan payments and investment contributions as expenses" do + create_transaction(account: @checking, amount: -200, date: Date.current, kind: "loan_payment") + create_transaction(account: @checking, amount: -300, date: Date.current, kind: "investment_contribution") + + assert_equal 500, daily_series.first.total + end + + test "converts foreign currency amounts at the day's exchange rate" do + eur_account = @family.accounts.create! name: "EUR Checking", currency: "EUR", balance: 1000, accountable: Depository.new + ExchangeRate.create! from_currency: "EUR", to_currency: @family.currency, date: Date.current, rate: 2 + + create_transaction(account: eur_account, amount: 100, currency: "EUR", date: Date.current) + + assert_equal 200, daily_series.first.total + end + + test "falls back to no conversion when the day's rate is missing" do + eur_account = @family.accounts.create! name: "EUR Checking", currency: "EUR", balance: 1000, accountable: Depository.new + + create_transaction(account: eur_account, amount: 100, currency: "EUR", date: Date.current) + + assert_equal 100, daily_series.first.total + end + + test "returns days in chronological order" do + create_transaction(account: @checking, amount: 10, date: Date.current) + create_transaction(account: @checking, amount: 20, date: 3.days.ago.to_date) + + assert_equal [ 3.days.ago.to_date, Date.current ], daily_series.map(&:date) + end + + private + def daily_series + IncomeStatement.new(@family).daily_expense_series(period: @period) + end +end diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 00c9ca9c8..4068775c4 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -20,6 +20,33 @@ class IncomeStatementTest < ActiveSupport::TestCase create_transaction(account: @credit_card_account, amount: 400, category: @groceries_category) end + test "daily_expense_series cache busts when the family currency changes" do + statement = IncomeStatement.new(@family) + period = Period.last_30_days + + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + IncomeStatement::DailyExpenseTotals.expects(:new).twice.returns(stub(call: [])) + + statement.daily_expense_series(period: period) + @family.update!(currency: "EUR") + statement.daily_expense_series(period: period) + end + + test "daily_expense_series cache busts when exchange rates change" do + statement = IncomeStatement.new(@family) + period = Period.last_30_days + + Rails.stubs(:cache).returns(ActiveSupport::Cache::MemoryStore.new) + IncomeStatement::DailyExpenseTotals.expects(:new).twice.returns(stub(call: [])) + + statement.daily_expense_series(period: period) + + travel 1.second do + ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 0.9) + statement.daily_expense_series(period: period) + end + end + test "calculates totals for transactions" do income_statement = IncomeStatement.new(@family) totals = income_statement.totals(date_range: Period.last_30_days.date_range)