1
0
Fork 0
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:
Zach Gollwitzer 2024-08-16 16:08:27 -04:00 committed by GitHub
parent 4527482aa2
commit 1b6ce6af45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 141 additions and 75 deletions

View file

@ -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',
});
}); });
}; }
} }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View 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>

View file

@ -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>

View 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>

View file

@ -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 %>

View 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 %>

View file

@ -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 %>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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