Files
sure/app/views/reports/_net_worth.html.erb
kianrafiee 40b18e8484 feat(reports): add net worth chart with breakdown tooltip (#2716)
* Add monthly net worth chart with group breakdown to Reports

Adds a net worth trend chart to the Reports > Net Worth section,
rendered in the same design scheme as the dashboard chart. The chart
shows one data point per month across the period selected at the top
of the Reports page, and its hover tooltip breaks the hovered month
down into per-account-group balances (Cash, Investments, Credit
Cards, Loans, etc.) under Assets and Liabilities headings with
section totals.

- BalanceSheet::NetWorthBreakdownSeriesBuilder builds the monthly
  series by running Balance::ChartSeriesBuilder per account group
  (grouped by accountable type), with liabilities reported as
  positive magnitudes and all-zero groups omitted; cached with the
  same invalidation pattern as the existing net worth series
- net_worth_chart Stimulus controller extends the existing
  time_series_chart controller, overriding only data normalization
  and the tooltip template
- Reports controller passes the series through the existing
  net_worth_metrics hash; tooltip headings reuse existing locale keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: month-over-month tooltip deltas, Stimulus value labels

- Recompute each chart point's trend from the previous monthly point
  instead of inheriting the raw series trend, which at a monthly
  interval compared the underlying balance row's own start/end and so
  reflected only the last balance update before the sample date
  (chatgpt-codex-connector). The first point has no prior month and
  renders the standard flat state.
- Pass tooltip section labels to the Stimulus controller as declared
  values (data-*-value attributes) per coding guidelines (coderabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 05:59:32 +02:00

108 lines
5.2 KiB
Plaintext

<div class="space-y-6">
<%# Main Net Worth Stats %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%# Current Net Worth %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.current_net_worth") %></p>
<p class="text-2xl font-semibold privacy-sensitive <%= net_worth_metrics[:current_net_worth] >= 0 ? "text-success" : "text-destructive" %>">
<%= net_worth_metrics[:current_net_worth].format %>
</p>
</div>
<%# Period Change %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.period_change") %></p>
<% if net_worth_metrics[:trend] %>
<% trend = net_worth_metrics[:trend] %>
<p class="text-2xl font-semibold mb-1 privacy-sensitive" style="color: <%= trend.color %>">
<%= trend.value.format(signify_positive: true) %>
</p>
<p class="text-xs" style="color: <%= trend.color %>">
<%= trend.value >= 0 ? "+" : "" %><%= trend.percent_formatted %>
</p>
<% else %>
<p class="text-2xl font-semibold mb-1 text-subdued">--</p>
<% end %>
</div>
<%# Assets vs Liabilities %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-sm text-secondary mb-2"><%= t("reports.net_worth.assets_vs_liabilities") %></p>
<div class="flex items-baseline gap-2 flex-wrap">
<span class="text-lg font-semibold text-primary break-words privacy-sensitive"><%= net_worth_metrics[:total_assets].format %></span>
<span class="text-xs text-subdued shrink-0">-</span>
<span class="text-lg font-semibold text-primary break-words privacy-sensitive"><%= net_worth_metrics[:total_liabilities].format %></span>
</div>
</div>
</div>
<%# Net Worth Over Time chart %>
<% breakdown_series = net_worth_metrics[:breakdown_series] %>
<% if breakdown_series && breakdown_series[:values].size >= 2 %>
<div
id="reportsNetWorthChart"
class="w-full h-72 privacy-sensitive"
data-controller="net-worth-chart"
data-net-worth-chart-data-value="<%= breakdown_series.to_json %>"
data-net-worth-chart-assets-label-value="<%= t("reports.net_worth.total_assets") %>"
data-net-worth-chart-liabilities-label-value="<%= t("reports.net_worth.total_liabilities") %>"></div>
<% end %>
<%# Asset/Liability Breakdown %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<%# Assets Summary %>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto w-full flex flex-col">
<div class="rounded-lg shadow-border-xs bg-container overflow-hidden">
<table class="w-full">
<thead class="bg-container-inset">
<tr class="uppercase text-xs font-medium text-secondary">
<th class="px-4 py-2 text-left font-medium"><%= t("reports.net_worth.total_assets") %></th>
<th class="px-4 py-2 text-right font-medium"><span class="sr-only"><%= t("reports.transactions_breakdown.table.amount") %></span></th>
</tr>
</thead>
<tbody>
<% net_worth_metrics[:asset_groups].each_with_index do |group, idx| %>
<tr class="<%= idx < net_worth_metrics[:asset_groups].size - 1 ? "table-divider" : "" %>">
<td class="py-3 px-4 lg:px-6 text-sm font-medium text-secondary"><%= group[:name] %></td>
<td class="py-3 px-4 lg:px-6 text-sm font-medium text-right text-primary privacy-sensitive"><%= group[:total].format %></td>
</tr>
<% end %>
<% if net_worth_metrics[:asset_groups].empty? %>
<tr>
<td colspan="2" class="text-sm text-subdued py-3 px-4 lg:px-6"><%= t("reports.net_worth.no_assets") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<%# Liabilities Summary %>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto w-full flex flex-col">
<div class="rounded-lg shadow-border-xs bg-container overflow-hidden">
<table class="w-full">
<thead class="bg-container-inset">
<tr class="uppercase text-xs font-medium text-secondary">
<th class="px-4 py-2 text-left font-medium"><%= t("reports.net_worth.total_liabilities") %></th>
<th class="px-4 py-2 text-right font-medium"><span class="sr-only"><%= t("reports.transactions_breakdown.table.amount") %></span></th>
</tr>
</thead>
<tbody>
<% net_worth_metrics[:liability_groups].each_with_index do |group, idx| %>
<tr class="<%= idx < net_worth_metrics[:liability_groups].size - 1 ? "table-divider" : "" %>">
<td class="py-3 px-4 lg:px-6 text-sm font-medium text-secondary"><%= group[:name] %></td>
<td class="py-3 px-4 lg:px-6 text-sm font-medium text-right text-primary privacy-sensitive"><%= group[:total].format %></td>
</tr>
<% end %>
<% if net_worth_metrics[:liability_groups].empty? %>
<tr>
<td colspan="2" class="text-sm text-subdued py-3 px-4 lg:px-6"><%= t("reports.net_worth.no_liabilities") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>