mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 13:35:21 +02:00
Dashboard View and Calculations (#521)
* Handle Turbo updates with tabs Fixes #491 * Add Filterable concern for controllers * Add trendline chart * Extract common UI to partials * Series refactor * Put placeholders for calculations in * Add classification generated column to account * Add basic net worth calculation * Add net worth tests * Get net worth graph working * Fix lint errors * Implement asset grouping query * Make trends and series more intuitive * Fully functional dashboard * Remove logging
This commit is contained in:
parent
680a91d807
commit
6f0e410684
37 changed files with 594 additions and 74 deletions
|
@ -1,4 +1,4 @@
|
|||
<%# locals: (account:, valuation_series:) %>
|
||||
<%# locals: (account:, valuations:) %>
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="font-medium text-lg">History</h3>
|
||||
|
@ -21,7 +21,7 @@
|
|||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||
<%= turbo_frame_tag dom_id(Valuation.new) %>
|
||||
<%= turbo_frame_tag "valuations_list" do %>
|
||||
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuation_series, classification: account.classification } %>
|
||||
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuations } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<%# locals: (valuation_series:, classification:) %>
|
||||
<%# locals: (valuation_series:) %>
|
||||
<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %>
|
||||
<% valuation, trend = valuation_item.values_at(:raw, :trend) %>
|
||||
<% valuation_styles = trend_styles(valuation_item[:trend]) %>
|
||||
<% valuation_styles = trend_styles(trend) %>
|
||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
||||
<div class="p-4 flex items-center">
|
||||
<div class="w-16">
|
||||
|
|
|
@ -34,18 +34,10 @@
|
|||
}
|
||||
%>
|
||||
</div>
|
||||
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %>
|
||||
<%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm py-2 pr-8 pl-2 cursor-pointer", onchange: "this.form.submit();" } %>
|
||||
<% end %>
|
||||
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: account_path(@account) } %>
|
||||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
<% if @balance_series %>
|
||||
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= @balance_series.serialize_for_d3_chart %>"></div>
|
||||
<% else %>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-gray-500">No data available for the selected period.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
|
||||
</div>
|
||||
</div>
|
||||
<div data-controller="tabs" data-tabs-active-class="bg-gray-100" data-tabs-default-tab-value="account-history-tab">
|
||||
|
@ -55,7 +47,7 @@
|
|||
</div>
|
||||
<div class="min-h-[800px]">
|
||||
<div data-tabs-target="tab" id="account-history-tab">
|
||||
<%= render partial: "accounts/account_history", locals: { account: @account, valuation_series: @valuation_series } %>
|
||||
<%= render partial: "accounts/account_history", locals: { account: @account, valuations: @valuation_series } %>
|
||||
</div>
|
||||
<div data-tabs-target="tab" id="account-transactions-tab" class="hidden">
|
||||
<%= render partial: "accounts/transactions", locals: { transactions: @account.transactions.order(date: :desc) } %>
|
||||
|
|
51
app/views/pages/_account_group_disclosure.erb
Normal file
51
app/views/pages/_account_group_disclosure.erb
Normal file
|
@ -0,0 +1,51 @@
|
|||
<%# locals: (account_group:) %>
|
||||
<% accountable_type, account_details = account_group%>
|
||||
<% text_class = accountable_text_class(accountable_type) %>
|
||||
<details class="open:bg-gray-25 group">
|
||||
<summary class="flex p-4 items-center w-full rounded-lg font-medium hover:bg-gray-50 text-gray-500 text-sm font-medium cursor-pointer">
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5") %>
|
||||
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(accountable_type) %>"></div>
|
||||
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(accountable_type)) %></p>
|
||||
<span class="mx-1">·</span>
|
||||
<div ><%= account_details[:accounts].size %></div>
|
||||
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-gray-900">
|
||||
<div class="flex items-center justify-end gap-2 w-24">
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: account_details[:allocation], text_class: text_class } %>
|
||||
<p><%= account_details[:allocation] %>%</p>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<p><%= format_currency account_details[:end_balance] %></p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<% account_details[:accounts].map do |account| %>
|
||||
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(accountable_type) %>">
|
||||
<%= account[:name][0].upcase %>
|
||||
</div>
|
||||
<div>
|
||||
<p><%= account[:name] %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-10 items-center text-right">
|
||||
<div class="flex items-center justify-end gap-2 w-24">
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: account[:allocation], text_class: text_class } %>
|
||||
<p><%= account[:allocation] %>%</p>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<p><%= format_currency account[:end_balance] %></p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
17
app/views/pages/_account_percentages_bar.html.erb
Normal file
17
app/views/pages/_account_percentages_bar.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<%# locals: (account_groups:) %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-1">
|
||||
<% account_groups.each do |type, value| %>
|
||||
<div class="h-1.5 rounded-sm w-12 <%= accountable_bg_class(type) %>" style="width: <%= value[:allocation] %>%;"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<% account_groups.each do |type, value| %>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div class="h-2.5 w-2.5 rounded-full <%= accountable_bg_class(type) %>"></div>
|
||||
<p class="text-gray-500"><%= to_accountable_title(Accountable.from_type(type)) %></p>
|
||||
<p class="text-black"><%= value[:allocation] %>%</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
20
app/views/pages/_account_percentages_table.html.erb
Normal file
20
app/views/pages/_account_percentages_table.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
|||
<%# locals: (account_groups:) %>
|
||||
<div class="bg-gray-25 p-1 rounded-xl">
|
||||
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-gray-500">
|
||||
<div>Name</div>
|
||||
<div class="ml-auto text-right flex items-center gap-10">
|
||||
<div class="w-24">
|
||||
<p>% of total</p>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<p>Value</p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<p>Change</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-lg divide-y divide-alpha-black-50">
|
||||
<%= render partial: "account_group_disclosure", collection: account_groups, as: :account_group %>
|
||||
</div>
|
||||
</div>
|
|
@ -1 +1,90 @@
|
|||
<h1 class="text-3xl font-semibold font-display"><%= t('.title')%></h1>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h1 class="sr-only">Dashboard</h1>
|
||||
<p class="text-xl font-medium text-gray-900 mb-1"><%= t('.greeting', name: Current.user.first_name )%></p>
|
||||
<p class="text-gray-500 text-sm font-medium"><%= Date.current.strftime('%A, %b %d') %></p>
|
||||
</div>
|
||||
<section class="bg-white rounded-xl shadow-xs border border-alpha-black-25">
|
||||
<div class="flex justify-between p-4">
|
||||
<div>
|
||||
<%= render partial: "shared/balance_heading", locals: {
|
||||
label: "Net Worth",
|
||||
period: @period,
|
||||
balance: Money.from_amount(Current.family.net_worth, Current.family.currency),
|
||||
trend: @net_worth_series.trend
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
|
||||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
<%= render partial: "shared/line_chart", locals: { series: @net_worth_series } %>
|
||||
</div>
|
||||
<div class="border-t border-t-alpha-black-100 flex divide-x divide-gray-200">
|
||||
<div class="w-1/2 p-4 flex items-stretch justify-between">
|
||||
<div class="space-y-2 grow">
|
||||
<%= render partial: "shared/balance_heading", locals: {
|
||||
label: "Assets",
|
||||
period: @period,
|
||||
balance: Money.from_amount(Current.family.assets, Current.family.currency),
|
||||
trend: @asset_series.trend
|
||||
} %>
|
||||
</div>
|
||||
<div
|
||||
data-controller="trendline"
|
||||
id="assetsTrendline"
|
||||
class="h-full w-2/5"
|
||||
data-trendline-series-value="<%= @asset_series.serialize_for_d3_chart %>"
|
||||
data-trendline-classification-value="asset"
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-1/2 p-4 flex items-stretch justify-between">
|
||||
<div class="space-y-2 grow">
|
||||
<%= render partial: "shared/balance_heading", locals: {
|
||||
label: "Liabilities",
|
||||
period: @period,
|
||||
size: "md",
|
||||
balance: Money.from_amount(Current.family.liabilities, Current.family.currency),
|
||||
trend: @liability_series.trend
|
||||
} %>
|
||||
</div>
|
||||
<div
|
||||
data-controller="trendline"
|
||||
id="liabilitiesTrendline"
|
||||
class="h-full w-2/5"
|
||||
data-trendline-series-value="<%= @liability_series.serialize_for_d3_chart %>"
|
||||
data-trendline-classification-value="liability"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="p-4 bg-white rounded-xl shadow-xs border border-alpha-black-25">
|
||||
<div data-controller="tabs" data-tabs-active-class="bg-white border border-alpha-black-25 shadow-xs" data-tabs-default-tab-value="asset-summary-tab">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-1 flex gap-1 text-sm text-gray-900 font-medium">
|
||||
<button data-id="asset-summary-tab" class="px-2 py-1 rounded-md" data-tabs-target="btn" data-action="tabs#select">Assets</button>
|
||||
<button data-id="liability-summary-tab" class="px-2 py-1 rounded-md" data-tabs-target="btn" data-action="tabs#select">Liabilities</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to new_account_path, class: "flex items-center gap-1 p-2 text-gray-900 text-sm font-medium bg-gray-50 rounded-lg hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||
<p><%= t('.new') %></p>
|
||||
<% end %>
|
||||
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div data-tabs-target="tab" id="asset-summary-tab" class="space-y-6">
|
||||
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
||||
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
||||
</div>
|
||||
<div data-tabs-target="tab" id="liability-summary-tab" class="space-y-6 hidden">
|
||||
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
||||
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -15,11 +15,9 @@
|
|||
<% elsif trend.direction == "flat" %>
|
||||
<p class="text-sm text-gray-500">No change vs. prior period</p>
|
||||
<% else %>
|
||||
<% styles = trend_styles(trend) %>
|
||||
<p class="text-sm <%= styles[:text_class] %>">
|
||||
<span><%= styles[:symbol] %><%= format_currency(trend.amount.abs, precision: balance.precision) %></span>
|
||||
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||
<span class="text-gray-500"><%= period_label(period) %></span>
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: trend } %>
|
||||
<span class="text-sm text-gray-500"><%= period_label(period) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
8
app/views/shared/_line_chart.html.erb
Normal file
8
app/views/shared/_line_chart.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
|||
<%# locals: (series:) %>
|
||||
<% if series %>
|
||||
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= series.serialize_for_d3_chart %>"></div>
|
||||
<% else %>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-gray-500">No data available for the selected period.</p>
|
||||
</div>
|
||||
<% end %>
|
10
app/views/shared/_progress_circle.html.erb
Normal file
10
app/views/shared/_progress_circle.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
|||
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
|
||||
<% circumference = Math::PI * 2 * radius %>
|
||||
<% progress_percent = progress.clamp(0, 100) %>
|
||||
<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %>
|
||||
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
|
||||
<!-- Background Circle -->
|
||||
<circle class="fill-transparent stroke-current text-gray-300" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" />
|
||||
<!-- Foreground Circle -->
|
||||
<circle class="fill-transparent stroke-current <%= text_class %>" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" stroke-dasharray="<%= circumference %>" stroke-dashoffset="<%= stroke_dashoffset %>" transform="rotate(-90, <%= radius + stroke / 2 %>, <%= radius + stroke / 2 %>)" />
|
||||
</svg>
|
10
app/views/shared/_trend_change.html.erb
Normal file
10
app/views/shared/_trend_change.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
|||
<%# locals: (trend:) %>
|
||||
<% styles = trend_styles(trend) %>
|
||||
<p class="text-sm <%= styles[:text_class] %>">
|
||||
<% if trend.direction == "flat" %>
|
||||
<span>No change</span>
|
||||
<% else %>
|
||||
<span><%= styles[:symbol] %><%= format_currency(trend.amount.abs) %></span>
|
||||
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||
<% end %>
|
||||
</p>
|
Loading…
Add table
Add a link
Reference in a new issue