Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit e8eb32d

Browse files
authored
Start and end balance breakdown in activity view (#2466)
* Initial data objects * Remove trend calculator * Fill in balance reconciliation for entry group * Initial tooltip component * Balance trends in activity view * Lint fixes * trade partial alignment fix * Tweaks to balance calculation to acknowledge holdings value better * More lint fixes * Bump brakeman dep * Test fixes * Remove unused class
1 parent ab6fdbb commit e8eb32d

27 files changed

+1088
-119
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ GEM
122122
bindex (0.8.1)
123123
bootsnap (1.18.6)
124124
msgpack (~> 1.2)
125-
brakeman (7.0.2)
125+
brakeman (7.1.0)
126126
racc
127127
builder (3.3.0)
128128
capybara (3.40.0)

app/components/DS/tooltip.html.erb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
2+
<%= helpers.icon icon_name, size: size, color: color %>
3+
4+
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
5+
<div class="fg-inverse font-normal max-w-[200px]">
6+
<%= tooltip_content %>
7+
</div>
8+
</div>
9+
</span>

app/components/DS/tooltip.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class DS::Tooltip < ApplicationComponent
2+
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
3+
4+
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
5+
@text = text
6+
@placement = placement
7+
@offset = offset
8+
@cross_axis = cross_axis
9+
@icon_name = icon
10+
@size = size
11+
@color = color
12+
end
13+
14+
def tooltip_content
15+
content? ? content : @text
16+
end
17+
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {
2+
autoUpdate,
3+
computePosition,
4+
flip,
5+
offset,
6+
shift,
7+
} from "@floating-ui/dom";
8+
import { Controller } from "@hotwired/stimulus";
9+
10+
export default class extends Controller {
11+
static targets = ["tooltip"];
12+
static values = {
13+
placement: { type: String, default: "top" },
14+
offset: { type: Number, default: 10 },
15+
crossAxis: { type: Number, default: 0 },
16+
};
17+
18+
connect() {
19+
this._cleanup = null;
20+
this.boundUpdate = this.update.bind(this);
21+
this.addEventListeners();
22+
}
23+
24+
disconnect() {
25+
this.removeEventListeners();
26+
this.stopAutoUpdate();
27+
}
28+
29+
addEventListeners() {
30+
this.element.addEventListener("mouseenter", this.show);
31+
this.element.addEventListener("mouseleave", this.hide);
32+
}
33+
34+
removeEventListeners() {
35+
this.element.removeEventListener("mouseenter", this.show);
36+
this.element.removeEventListener("mouseleave", this.hide);
37+
}
38+
39+
show = () => {
40+
this.tooltipTarget.classList.remove("hidden");
41+
this.startAutoUpdate();
42+
this.update();
43+
};
44+
45+
hide = () => {
46+
this.tooltipTarget.classList.add("hidden");
47+
this.stopAutoUpdate();
48+
};
49+
50+
startAutoUpdate() {
51+
if (!this._cleanup) {
52+
const reference = this.element.querySelector("[data-icon]");
53+
this._cleanup = autoUpdate(
54+
reference || this.element,
55+
this.tooltipTarget,
56+
this.boundUpdate
57+
);
58+
}
59+
}
60+
61+
stopAutoUpdate() {
62+
if (this._cleanup) {
63+
this._cleanup();
64+
this._cleanup = null;
65+
}
66+
}
67+
68+
update() {
69+
const reference = this.element.querySelector("[data-icon]");
70+
computePosition(reference || this.element, this.tooltipTarget, {
71+
placement: this.placementValue,
72+
middleware: [
73+
offset({
74+
mainAxis: this.offsetValue,
75+
crossAxis: this.crossAxisValue,
76+
}),
77+
flip(),
78+
shift({ padding: 5 }),
79+
],
80+
}).then(({ x, y }) => {
81+
Object.assign(this.tooltipTarget.style, {
82+
left: `${x}px`,
83+
top: `${y}px`,
84+
});
85+
});
86+
}
87+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
2+
<details class="group">
3+
<summary>
4+
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
5+
<div class="flex pl-0.5 items-center gap-4">
6+
<%= check_box_tag "#{date}_entries_selection",
7+
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
8+
id: "selection_entry_#{date}",
9+
data: { action: "bulk-select#toggleGroupSelection" } %>
10+
11+
<p class="uppercase space-x-1.5">
12+
<%= tag.span I18n.l(date, format: :long) %>
13+
<span>&middot;</span>
14+
<%= tag.span entries.size %>
15+
</p>
16+
</div>
17+
18+
<div class="flex items-center gap-4">
19+
<div class="flex items-center gap-2">
20+
<span class="font-medium"><%= balance_trend.current.format %></span>
21+
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
22+
</div>
23+
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
24+
</div>
25+
</div>
26+
</summary>
27+
28+
<div class="p-4 space-y-3">
29+
<dl class="flex gap-4 items-center text-sm text-primary">
30+
<dt class="flex items-center gap-2">
31+
Start of day balance
32+
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
33+
</dt>
34+
<hr class="grow border-dashed border-secondary">
35+
<dd class="font-bold"><%= start_balance_money.format %></dd>
36+
</dl>
37+
38+
<% if account.balance_type == :investment %>
39+
<dl class="flex gap-4 items-center text-sm text-primary">
40+
<dt class="flex items-center gap-2">
41+
&#916; Cash
42+
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
43+
</dt>
44+
<hr class="grow border-dashed border-secondary">
45+
<dd><%= cash_change_money.format %></dd>
46+
</dl>
47+
48+
<dl class="flex gap-4 items-center text-sm text-primary">
49+
<dt class="flex items-center gap-2">
50+
&#916; Holdings
51+
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
52+
</dt>
53+
<hr class="grow border-dashed border-secondary">
54+
<dd><%= holdings_change_money.format %></dd>
55+
</dl>
56+
<% else %>
57+
<dl class="flex gap-4 items-center text-sm text-primary">
58+
<dt class="flex items-center gap-2">
59+
&#916; Cash
60+
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
61+
</dt>
62+
<hr class="grow border-dashed border-secondary">
63+
<dd><%= cash_change_money.format %></dd>
64+
</dl>
65+
<% end %>
66+
67+
<dl class="flex gap-4 items-center text-sm text-primary">
68+
<dt class="flex items-center gap-2">
69+
End of day balance
70+
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
71+
</dt>
72+
<hr class="grow border-dashed border-secondary">
73+
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
74+
</dl>
75+
76+
<hr class="border border-primary">
77+
78+
<dl class="flex gap-4 items-center text-sm text-primary">
79+
<dt class="flex items-center gap-2">
80+
&#916; Value adjustments
81+
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
82+
</dt>
83+
<hr class="grow border-dashed border-secondary">
84+
<dd><%= adjustments_money.format %></dd>
85+
</dl>
86+
87+
<dl class="flex gap-4 items-center text-sm text-primary">
88+
<dt class="flex items-center gap-2">
89+
Closing balance
90+
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
91+
</dt>
92+
<hr class="grow border-dashed border-primary">
93+
<dd class="font-bold"><%= end_balance_money.format %></dd>
94+
</dl>
95+
</div>
96+
</details>
97+
98+
<div class="bg-container shadow-border-xs rounded-lg">
99+
<% entries.each do |entry| %>
100+
<%= render entry, view_ctx: "account" %>
101+
<% end %>
102+
</div>
103+
<% end %>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
class UI::Account::ActivityDate < ApplicationComponent
2+
attr_reader :account, :data
3+
4+
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
5+
6+
def initialize(account:, data:)
7+
@account = account
8+
@data = data
9+
end
10+
11+
def id
12+
dom_id(account, "entries_#{date}")
13+
end
14+
15+
def broadcast_channel
16+
account
17+
end
18+
19+
def start_balance_money
20+
balance_trend.previous
21+
end
22+
23+
def cash_change_money
24+
cash_balance_trend.value
25+
end
26+
27+
def holdings_change_money
28+
holdings_value_trend.value
29+
end
30+
31+
def end_balance_before_adjustments_money
32+
balance_trend.previous + cash_change_money + holdings_change_money
33+
end
34+
35+
def adjustments_money
36+
end_balance_money - end_balance_before_adjustments_money
37+
end
38+
39+
def end_balance_money
40+
balance_trend.current
41+
end
42+
43+
def broadcast_refresh!
44+
Turbo::StreamsChannel.broadcast_replace_to(
45+
broadcast_channel,
46+
target: id,
47+
renderable: self,
48+
layout: false
49+
)
50+
end
51+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<%= turbo_frame_tag dom_id(account, "entries") do %>
2+
<div class="bg-container p-5 shadow-border-xs rounded-xl">
3+
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
4+
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
5+
6+
<% if account.manual? %>
7+
<%= render DS::Menu.new(variant: "button") do |menu| %>
8+
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
9+
10+
<% menu.with_item(
11+
variant: "link",
12+
text: "New balance",
13+
icon: "circle-dollar-sign",
14+
href: new_valuation_path(account_id: account.id),
15+
data: { turbo_frame: :modal }) %>
16+
17+
<% unless account.crypto? %>
18+
<% menu.with_item(
19+
variant: "link",
20+
text: "New transaction",
21+
icon: "credit-card",
22+
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
23+
data: { turbo_frame: :modal }) %>
24+
<% end %>
25+
<% end %>
26+
<% end %>
27+
</div>
28+
29+
<div>
30+
<%= form_with url: account_path(account),
31+
id: "entries-search",
32+
scope: :q,
33+
method: :get,
34+
data: { controller: "auto-submit-form" } do |form| %>
35+
<div class="flex gap-2 mb-4">
36+
<div class="grow">
37+
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
38+
<%= helpers.icon("search") %>
39+
40+
<%= hidden_field_tag :account_id, account.id %>
41+
42+
<%= form.search_field :search,
43+
placeholder: "Search entries by name",
44+
value: search,
45+
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
46+
"data-auto-submit-form-target": "auto" %>
47+
</div>
48+
</div>
49+
</div>
50+
<% end %>
51+
</div>
52+
53+
<% if activity_dates.empty? %>
54+
<p class="text-secondary text-sm p-4">No entries yet</p>
55+
<% else %>
56+
<%= tag.div id: dom_id(account, "entries_bulk_select"),
57+
data: {
58+
controller: "bulk-select",
59+
bulk_select_singular_label_value: "entry",
60+
bulk_select_plural_label_value: "entries"
61+
} do %>
62+
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
63+
<%= render "entries/selection_bar" %>
64+
</div>
65+
66+
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
67+
<div class="pl-0.5 col-span-8 flex items-center gap-4">
68+
<%= check_box_tag "selection_entry",
69+
class: "checkbox checkbox--light",
70+
data: { action: "bulk-select#togglePageSelection" } %>
71+
<p>Date</p>
72+
</div>
73+
74+
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
75+
</div>
76+
77+
<div>
78+
<div class="space-y-4">
79+
<% activity_dates.each do |activity_date_data| %>
80+
<%= render UI::Account::ActivityDate.new(
81+
account: account,
82+
data: activity_date_data
83+
) %>
84+
<% end %>
85+
</div>
86+
87+
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
88+
<%= render "shared/pagination", pagy: pagy %>
89+
</div>
90+
</div>
91+
<% end %>
92+
<% end %>
93+
</div>
94+
<% end %>

0 commit comments

Comments
 (0)