mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Improved UI warning states for holdings with missing data (#1098)
* Fix security price issue flow * Fix tooltip positioning and add tooltip for missing holding data * Fix tooltip controller error with stale arrow target * Lint fixes
This commit is contained in:
parent
4527482aa2
commit
1b6ce6af45
16 changed files with 141 additions and 75 deletions
|
@ -4,11 +4,11 @@ import {
|
||||||
flip,
|
flip,
|
||||||
shift,
|
shift,
|
||||||
offset,
|
offset,
|
||||||
arrow
|
autoUpdate
|
||||||
} from '@floating-ui/dom';
|
} from '@floating-ui/dom';
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["arrow", "tooltip"];
|
static targets = ["tooltip"];
|
||||||
static values = {
|
static values = {
|
||||||
placement: { type: String, default: "top" },
|
placement: { type: String, default: "top" },
|
||||||
offset: { type: Number, default: 10 },
|
offset: { type: Number, default: 10 },
|
||||||
|
@ -17,58 +17,67 @@ export default class extends Controller {
|
||||||
};
|
};
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.element.addEventListener("mouseenter", this.showTooltip);
|
this._cleanup = null;
|
||||||
this.element.addEventListener("mouseleave", this.hideTooltip);
|
this.boundUpdate = this.update.bind(this);
|
||||||
this.element.addEventListener("focus", this.showTooltip);
|
this.startAutoUpdate();
|
||||||
this.element.addEventListener("blur", this.hideTooltip);
|
this.addEventListeners();
|
||||||
};
|
}
|
||||||
|
|
||||||
showTooltip = () => {
|
|
||||||
this.tooltipTarget.style.display = 'block';
|
|
||||||
this.#update();
|
|
||||||
};
|
|
||||||
|
|
||||||
hideTooltip = () => {
|
|
||||||
this.tooltipTarget.style.display = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.element.removeEventListener("mouseenter", this.showTooltip);
|
this.removeEventListeners();
|
||||||
this.element.removeEventListener("mouseleave", this.hideTooltip);
|
this.stopAutoUpdate();
|
||||||
this.element.removeEventListener("focus", this.showTooltip);
|
}
|
||||||
this.element.removeEventListener("blur", this.hideTooltip);
|
|
||||||
};
|
|
||||||
|
|
||||||
#update() {
|
addEventListeners() {
|
||||||
|
this.element.addEventListener("mouseenter", this.show);
|
||||||
|
this.element.addEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListeners() {
|
||||||
|
this.element.removeEventListener("mouseenter", this.show);
|
||||||
|
this.element.removeEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
show = () => {
|
||||||
|
this.tooltipTarget.style.display = 'block';
|
||||||
|
this.update(); // Ensure immediate update when shown
|
||||||
|
}
|
||||||
|
|
||||||
|
hide = () => {
|
||||||
|
this.tooltipTarget.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
startAutoUpdate() {
|
||||||
|
if (!this._cleanup) {
|
||||||
|
this._cleanup = autoUpdate(
|
||||||
|
this.element,
|
||||||
|
this.tooltipTarget,
|
||||||
|
this.boundUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoUpdate() {
|
||||||
|
if (this._cleanup) {
|
||||||
|
this._cleanup();
|
||||||
|
this._cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
// Update position even if not visible, to ensure correct positioning when shown
|
||||||
computePosition(this.element, this.tooltipTarget, {
|
computePosition(this.element, this.tooltipTarget, {
|
||||||
placement: this.placementValue,
|
placement: this.placementValue,
|
||||||
middleware: [
|
middleware: [
|
||||||
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
|
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
|
||||||
flip(),
|
flip(),
|
||||||
shift({ padding: 5 }),
|
shift({ padding: 5 })
|
||||||
arrow({ element: this.arrowTarget }),
|
|
||||||
],
|
],
|
||||||
}).then(({ x, y, placement, middlewareData }) => {
|
}).then(({ x, y, placement, middlewareData }) => {
|
||||||
Object.assign(this.tooltipTarget.style, {
|
Object.assign(this.tooltipTarget.style, {
|
||||||
left: `${x}px`,
|
left: `${x}px`,
|
||||||
top: `${y}px`,
|
top: `${y}px`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { x: arrowX, y: arrowY } = middlewareData.arrow;
|
|
||||||
const staticSide = {
|
|
||||||
top: 'bottom',
|
|
||||||
right: 'left',
|
|
||||||
bottom: 'top',
|
|
||||||
left: 'right',
|
|
||||||
}[placement.split('-')[0]];
|
|
||||||
|
|
||||||
Object.assign(this.arrowTarget.style, {
|
|
||||||
left: arrowX != null ? `${arrowX}px` : '',
|
|
||||||
top: arrowY != null ? `${arrowY}px` : '',
|
|
||||||
right: '',
|
|
||||||
bottom: '',
|
|
||||||
[staticSide]: '-4px',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
}
|
}
|
|
@ -75,9 +75,11 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def alert
|
def owns_ticker?(ticker)
|
||||||
latest_sync = syncs.latest
|
security_id = Security.find_by(ticker: ticker)&.id
|
||||||
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
|
entries.account_trades
|
||||||
|
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||||
|
.where(account_trades: { security_id: security_id }).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
def favorable_direction
|
def favorable_direction
|
||||||
|
|
|
@ -25,6 +25,8 @@ class Account::Sync < ApplicationRecord
|
||||||
rescue StandardError => error
|
rescue StandardError => error
|
||||||
account.observe_unknown_issue(error)
|
account.observe_unknown_issue(error)
|
||||||
fail! error
|
fail! error
|
||||||
|
|
||||||
|
raise error if Rails.env.development?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
class Issue::PricesMissing < Issue
|
class Issue::PricesMissing < Issue
|
||||||
store_accessor :data, :missing_prices
|
store_accessor :data, :missing_prices
|
||||||
|
|
||||||
|
after_initialize :initialize_missing_prices
|
||||||
|
|
||||||
validates :missing_prices, presence: true
|
validates :missing_prices, presence: true
|
||||||
|
|
||||||
def append_missing_price(ticker, date)
|
def append_missing_price(ticker, date)
|
||||||
|
@ -10,7 +12,10 @@ class Issue::PricesMissing < Issue
|
||||||
|
|
||||||
def stale?
|
def stale?
|
||||||
stale = true
|
stale = true
|
||||||
|
|
||||||
missing_prices.each do |ticker, dates|
|
missing_prices.each do |ticker, dates|
|
||||||
|
next unless issuable.owns_ticker?(ticker)
|
||||||
|
|
||||||
oldest_date = dates.min
|
oldest_date = dates.min
|
||||||
expected_price_count = (oldest_date..Date.current).count
|
expected_price_count = (oldest_date..Date.current).count
|
||||||
prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date)
|
prices = Security::Price.find_prices(ticker: ticker, start_date: oldest_date)
|
||||||
|
@ -19,4 +24,10 @@ class Issue::PricesMissing < Issue
|
||||||
|
|
||||||
stale
|
stale
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def initialize_missing_prices
|
||||||
|
self.missing_prices ||= {}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,9 +4,13 @@
|
||||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||||
<div class="col-span-4 flex items-center gap-4">
|
<div class="col-span-4 flex items-center gap-4">
|
||||||
<%= render "shared/circle_logo", name: holding.name || "H" %>
|
<%= render "shared/circle_logo", name: holding.name || "H" %>
|
||||||
<div>
|
<div class="space-y-0.5">
|
||||||
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
<%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
|
||||||
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
<% if holding.amount %>
|
||||||
|
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||||
|
<% else %>
|
||||||
|
<%= render "missing_price_tooltip" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -15,7 +19,7 @@
|
||||||
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
|
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
|
||||||
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "?", class: "text-gray-500" %>
|
<%= tag.p "--", class: "text-gray-500 mb-5" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -28,7 +32,7 @@
|
||||||
<% if holding.amount_money %>
|
<% if holding.amount_money %>
|
||||||
<%= tag.p format_money holding.amount_money %>
|
<%= tag.p format_money holding.amount_money %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "?", class: "text-gray-500" %>
|
<%= tag.p "--", class: "text-gray-500" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
|
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,7 +42,7 @@
|
||||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= tag.p "?", class: "text-gray-500" %>
|
<%= tag.p "--", class: "text-gray-500 mb-4" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
11
app/views/account/holdings/_missing_price_tooltip.html.erb
Normal file
11
app/views/account/holdings/_missing_price_tooltip.html.erb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<div data-controller="tooltip" data-tooltip-cross-axis-value="50">
|
||||||
|
<div class="flex items-center gap-1 text-warning">
|
||||||
|
<%= lucide_icon "info", class: "w-4 h-4 shrink-0" %>
|
||||||
|
<%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
|
||||||
|
</div>
|
||||||
|
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
|
||||||
|
<div class="text-white">
|
||||||
|
<%= t(".description") %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (account:) -%>
|
<%# locals: (account:) -%>
|
||||||
<div data-controller="tooltip" data-tooltip-target="element" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
|
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
|
||||||
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
|
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
|
||||||
<div id="tooltip" role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
|
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64">
|
||||||
<div class="text-white">
|
<div class="text-white">
|
||||||
<%= t(".total_value_tooltip") %>
|
<%= t(".total_value_tooltip") %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,5 +22,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-tooltip-target="arrow"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
12
app/views/issue/_request_synth_data_action.html.erb
Normal file
12
app/views/issue/_request_synth_data_action.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<p>The Synth data provider could not find the requested data.</p>
|
||||||
|
|
||||||
|
<p>We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by
|
||||||
|
requesting the data you need.</p>
|
||||||
|
|
||||||
|
<p>Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the
|
||||||
|
following information:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>What type of data is missing?</li>
|
||||||
|
<li>Any other information you think might be helpful</li>
|
||||||
|
</ul>
|
|
@ -7,16 +7,5 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= content_for :action do %>
|
<%= content_for :action do %>
|
||||||
<p>The Synth data provider could not find the requested data.</p>
|
<%= render "issue/request_synth_data_action" %>
|
||||||
|
|
||||||
<p>We are actively developing Synth to be a low cost and easy to use data provider. You can help us improve Synth by
|
|
||||||
requesting the data you need.</p>
|
|
||||||
|
|
||||||
<p>Please post in our <%= link_to "Discord server", "https://link.maybe.co/discord", target: "_blank" %> with the
|
|
||||||
following information:</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>What type of data is missing?</li>
|
|
||||||
<li>Any other information you think might be helpful</li>
|
|
||||||
</ul>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
11
app/views/issue/prices_missings/show.html.erb
Normal file
11
app/views/issue/prices_missings/show.html.erb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<%= content_for :title, @issue.title %>
|
||||||
|
|
||||||
|
<%= content_for :description do %>
|
||||||
|
<p>Some stock prices are missing for this account.</p>
|
||||||
|
|
||||||
|
<pre><code><%= JSON.pretty_generate(@issue.data) %></code></pre>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= content_for :action do %>
|
||||||
|
<%= render "issue/request_synth_data_action" %>
|
||||||
|
<% end %>
|
|
@ -1,14 +1,15 @@
|
||||||
<%# locals: (issue:) %>
|
<%# locals: (issue:) %>
|
||||||
|
|
||||||
<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %>
|
<% priority_class = issue.critical? || issue.error? ? "bg-error/5" : "bg-warning/5" %>
|
||||||
|
<% text_class = issue.critical? || issue.error? ? "text-error" : "text-warning" %>
|
||||||
|
|
||||||
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %>
|
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 #{priority_class}" do %>
|
||||||
<div class="flex gap-3 items-center text-red-500 grow overflow-x-scroll">
|
<div class="flex gap-3 items-center grow overflow-x-scroll <%= text_class %>">
|
||||||
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
|
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
|
||||||
<p class="text-sm whitespace-nowrap"><%= issue.title %></p>
|
<p class="text-sm whitespace-nowrap"><%= issue.title %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 ml-auto">
|
<div class="flex items-center gap-4 ml-auto">
|
||||||
<%= link_to "Troubleshoot", issue_path(issue), class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %>
|
<%= link_to "Troubleshoot", issue_path(issue), class: "#{text_class} font-medium hover:underline", data: { turbo_frame: :drawer } %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
<%= turbo_frame_tag "drawer" do %>
|
<%= turbo_frame_tag "drawer" do %>
|
||||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
|
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none flex flex-col" data-controller="modal" data-action="click->modal#clickOutside">
|
||||||
<div class="flex flex-col h-full p-4">
|
<div class="flex flex-col h-full">
|
||||||
<div class="flex justify-end items-center pb-4">
|
<div class="flex justify-end items-center p-4">
|
||||||
<div data-action="click->modal#close" class="cursor-pointer p-2">
|
<div data-action="click->modal#close" class="cursor-pointer p-2">
|
||||||
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
|
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col overflow-scroll">
|
<div class="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
<%= content %>
|
<%= content %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,4 +4,5 @@ en:
|
||||||
models:
|
models:
|
||||||
issue/exchange_rate_provider_missing: Exchange rate provider missing
|
issue/exchange_rate_provider_missing: Exchange rate provider missing
|
||||||
issue/exchange_rates_missing: Exchange rates missing
|
issue/exchange_rates_missing: Exchange rates missing
|
||||||
|
issue/missing_prices: Missing prices
|
||||||
issue/unknown: Unknown issue occurred
|
issue/unknown: Unknown issue occurred
|
||||||
|
|
|
@ -15,6 +15,10 @@ en:
|
||||||
no_holdings: No holdings to show.
|
no_holdings: No holdings to show.
|
||||||
return: total return
|
return: total return
|
||||||
weight: weight
|
weight: weight
|
||||||
|
missing_price_tooltip:
|
||||||
|
description: This investment has missing values and we could not calculate
|
||||||
|
its returns or value.
|
||||||
|
missing_data: Missing data
|
||||||
show:
|
show:
|
||||||
history: History
|
history: History
|
||||||
overview: Overview
|
overview: Overview
|
||||||
|
|
|
@ -89,4 +89,12 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date)
|
assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date)
|
||||||
assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date)
|
assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "can observe missing price" do
|
||||||
|
account = accounts(:investment)
|
||||||
|
|
||||||
|
assert_difference -> { account.issues.count } do
|
||||||
|
account.observe_missing_price(ticker: "AAPL", date: Date.current)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,9 +11,11 @@ class TooltipsTest < ApplicationSystemTestCase
|
||||||
|
|
||||||
test "can see account information tooltip" do
|
test "can see account information tooltip" do
|
||||||
visit account_path(@account)
|
visit account_path(@account)
|
||||||
find('[data-controller="tooltip"]').hover
|
tooltip_element = find('[data-controller="tooltip"]')
|
||||||
assert find("#tooltip", visible: true)
|
tooltip_element.hover
|
||||||
within "#tooltip" do
|
tooltip_contents = find('[data-tooltip-target="tooltip"]')
|
||||||
|
assert tooltip_contents.visible?
|
||||||
|
within tooltip_contents do
|
||||||
assert_text I18n.t("accounts.tooltip.total_value_tooltip")
|
assert_text I18n.t("accounts.tooltip.total_value_tooltip")
|
||||||
assert_text I18n.t("accounts.tooltip.holdings")
|
assert_text I18n.t("accounts.tooltip.holdings")
|
||||||
assert_text format_money(@account.investment.holdings_value, precision: 0)
|
assert_text format_money(@account.investment.holdings_value, precision: 0)
|
||||||
|
@ -21,6 +23,6 @@ class TooltipsTest < ApplicationSystemTestCase
|
||||||
assert_text format_money(@account.balance_money, precision: 0)
|
assert_text format_money(@account.balance_money, precision: 0)
|
||||||
end
|
end
|
||||||
find("body").click
|
find("body").click
|
||||||
assert find("#tooltip", visible: false)
|
assert find('[data-tooltip-target="tooltip"]', visible: false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue