Compare commits

...

14 Commits

Author SHA1 Message Date
Zach Gollwitzer
f3c44464be Update version.rb
Bump to v0.1.0-alpha.14

Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-09 17:42:48 -04:00
Zach Gollwitzer
c0908f454a Temp fix for missing accountables on self hosted instances (#1071)
* Temp fix #1068

* Cleanup
2024-08-09 13:31:32 -04:00
Zach Gollwitzer
e05f03b314 Allow user to add buy and sell trade transactions for investment accounts (#1066)
* Consolidate modal form structure into partial + helper

* Scaffold out trade transaction form

* Normalize translations

* Add buy and sell trade form with tests

* Move entryable lists to dedicated controllers

* Delegate entry group contents rendering

* More cleanup

* Extract transaction and valuation update logic from entries controller

* Delegate edit and show actions to entryables

* Trade builder

* Update paths for transaction updates
2024-08-09 11:22:57 -04:00
Tony Vincent
6bca35fa22 Fix minitest assert_nil warning (#1070)
* Fix minitest assert_nil warning

* Remove empty line

* Fix my stupidity
2024-08-09 10:58:01 -04:00
Tony Vincent
6fa40e0fa2 Fetch exchange rates in bulk from synth (#1069)
* Fetch exchnage rates in bulk

* Handle paginated response

* Rename method and improve tests

* Change argument names

* Use standard date format
2024-08-09 10:57:33 -04:00
Tony Vincent
f315370512 Add stimulus tooltip controller (#1065)
* Add Tooltip Stimulus controller

* Add test for tooltip

* Remove comma

* Normalize translations

* Use floating-ui instead popper

* Use component classes

* Increase cross axis value

* Cleanup

* Update app/views/accounts/show.html.erb

Use correct tailwind class

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Tony Vincent <tonyvince7@gmail.com>

* Use default values for options

* Remove tooltip global variable

* Add arrow target

* Remove unused method

---------

Signed-off-by: Tony Vincent <tonyvince7@gmail.com>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-08-08 06:53:27 -04:00
Zach Gollwitzer
6e74414cb2 Add source headers to Synth calls (#1062) 2024-08-05 12:21:12 -04:00
dependabot[bot]
9ad04a82cb Bump rails from 5cb5cad to 43530b4 (#1059)
Bumps [rails](https://github.com/rails/rails) from `5cb5cad` to `43530b4`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](5cb5cad322...43530b4ac9)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:13:34 -04:00
dependabot[bot]
7c878697f4 Bump pagy from 9.0.3 to 9.0.5 (#1056)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.3 to 9.0.5.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.3...9.0.5)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:13:03 -04:00
dependabot[bot]
cdb134077d Bump good_job from 4.1.0 to 4.1.1 (#1053)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 09:00:32 -04:00
dependabot[bot]
65aeab4681 Bump aws-sdk-s3 from 1.156.0 to 1.157.0 (#1054)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.156.0 to 1.157.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:59:30 -04:00
dependabot[bot]
e0d2b951d6 Bump erb_lint from 0.5.0 to 0.6.0 (#1057)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: erb_lint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:58:05 -04:00
dependabot[bot]
4eeca00121 Bump faraday from 2.10.0 to 2.10.1 (#1055)
Bumps [faraday](https://github.com/lostisland/faraday) from 2.10.0 to 2.10.1.
- [Release notes](https://github.com/lostisland/faraday/releases)
- [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lostisland/faraday/compare/v2.10.0...v2.10.1)

---
updated-dependencies:
- dependency-name: faraday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:57:19 -04:00
dependabot[bot]
07a7a6b1aa Bump tailwindcss-rails from 2.6.4 to 2.6.5 (#1058)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.4 to 2.6.5.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.4...v2.6.5)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 08:57:03 -04:00
96 changed files with 1344 additions and 675 deletions

View File

@@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
Layout/ElseAlignment:
Enabled: false
Layout/EndAlignment:
Enabled: false

View File

@@ -7,7 +7,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 5cb5cad3224d03114313fbe28c4dd1374c313d8f
revision: 43530b4ac911b8722b8a7ac8025eb9298e1292b4
branch: 7-2-stable
specs:
actioncable (7.2.0.beta3)
@@ -35,7 +35,7 @@ GIT
activesupport (= 7.2.0.beta3)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
@@ -78,6 +78,7 @@ GIT
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
rails (7.2.0.beta3)
actioncable (= 7.2.0.beta3)
@@ -109,8 +110,8 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-partitions (1.961.0)
aws-sdk-core (3.201.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
@@ -118,11 +119,11 @@ GEM
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.156.0)
aws-sdk-s3 (1.157.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
@@ -168,22 +169,22 @@ GEM
dotenv (= 3.1.2)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.5.0)
erb_lint (0.6.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
rainbow
rubocop
rubocop (>= 1)
smart_properties
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.2)
i18n (>= 1.8.11, < 2)
faraday (2.10.0)
faraday (2.10.1)
faraday-net_http (>= 2.0, < 3.2)
logger
faraday-net_http (3.1.0)
faraday-net_http (3.1.1)
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
@@ -198,7 +199,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.1.0)
good_job (4.1.1)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -292,9 +293,9 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.0.3)
parallel (1.24.0)
parser (3.3.1.0)
pagy (9.0.5)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
pg (1.5.7)
@@ -345,15 +346,15 @@ GEM
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
rexml (3.3.2)
rexml (3.3.4)
strscan
rubocop (1.63.5)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
@@ -392,6 +393,7 @@ GEM
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.23.0)
base64 (~> 0.2)
logger (~> 1.4)
@@ -417,17 +419,17 @@ GEM
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.4)
tailwindcss-rails (2.6.5)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-aarch64-linux)
tailwindcss-rails (2.6.5-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-arm-linux)
tailwindcss-rails (2.6.5-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-arm64-darwin)
tailwindcss-rails (2.6.5-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-x86_64-darwin)
tailwindcss-rails (2.6.5-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.4-x86_64-linux)
tailwindcss-rails (2.6.5-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)

View File

@@ -4,11 +4,11 @@
/* Reset rules, default styles applied to plain HTML */
@layer base {
details > summary::-webkit-details-marker {
details>summary::-webkit-details-marker {
@apply hidden;
}
details > summary {
details>summary {
@apply list-none;
}
}
@@ -38,7 +38,7 @@
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
}
input:checked + label + .toggle-switch-dot {
input:checked+label+.toggle-switch-dot {
transform: translateX(100%);
}
@@ -90,6 +90,10 @@
@apply font-bold;
}
}
.tooltip {
@apply hidden absolute;
}
}
/* Small, single purpose classes that should take precedence over other styles */

View File

@@ -4,49 +4,12 @@ class Account::EntriesController < ApplicationController
before_action :set_account
before_action :set_entry, only: %i[ edit update show destroy ]
def transactions
@transaction_entries = @account.entries.account_transactions.reverse_chronological
end
def valuations
@valuation_entries = @account.entries.account_valuations.reverse_chronological
end
def trades
@trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).reverse_chronological
end
def new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
else
entry.entryable = Account::Valuation.new
end
end
end
def create
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
if @entry.save
@entry.sync_account_later
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
else
# TODO: this is not an ideal way to handle errors and should eventually be improved.
# See: https://github.com/hotwired/turbo-rails/pull/367
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def edit
render entryable_view_path(:edit)
end
def update
@entry.assign_attributes entry_params
@entry.amount = amount if nature.present?
@entry.save!
@entry.update!(entry_params)
@entry.sync_account_later
respond_to do |format|
@@ -56,6 +19,7 @@ class Account::EntriesController < ApplicationController
end
def show
render entryable_view_path(:show)
end
def destroy
@@ -66,6 +30,10 @@ class Account::EntriesController < ApplicationController
private
def entryable_view_path(action)
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
end
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
@@ -74,36 +42,7 @@ class Account::EntriesController < ApplicationController
@entry = @account.entries.find(params[:id])
end
def permitted_entryable_attributes
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
case entryable_type
when "Account::Transaction"
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
else
[ :id ]
end
end
def entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
end
def amount
if nature.income?
entry_params[:amount].to_d.abs * -1
else
entry_params[:amount].to_d.abs
end
end
def nature
params[:account_entry][:nature].to_s.inquiry
end
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
def entry_params_with_defaults(params)
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
end

View File

@@ -0,0 +1,34 @@
class Account::TradesController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_trades.new(entryable_attributes: {})
end
def index
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ])
end
def create
@builder = Account::TradeBuilder.new(entry_params)
if entry = @builder.save
entry.sync_account_later
redirect_to account_path(@account), notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry).permit(:type, :date, :qty, :ticker, :price).merge(account: @account)
end
end

View File

@@ -0,0 +1,53 @@
class Account::TransactionsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: :update
def index
@entries = @account.entries.account_transactions.reverse_chronological
end
def update
@entry.update!(entry_params.merge(amount: amount))
@entry.sync_account_later
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :entryable_type,
entryable_attributes: [
:id,
:notes,
:excluded,
:category_id,
:merchant_id,
{ tag_ids: [] }
]
)
end
def amount
if params[:account_entry][:nature] == "income"
entry_params[:amount].to_d * -1
else
entry_params[:amount].to_d
end
end
end

View File

@@ -0,0 +1,35 @@
class Account::ValuationsController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_valuations.new(entryable_attributes: {})
end
def create
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
if @entry.save
@entry.sync_account_later
redirect_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
end
end
def index
@entries = @account.entries.account_valuations.reverse_chronological
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
end

View File

@@ -30,6 +30,28 @@ module Account::EntriesHelper
mixed_hex_styles(color)
end
def entry_name(entry)
if entry.account_trade?
trade = entry.account_trade
prefix = trade.sell? ? "Sell " : "Buy "
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
name = entry.name || generated
name
else
entry.name
end
end
def entries_by_date(entries, selectable: true)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
end
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
end.join.html_safe
end
private
def permitted_entryable_key(entry)

View File

@@ -26,9 +26,9 @@ module AccountsHelper
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) }
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?

View File

@@ -4,6 +4,12 @@ module FormsHelper
form_with(**options, &block)
end
def modal_form_wrapper(title:, subtitle: nil, &block)
content = capture &block
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
end
def form_field_tag(options = {}, &block)
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
tag.div(**options, &block)
@@ -23,17 +29,17 @@ module FormsHelper
def money_with_currency_field(form, money_method, options = {})
render partial: "shared/money_field", locals: {
form: form,
money_method: money_method,
form: form,
money_method: money_method,
default_currency: options[:default_currency] || "USD",
disable_currency: options[:disable_currency] || false,
hide_currency: options[:hide_currency] || false,
label: options[:label] || "Amount"
label: options[:label] || "Amount"
}
end
def money_field(form, method, options = {})
value = form.object.send(method)
value = form.object ? form.object.send(method) : nil
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
@@ -42,10 +48,10 @@ module FormsHelper
money_options = {
value: value&.amount,
placeholder: 100,
min: -99999999999999,
max: 99999999999999,
step: currency.step
placeholder: "100",
min: -99999999999999,
max: 99999999999999,
step: currency.step
}
merged_options = options.merge(money_options)

View File

@@ -0,0 +1,74 @@
import { Controller } from '@hotwired/stimulus'
import {
computePosition,
flip,
shift,
offset,
arrow
} from '@floating-ui/dom';
export default class extends Controller {
static targets = ["arrow", "tooltip"];
static values = {
placement: { type: String, default: "top" },
offset: { type: Number, default: 10 },
crossAxis: { type: Number, default: 0 },
alignmentAxis: { type: Number, default: null },
};
connect() {
this.element.addEventListener("mouseenter", this.showTooltip);
this.element.addEventListener("mouseleave", this.hideTooltip);
this.element.addEventListener("focus", this.showTooltip);
this.element.addEventListener("blur", this.hideTooltip);
};
showTooltip = () => {
this.tooltipTarget.style.display = 'block';
this.#update();
};
hideTooltip = () => {
this.tooltipTarget.style.display = '';
};
disconnect() {
this.element.removeEventListener("mouseenter", this.showTooltip);
this.element.removeEventListener("mouseleave", this.hideTooltip);
this.element.removeEventListener("focus", this.showTooltip);
this.element.removeEventListener("blur", this.hideTooltip);
};
#update() {
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
flip(),
shift({ padding: 5 }),
arrow({ element: this.arrowTarget }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
left: `${x}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

@@ -28,8 +28,6 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
delegate :value, :series, to: :accountable
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
@@ -75,6 +73,31 @@ class Account < ApplicationRecord
end
end
# Start of temporary fix for #1068
# ==========================================================================
# TODO: Both `series` and `value` methods are a temporary fix for #1068, which appears to be a data corruption issue.
# Every account should have an accountable no matter what, but some self hosted instances seem to have missing accountables.
# When this is fixed, we can add this back to `delegate :value, :series, to: :accountable`
def series(period: Period.all, currency: self.currency)
if accountable.present?
accountable.series(period: period, currency: currency)
else
TimeSeries.new([])
end
end
def value
if accountable.present?
accountable.value
else
balance_money
end
end
# ==========================================================================
# End of temporary fix for #1068
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first

View File

@@ -1,10 +1,12 @@
class Account::Trade < ApplicationRecord
include Account::Entryable
include Account::Entryable, Monetizable
monetize :price
belongs_to :security
validates :qty, presence: true, numericality: { other_than: 0 }
validates :price, presence: true
validates :price, :currency, presence: true
class << self
def search(_params)

View File

@@ -0,0 +1,46 @@
class Account::TradeBuilder
TYPES = %w[ buy sell ].freeze
include ActiveModel::Model
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true
validates :price, numericality: { greater_than: 0 }
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_entry
end
end
private
def create_entry
account.entries.account_trades.create! \
date: date,
amount: amount,
currency: account.currency,
entryable: Account::Trade.new(
security: security,
qty: signed_qty,
price: price.to_d,
currency: account.currency
)
end
def security
Security.find_or_create_by(ticker: ticker)
end
def amount
price.to_d * signed_qty
end
def signed_qty
_qty = qty.to_d
_qty = _qty * -1 if type == "sell"
_qty
end
end

View File

@@ -207,7 +207,7 @@ class Demo::Generator
unknown = Security.find_by(ticker: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD")
trades = [
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
@@ -228,7 +228,7 @@ class Demo::Generator
amount: qty * price,
currency: "USD",
name: name_prefix + "#{qty} shares of #{security.ticker}",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security)
end
end

View File

@@ -15,12 +15,11 @@ class ExchangeRate < ApplicationRecord
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = rates.map(&:date).to_set
all_dates = (start_date..end_date).to_a
existing_dates = rates.map(&:date)
missing_dates = all_dates - existing_dates
if missing_dates.any?
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
rates += fetch_rates_from_provider(from:, to:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
end
rates

View File

@@ -6,12 +6,31 @@ module ExchangeRate::Provided
class_methods do
private
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
def fetch_rates_from_provider(from:, to:, start_date:, end_date: Date.current, cache: false)
return [] unless exchange_rates_provider.present?
dates.map do |date|
fetch_rate_from_provider from:, to:, date:, cache:
end.compact
response = exchange_rates_provider.fetch_exchange_rates \
from: from,
to: to,
start_date: start_date,
end_date: end_date
if response.success?
response.rates.map do |exchange_rate|
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
date: exchange_rate.dig(:date).to_date,
rate: exchange_rate.dig(:rate)
rate.save! if cache
rate
rescue ActiveRecord::RecordNotUnique
next
end
else
[]
end
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)

View File

@@ -33,8 +33,7 @@ class Provider::Synth
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
response = client.get("#{base_url}/rates/historical") do |req|
req.params["date"] = date.to_s
req.params["from"] = from
req.params["to"] = to
@@ -58,17 +57,61 @@ class Provider::Synth
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
exchange_rates = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data").map do |exchange_rate|
{
date: exchange_rate.dig("date"),
rate: exchange_rate.dig("rates", to)
}
end
end
ExchangeRatesResponse.new \
rates: exchange_rates,
success?: true,
raw_response: exchange_rates.to_json
rescue StandardError => error
ExchangeRatesResponse.new \
success?: false,
error: error,
raw_response: error
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
end
def app_name
"maybe_app"
end
def app_type
Rails.application.config.app_mode
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.headers["Authorization"] = "Bearer #{api_key}"
faraday.headers["X-Source"] = app_name
faraday.headers["X-Source-Type"] = app_type
end
end
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch data from #{self.class}
@@ -78,7 +121,7 @@ class Provider::Synth
end
def fetch_page(url, page, params = {})
Faraday.get(url) do |req|
client.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page

View File

@@ -1,4 +1,5 @@
<%# locals: (entry:, **opts) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry: entry, **opts } %>
<% end %>

View File

@@ -1,4 +1,4 @@
<%# locals: (date:, entries:, selectable: true, combine_transfers: false, **opts) %>
<%# locals: (date:, entries:, content:, selectable:) %>
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
@@ -15,11 +15,6 @@
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<% if combine_transfers %>
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
<%= render transfer_entries(entries), selectable: false, **opts %>
<% else %>
<%= render entries, selectable:, **opts %>
<% end %>
<%= content %>
</div>
</div>

View File

@@ -1,3 +0,0 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
<% end %>

View File

@@ -1 +0,0 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>

View File

@@ -1,2 +0,0 @@
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>

View File

@@ -1 +0,0 @@
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>

View File

@@ -1,3 +0,0 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
<% end %>

View File

@@ -1 +0,0 @@
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>

View File

@@ -3,9 +3,9 @@
<%= turbo_frame_tag dom_id(holding) do %>
<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">
<%= render "shared/circle_logo", name: holding.name %>
<%= render "shared/circle_logo", name: holding.name || "H" %>
<div>
<%= link_to holding.name, 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" %>
</div>
</div>

View File

@@ -2,10 +2,10 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_holding_path(@account),
disabled: true,
<%= link_to new_account_trade_path(@account),
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>

View File

@@ -0,0 +1,19 @@
<%# locals: (entry:) %>
<%= styled_form_with data: { turbo_frame: "_top" },
scope: :account_entry,
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
<div class="space-y-4">
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell]], "buy"), label: t(".type") %>
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
<%= form.date_field :date, label: true %>
<%= form.hidden_field :currency, value: entry.account.currency %>
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
<%= form.hidden_field :currency, value: entry.account.currency %>
</div>
<%= form.submit t(".submit") %>
</div>
<% end %>

View File

@@ -13,14 +13,14 @@
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry.name[0].upcase %>
<%= entry_name(entry).first.upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<%= content_tag :p, entry_name(entry) %>
<% else %>
<%= link_to entry.name,
<%= link_to entry_name(entry),
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
@@ -31,11 +31,20 @@
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% if entry.account_transaction? && entry.marked_as_transfer? %>
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
<% elsif entry.account_transaction? %>
<%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %>
<% else %>
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
<% end %>
</div>
<div class="col-span-3 flex items-center justify-end">
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% if entry.account_transaction? %>
<%= tag.p format_money(entry.amount_money), class: { "text-green-500": entry.inflow? } %>
<% else %>
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
<% end %>
</div>
</div>

View File

@@ -2,10 +2,10 @@
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" 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"><%= t(".trades") %></h3>
<%= link_to new_account_entry_path(@account),
disabled: true,
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= link_to new_account_trade_path(@account),
id: dom_id(@account, "new_trade"),
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm"><%= t(".new") %></span>
<% end %>
@@ -15,7 +15,7 @@
<div class="pl-0.5 col-span-6 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
data: { action: "bulk-select#togglePageSelection" } %>
<%= tag.p t(".trade") %>
</div>
@@ -25,15 +25,15 @@
<div>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/entryables/trade/selection_bar" %>
<%= render "selection_bar" %>
</div>
<% if @trades.empty? %>
<% if @entries.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<% @trades.group_by(&:date).each do |date, entries| %>
<%= render "entry_group", date:, entries: entries %>
<%= entries_by_date(@entries) do |entries| %>
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,3 @@
<%= modal_form_wrapper title: t(".title") do %>
<%= render "account/trades/form", entry: @entry %>
<% end %>

View File

@@ -1,6 +1,4 @@
<%# locals: (entry:) %>
<% trade, account = entry.account_trade, entry.account %>
<% entry = @entry %>
<%= drawer do %>
<div>

View File

@@ -1,6 +1,5 @@
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
<% transaction, account = entry.account_transaction, entry.account %>
<% is_investment_transfer = entry.account.investment? && entry.transfer.present? %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
@@ -52,12 +51,6 @@
<% end %>
</div>
<% if is_investment_transfer %>
<div class="col-span-5 text-right">
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
</div>
<% end %>
<% unless entry.marked_as_transfer? %>
<% unless short %>
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
@@ -89,7 +82,7 @@
<% end %>
<% end %>
<div class="<%= is_investment_transfer ? "col-span-3" : "col-span-2" %> ml-auto">
<div class="col-span-2 ml-auto">
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %>

View File

@@ -4,7 +4,7 @@
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
<%= link_to new_transaction_path(account_id: @account),
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm"><%= t(".new") %></span>
<% end %>
@@ -12,15 +12,15 @@
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/entryables/transaction/selection_bar" %>
<%= render "selection_bar" %>
</div>
<% if @transaction_entries.empty? %>
<% if @entries.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
<% else %>
<div class="space-y-6">
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
<%= render "entry_group", date:, entries: entries %>
<%= entries_by_date(@entries) do |entries| %>
<%= render entries %>
<% end %>
</div>
<% end %>

View File

@@ -1,6 +1,4 @@
<%# locals: (entry:) %>
<% transaction, account = entry.account_transaction, entry.account %>
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
<%= drawer do %>
<div>
@@ -27,7 +25,7 @@
</summary>
<div class="pb-6">
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
<% unless entry.marked_as_transfer? %>
<div class="flex space-x-2">
@@ -60,15 +58,15 @@
</summary>
<div class="pb-6">
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.select :tag_ids,
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
{
multiple: true,
label: t(".tags_label"),
class: "placeholder:text-gray-500"
label: t(".tags_label"),
class: "placeholder:text-gray-500"
},
"data-auto-submit-form-target": "auto" %>
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
@@ -84,7 +82,7 @@
</summary>
<div class="pb-6">
<%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %>
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
@@ -110,8 +108,8 @@
<%= button_to t(".delete"),
account_entry_path(account, entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
<% end %>
</div>

View File

@@ -1,4 +1,11 @@
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
<% if transfer.errors.present? %>
<div class="text-red-600 flex items-center gap-2">
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
</div>
<% end %>
<section>
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>

View File

@@ -1,17 +1,3 @@
<%= modal do %>
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
<header class="flex justify-between">
<%= tag.h2 t(".title"), class: "font-medium text-xl" %>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<% if @transfer.errors.present? %>
<div class="text-red-600 flex items-center gap-2">
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
</div>
<% end %>
<%= render "form", transfer: @transfer %>
</article>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "form", transfer: @transfer %>
<% end %>

View File

@@ -1,7 +1,8 @@
<%# locals: (entry:) %>
<%= form_with model: [entry.account, entry],
data: { turbo_frame: "_top" },
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
<div class="grid grid-cols-10 p-4 items-center">
<div class="col-span-7 flex items-center gap-4">
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
@@ -11,12 +12,11 @@
<%= f.date_field :date, required: "required", min: Account::Entry.min_supported_date, max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
<%= f.hidden_field :currency, value: entry.account.currency %>
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>
</div>
</div>
<div class="col-span-3 flex gap-2 justify-end items-center">
<%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= link_to t(".cancel"), account_valuations_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render "account/valuations/form", entry: @entry %>
<% end %>

View File

@@ -2,8 +2,8 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
<%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"),
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
<%= link_to new_account_valuation_path(@account),
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_entry"), class: "text-sm" %>
@@ -21,11 +21,11 @@
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
<% if @valuation_entries.any? %>
<%= render partial: "account/entries/entryables/valuation/valuation",
collection: @valuation_entries,
as: :entry,
spacer_template: "ruler" %>
<% if @entries.any? %>
<%= render partial: "account/valuations/valuation",
collection: @entries,
as: :entry,
spacer_template: "account/entries/ruler" %>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
<% end %>

View File

@@ -0,0 +1,4 @@
<%= turbo_frame_tag dom_id(@entry) do %>
<%= render "account/valuations/form", entry: @entry %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %>

View File

@@ -0,0 +1,3 @@
<% entry = @entry %>
<%= render "account/valuations/valuation", entry: entry %>

View File

@@ -0,0 +1,26 @@
<%# 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>
<%= 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 class="text-white">
<%= t(".total_value_tooltip") %>
</div>
<div class="flex pt-3">
<div class="text-gray-300">
<%= t(".holdings") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(account.investment.holdings_value, precision: 0) %>
</div>
</div>
<div class="flex">
<div class="text-gray-300">
<%= t(".cash") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(account.balance_money, precision: 0) %>
</div>
</div>
</div>
<div data-tooltip-target="arrow"></div>
</div>

View File

@@ -1,22 +1,15 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", account: @account.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :name, label: t(".name") %>
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :name, label: t(".name") %>
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
<div class="relative">
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= link_to new_institution_path do %>
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
<% end %>
</div>
<div class="relative">
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
<%= link_to new_institution_path do %>
<%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %>
<% end %>
</div>
<%= f.submit %>
<% end %>
</article>
<%= f.submit %>
<% end %>
<% end %>

View File

@@ -52,7 +52,12 @@
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
<div class="flex items-center gap-1">
<div>
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
</div>
<%= render "tooltip", account: @account if @account.investment? %>
</div>
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if @series.trend.direction.flat? %>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", category: @category %>
</article>
<%= modal_form_wrapper title: t(".edit") do %>
<%= render "form", category: @category %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_category") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", category: @category %>
</article>
<%= modal_form_wrapper title: t(".new_category") do %>
<%= render "form", category: @category %>
<% end %>

View File

@@ -1,34 +1,21 @@
<%= modal do %>
<article class="mx-auto p-4 w-screen max-w-md">
<div class="space-y-2">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".delete_category") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %>
<%= styled_form_with url: category_deletions_path(@category),
class: "space-y-4",
data: {
turbo: false,
controller: "deletion",
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
<%= f.collection_select :replacement_category_id,
Current.family.categories.alphabetically.without(@category),
:id, :name,
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
<p class="text-gray-500 font-light">
<%= t(".explanation", category_name: @category.name) %>
</p>
</div>
<%= styled_form_with url: category_deletions_path(@category),
class: "space-y-4",
data: {
turbo: false,
controller: "deletion",
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
<%= f.collection_select :replacement_category_id,
Current.family.categories.alphabetically.without(@category),
:id, :name,
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
data: { deletion_target: "submitButton" } %>
<% end %>
</article>
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
data: { deletion_target: "submitButton" } %>
<% end %>
<% end %>

View File

@@ -2,7 +2,7 @@
<% is_selected = category.id === @selected_category&.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>

View File

@@ -25,7 +25,7 @@
<% end %>
<% if @transaction.category %>
<%= button_to account_entry_path(@transaction.entry.account, @transaction.entry),
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },

View File

@@ -9,14 +9,8 @@
</div>
<div class="mb-8 space-y-4">
<% transaction_entries = @import.dry_run %>
<% transaction_entries.group_by(&:date).each do |date, transactions| %>
<%= render "account/entries/entry_group",
date: date,
entries: transactions,
show_tags: true,
selectable: false,
editable: false %>
<%= entries_by_date(@import.dry_run, selectable: false) do |entries| %>
<%= render entries, show_tags: true, selectable: false, editable: false %>
<% end %>
</div>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit", institution: @institution.name) %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %>
<%= render "form", institution: @institution %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_institution") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", institution: @institution %>
</article>
<%= modal_form_wrapper title: t(".new_institution") do %>
<%= render "form", institution: @institution %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal classes: "max-w-fit" do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".title") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", merchant: @merchant %>
</article>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "form", merchant: @merchant %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal classes: "max-w-fit" do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".title") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", merchant: @merchant %>
</article>
<%= modal_form_wrapper title: t(".title") do %>
<%= render "form", merchant: @merchant %>
<% end %>

View File

@@ -162,13 +162,8 @@
</div>
<% else %>
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
<% @transaction_entries.group_by(&:date).each do |date, transactions| %>
<%= render "account/entries/entry_group",
date: date,
entries: transactions,
selectable: false,
editable: false,
short: true %>
<%= entries_by_date(@transaction_entries, selectable: false) do |entries| %>
<%= render entries, selectable: false, editable: false, short: true %>
<% end %>
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>

View File

@@ -0,0 +1,18 @@
<%# locals: (title:, content:, subtitle: nil) %>
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4 min-w-[450px] max-w-xl">
<div class="space-y-2">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= title %></h2>
<%= lucide_icon("x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" }) %>
</header>
<% if subtitle.present? %>
<%= tag.p subtitle, class: "text-gray-500 font-light" %>
<% end %>
</div>
<%= content %>
</article>
<% end %>

View File

@@ -1,7 +1,7 @@
<%# locals: (form:, money_method:, default_currency:, disable_currency: false, hide_currency: false, label: nil) %>
<% fallback_label = t(".money-label") %>
<% currency = form.object.send(money_method)&.currency || Money::Currency.new(default_currency) %>
<% currency = form.object ? (form.object.send(money_method)&.currency || Money::Currency.new(default_currency)) : Money::Currency.new(default_currency) %>
<div class="form-field pr-0" data-controller="money-field">
<%= form.label label || fallback_label, { class: "form-field__label" } %>

View File

@@ -1,34 +1,21 @@
<%= modal do %>
<article class="mx-auto p-4 w-screen max-w-md">
<div class="space-y-2">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".delete_tag") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= modal_form_wrapper title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name) do %>
<%= styled_form_with url: tag_deletions_path(@tag),
class: "space-y-4",
data: {
turbo: false,
controller: "deletion",
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
<%= f.collection_select :replacement_tag_id,
Current.family.tags.alphabetically.without(@tag),
:id, :name,
{ prompt: t(".replacement_tag_prompt"), label: t(".tag") },
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
<p class="text-gray-500 font-light">
<%= t(".explanation", tag_name: @tag.name) %>
</p>
</div>
<%= styled_form_with url: tag_deletions_path(@tag),
class: "space-y-4",
data: {
turbo: false,
controller: "deletion",
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
<%= f.collection_select :replacement_tag_id,
Current.family.tags.alphabetically.without(@tag),
:id, :name,
{ prompt: t(".replacement_tag_prompt"), label: t(".tag") },
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
data: { deletion_target: "submitButton" } %>
<% end %>
</article>
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
data: { deletion_target: "submitButton" } %>
<% end %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", tag: @tag %>
</article>
<%= modal_form_wrapper title: t(".edit") do %>
<%= render "form", tag: @tag %>
<% end %>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto w-full p-4 space-y-4">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", tag: @tag %>
</article>
<%= modal_form_wrapper title: t(".new") do %>
<%= render "form", tag: @tag %>
<% end %>

View File

@@ -11,7 +11,7 @@
<% if @transaction_entries.present? %>
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/entryables/transaction/selection_bar" %>
<%= render "account/transactions/selection_bar" %>
</div>
<div class="grow overflow-y-auto">
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
@@ -27,8 +27,9 @@
<p class="col-span-2 justify-self-end">amount</p>
</div>
<div class="space-y-6">
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
<%= render "account/entries/entry_group", date:, combine_transfers: true, entries: %>
<%= entries_by_date(@transaction_entries) do |entries| %>
<%= render entries.reject { |e| e.transfer_id.present? }, selectable: true %>
<%= render transfer_entries(entries), selectable: false %>
<% end %>
</div>
</div>

View File

@@ -1,10 +1,3 @@
<%= modal do %>
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
<header class="flex justify-between">
<h2 class="font-medium text-xl"><%= t(".new_transaction") %></h2>
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
</header>
<%= render "form", transaction: @transaction, entry: @entry %>
</article>
<%= modal_form_wrapper title: t(".new_transaction") do %>
<%= render "form", transaction: @transaction, entry: @entry %>
<% end %>

View File

@@ -1,74 +1,6 @@
{
"ignored_warnings": [
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "6179565a9eb1786348e6bbaf5d838b77f9075551930a6ca8ba33fbbf6d2adf26",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/account/entries/show.html.erb",
"line": 1,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(partial => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"show\"), { :locals => ({ :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) }) })",
"render_path": [
{
"type": "controller",
"class": "Account::EntriesController",
"method": "show",
"line": 42,
"file": "app/controllers/account/entries_controller.rb",
"rendered": {
"name": "account/entries/show",
"file": "app/views/account/entries/show.html.erb"
}
}
],
"location": {
"type": "template",
"template": "account/entries/show"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "7a182d062523a7fe890fbe5945c0004aeec1044ac764430f1d464326e5fa2710",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/account/entries/edit.html.erb",
"line": 2,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"edit\"), { :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) })",
"render_path": [
{
"type": "controller",
"class": "Account::EntriesController",
"method": "edit",
"line": 29,
"file": "app/controllers/account/entries_controller.rb",
"rendered": {
"name": "account/entries/edit",
"file": "app/views/account/entries/edit.html.erb"
}
}
],
"location": {
"type": "template",
"template": "account/entries/edit"
},
"user_input": "params[:id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
}
],
"updated": "2024-06-30 12:52:10 -0400",
"updated": "2024-08-09 10:16:00 -0400",
"brakeman_version": "6.1.2"
}

View File

@@ -46,3 +46,7 @@ pin "d3-zoom" # @3.0.0
pin "delaunator" # @5.0.1
pin "internmap" # @2.0.3
pin "robust-predicates" # @3.0.2
pin "@floating-ui/dom", to: "@floating-ui--dom.js" # @1.6.9
pin "@floating-ui/core", to: "@floating-ui--core.js" # @1.6.6
pin "@floating-ui/utils", to: "@floating-ui--utils.js" # @0.2.6
pin "@floating-ui/utils/dom", to: "@floating-ui--utils--dom.js" # @0.2.6

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.13"
"0.1.0-alpha.14"
end
end
end

View File

@@ -2,92 +2,12 @@
en:
account:
entries:
create:
success: "%{name} created"
destroy:
success: Entry deleted
empty:
description: Try adding an entry, editing filters or refining your search
title: No entries found
entryables:
trade:
show:
overview: Overview
trade:
buy: Buy
sell: Sell
transaction:
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will
no longer be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
additional: Additional
amount: Amount
category_label: Category
category_placeholder: Select a category
date_label: Date
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your
historical balances, and cannot be undone.
delete_title: Delete transaction
exclude_subtitle: This excludes the transaction from any in-app features
or analytics.
exclude_title: Exclude transaction
merchant_label: Merchant
merchant_placeholder: Select a merchant
name_label: Name
nature: Transaction type
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Select one or more tags
transaction:
deposit: Deposit
remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm
withdrawal: Withdrawal
valuation:
form:
cancel: Cancel
valuation:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes
the net worth and account graphs.</p></br><p>The only way youll be
able to add this entry back is by re-entering it manually via a new
entry</p>"
confirm_title: Delete Entry?
delete_entry: Delete entry
edit_entry: Edit entry
no_change: No change
start_balance: Starting balance
value_update: Value update
loading:
loading: Loading entries...
trades:
amount: Amount
new: New transaction
no_trades: No transactions for this account yet.
trade: transaction
trades: Transactions
type: Type
transactions:
new: New transaction
no_transactions: No transactions for this account yet.
transaction: transaction
transactions: Transactions
update:
success: Entry updated
valuations:
change: change
date: date
new_entry: New entry
no_valuations: No valuations for this account yet
valuations: Value history
value: value

View File

@@ -11,7 +11,7 @@ en:
name: name
needs_sync: Your account needs to sync the latest prices to calculate this
portfolio
new_holding: New holding
new_holding: New transaction
no_holdings: No holdings to show.
return: total return
weight: weight

View File

@@ -0,0 +1,31 @@
---
en:
account:
trades:
create:
success: Transaction created successfully.
form:
holding: Ticker symbol
price: Price per share
qty: Quantity
submit: Add transaction
ticker_placeholder: AAPL
type: Type
index:
amount: Amount
new: New transaction
no_trades: No transactions for this account yet.
trade: transaction
trades: Transactions
type: Type
new:
title: New transaction
show:
overview: Overview
trade:
buy: Buy
deposit: Deposit
inflow: Inflow
outflow: Outflow
sell: Sell
withdrawal: Withdrawal

View File

@@ -0,0 +1,44 @@
---
en:
account:
transactions:
index:
new: New transaction
no_transactions: No transactions for this account yet.
transaction: transaction
transactions: Transactions
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will no
longer be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
additional: Additional
amount: Amount
category_label: Category
category_placeholder: Select a category
date_label: Date
delete: Delete
delete_subtitle: This permanently deletes the transaction, affects your historical
balances, and cannot be undone.
delete_title: Delete transaction
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
merchant_label: Merchant
merchant_placeholder: Select a merchant
name_label: Name
nature: Transaction type
note_label: Notes
note_placeholder: Enter a note
overview: Overview
settings: Settings
tags_label: Select one or more tags
transaction:
remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm
update:
success: Transaction updated successfully.

View File

@@ -0,0 +1,27 @@
---
en:
account:
valuations:
create:
success: Valuation created successfully.
form:
cancel: Cancel
index:
change: change
date: date
new_entry: New entry
no_valuations: No valuations for this account yet
valuations: Value history
value: value
valuation:
confirm_accept: Delete entry
confirm_body_html: "<p>Deleting this entry will remove it from the accounts
history which will impact different parts of your account. This includes
the net worth and account graphs.</p></br><p>The only way youll be able
to add this entry back is by re-entering it manually via a new entry</p>"
confirm_title: Delete Entry?
delete_entry: Delete entry
edit_entry: Edit entry
no_change: No change
start_balance: Starting balance
value_update: Value update

View File

@@ -71,5 +71,10 @@ en:
new: New account
sync_all:
success: Successfully queued accounts for syncing.
tooltip:
cash: Cash
holdings: Holdings
total_value_tooltip: The total value is the sum of cash balance and your holdings
value, minus margin loans.
update:
success: Account updated

View File

@@ -81,13 +81,11 @@ Rails.application.routes.draw do
resources :holdings, only: %i[ index new show ]
resources :cashes, only: :index
resources :entries, except: :index do
collection do
get "transactions", as: :transaction
get "valuations", as: :valuation
get "trades", as: :trade
end
end
resources :transactions, only: %i[ index update ]
resources :valuations, only: %i[ index new create ]
resources :trades, only: %i[ index new create ]
resources :entries, only: %i[ edit update show destroy ]
end
end

View File

@@ -0,0 +1,5 @@
class AddCurrencyFieldToTrade < ActiveRecord::Migration[7.2]
def change
add_column :account_trades, :currency, :string
end
end

3
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_07_31_191344) do
ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -81,6 +81,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_31_191344) do
t.decimal "price", precision: 19, scale: 4
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "currency"
t.index ["security_id"], name: "index_account_trades_on_security_id"
end

View File

@@ -3,6 +3,9 @@ require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
setup do
Capybara.default_max_wait_time = 5
# Prevent "auto sync" from running when tests execute enqueued jobs
families(:dylan_family).update! last_synced_at: Time.now
end
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]

View File

@@ -5,114 +5,15 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
sign_in @user = users(:family_admin)
@transaction = account_entries :transaction
@valuation = account_entries :valuation
@trade = account_entries :trade
end
test "should edit valuation entry" do
get edit_account_entry_url(@valuation.account, @valuation)
assert_response :success
end
# =================
# Shared
# =================
test "should show transaction entry" do
get account_entry_url(@transaction.account, @transaction)
assert_response :success
end
test "should show valuation entry" do
get account_entry_url(@valuation.account, @valuation)
assert_response :success
end
test "should get list of transaction entries" do
get transaction_account_entries_url(@transaction.account)
assert_response :success
end
test "should get list of valuation entries" do
get valuation_account_entries_url(@valuation.account)
assert_response :success
end
test "gets new entry by type" do
get new_account_entry_url(@valuation.account, entryable_type: "Account::Valuation")
assert_response :success
end
test "should create valuation" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
post account_entries_url(@valuation.account), params: {
account_entry: {
name: "Manual valuation",
amount: 19800,
date: Date.current,
currency: @valuation.account.currency,
entryable_type: "Account::Valuation",
entryable_attributes: {}
}
}
end
assert_equal "Valuation created", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@valuation.account)
end
test "error when valuation already exists for date" do
assert_no_difference_in_entries do
post account_entries_url(@valuation.account), params: {
account_entry: {
amount: 19800,
date: @valuation.date,
currency: @valuation.currency,
entryable_type: "Account::Valuation",
entryable_attributes: {}
}
}
end
assert_equal "Date has already been taken", flash[:alert]
assert_redirected_to account_path(@valuation.account)
end
test "can update entry without entryable attributes" do
assert_no_difference_in_entries do
patch account_entry_url(@valuation.account, @valuation), params: {
account_entry: {
name: "Updated name"
}
}
end
assert_redirected_to account_entry_url(@valuation.account, @valuation)
assert_enqueued_with(job: AccountSyncJob)
end
test "should update transaction entry with entryable attributes" do
assert_no_difference_in_entries do
patch account_entry_url(@transaction.account, @transaction), params: {
account_entry: {
name: "Updated name",
date: Date.current,
currency: "USD",
amount: 20,
entryable_type: @transaction.entryable_type,
entryable_attributes: {
id: @transaction.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "test notes",
excluded: false
}
}
}
end
assert_redirected_to account_entry_url(@transaction.account, @transaction)
assert_enqueued_with(job: AccountSyncJob)
end
test "should destroy transaction entry" do
[ @transaction, @valuation ].each do |entry|
test "should destroy entry" do
[ @transaction, @valuation, @trade ].each do |entry|
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
delete account_entry_url(entry.account, entry)
end
@@ -122,6 +23,38 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
end
end
test "gets show" do
[ @transaction, @valuation, @trade ].each do |entry|
get account_entry_url(entry.account, entry)
assert_response :success
end
end
test "gets edit" do
[ @valuation ].each do |entry|
get edit_account_entry_url(entry.account, entry)
assert_response :success
end
end
test "can update generic entry" do
[ @transaction, @valuation, @trade ].each do |entry|
assert_no_difference_in_entries do
patch account_entry_url(entry.account, entry), params: {
account_entry: {
name: "Name",
date: Date.current,
currency: "USD",
amount: 100
}
}
end
assert_redirected_to account_entry_url(entry.account, entry)
assert_enqueued_with(job: AccountSyncJob)
end
end
private
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record

View File

@@ -0,0 +1,63 @@
require "test_helper"
class Account::TradesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :trade
end
test "should get index" do
get account_trades_url(@entry.account)
assert_response :success
end
test "should get new" do
get new_account_trade_url(@entry.account)
assert_response :success
end
test "creates trade buy entry" do
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "buy",
date: Date.current,
ticker: "NVDA",
qty: 10,
price: 10
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.amount.positive?
assert created_entry.account_trade.qty.positive?
assert_equal "Transaction created successfully.", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@entry.account)
end
test "creates trade sell entry" do
assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do
post account_trades_url(@entry.account), params: {
account_entry: {
type: "sell",
ticker: "AAPL",
date: Date.current,
currency: "USD",
qty: 10,
price: 10
}
}
end
created_entry = Account::Entry.order(created_at: :desc).first
assert created_entry.amount.negative?
assert created_entry.account_trade.qty.negative?
assert_equal "Transaction created successfully.", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@entry.account)
end
end

View File

@@ -0,0 +1,40 @@
require "test_helper"
class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :transaction
end
test "should get index" do
get account_transactions_url(@entry.account)
assert_response :success
end
test "update" do
assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do
patch account_transaction_url(@entry.account, @entry), params: {
account_entry: {
name: "Name",
date: Date.current,
currency: "USD",
amount: 100,
nature: "income",
entryable_type: @entry.entryable_type,
entryable_attributes: {
id: @entry.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "test notes",
excluded: false
}
}
}
end
assert_equal "Transaction updated successfully.", flash[:notice]
assert_redirected_to account_entry_url(@entry.account, @entry)
assert_enqueued_with(job: AccountSyncJob)
end
end

View File

@@ -0,0 +1,50 @@
require "test_helper"
class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@entry = account_entries :valuation
end
test "should get index" do
get account_valuations_url(@entry.account)
assert_response :success
end
test "should get new" do
get new_account_valuation_url(@entry.account)
assert_response :success
end
test "create" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
post account_valuations_url(@entry.account), params: {
account_entry: {
name: "Manual valuation",
amount: 19800,
date: Date.current,
currency: "USD"
}
}
end
assert_equal "Valuation created successfully.", flash[:notice]
assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_valuations_path(@entry.account)
end
test "error when valuation already exists for date" do
assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do
post account_valuations_url(@entry.account), params: {
account_entry: {
amount: 19800,
date: @entry.date,
currency: "USD"
}
}
end
assert_equal "Date has already been taken", flash[:alert]
assert_redirected_to account_path(@entry.account)
end
end

View File

@@ -2,3 +2,4 @@ one:
security: aapl
qty: 10
price: 214
currency: USD

View File

@@ -103,8 +103,8 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
assert actual_holding, "expected #{ticker} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:amount].to_d, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:price].to_d, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
end
end

View File

@@ -62,8 +62,16 @@ class ExchangeRateTest < ActiveSupport::TestCase
end
test "finds multiple rates from provider and caches to DB" do
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: 1.day.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: Date.current).returns(OpenStruct.new(success?: true, rate: 1.2)).once
@provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, end_date: Date.current)
.returns(
OpenStruct.new(
rates: [
OpenStruct.new(date: 1.day.ago.to_date, rate: 1.1),
OpenStruct.new(date: Date.current, rate: 1.2)
],
success?: true
)
).once
fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true)
refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date)
@@ -73,7 +81,15 @@ class ExchangeRateTest < ActiveSupport::TestCase
end
test "finds missing db rates from provider and appends to results" do
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "GBP", date: 2.days.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once
@provider.expects(:fetch_exchange_rates).with(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date)
.returns(
OpenStruct.new(
rates: [
OpenStruct.new(date: 2.day.ago.to_date, rate: 1.1)
],
success?: true
)
).once
rate1 = exchange_rates(:one) # EUR -> GBP, today
rate2 = exchange_rates(:two) # EUR -> GBP, yesterday

View File

@@ -16,19 +16,36 @@ class Provider::SynthTest < ActiveSupport::TestCase
end
end
test "retries then provides failed response" do
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
test "fetches paginated exchange_rate historical data" do
VCR.use_cassette("synth/exchange_rate_historical") do
response = @synth.fetch_exchange_rates(
from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024")
)
response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
assert 213, response.rates.size # 213 days between 01.01.2024 and 31.07.2024
assert_equal [ :date, :rate ], response.rates.first.keys
end
end
test "retries then provides failed response" do
@client = mock
Faraday.stubs(:new).returns(@client)
@client.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
assert_match "Failed to fetch data from Provider::Synth", response.error.message
end
test "retrying, then raising on network error" do
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3)
@client = mock
Faraday.stubs(:new).returns(@client)
@client.expects(:get).raises(Faraday::TimeoutError).times(3)
assert_raises Faraday::TimeoutError do
@synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
@synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
end
end
end

View File

@@ -34,7 +34,8 @@ module Account::EntriesTestHelper
trade = Account::Trade.new \
qty: qty,
security: security,
price: trade_price
price: trade_price,
currency: "USD"
account.entries.create! \
name: "Trade",

View File

@@ -0,0 +1,26 @@
require "application_system_test_case"
class TooltipsTest < ApplicationSystemTestCase
include ActionView::Helpers::NumberHelper
include ApplicationHelper
setup do
sign_in @user = users(:family_admin)
@account = accounts(:investment)
end
test "can see account information tooltip" do
visit account_path(@account)
find('[data-controller="tooltip"]').hover
assert find("#tooltip", visible: true)
within "#tooltip" do
assert_text I18n.t("accounts.tooltip.total_value_tooltip")
assert_text I18n.t("accounts.tooltip.holdings")
assert_text format_money(@account.investment.holdings_value, precision: 0)
assert_text I18n.t("accounts.tooltip.cash")
assert_text format_money(@account.balance_money, precision: 0)
end
find("body").click
assert find("#tooltip", visible: false)
end
end

View File

@@ -0,0 +1,67 @@
require "application_system_test_case"
class TradesTest < ApplicationSystemTestCase
include ActiveJob::TestHelper
setup do
sign_in @user = users(:family_admin)
@account = accounts(:investment)
visit_account_trades
end
test "can create buy transaction" do
shares_qty = 25.0
open_new_trade_modal
fill_in "Ticker symbol", with: "NVDA"
fill_in "Date", with: Date.current
fill_in "Quantity", with: shares_qty
fill_in "account_entry[price]", with: 214.23
click_button "Add transaction"
visit_account_trades
within_trades do
assert_text "Purchase 10 shares of AAPL"
assert_text "Buy #{shares_qty} shares of NVDA"
end
end
test "can create sell transaction" do
aapl = @account.holdings.current.find { |h| h.security.ticker == "AAPL" }
open_new_trade_modal
select "Sell", from: "Type"
fill_in "Ticker symbol", with: aapl.ticker
fill_in "Date", with: Date.current
fill_in "Quantity", with: aapl.qty
fill_in "account_entry[price]", with: 215.33
click_button "Add transaction"
visit_account_trades
within_trades do
assert_text "Sell #{aapl.qty} shares of AAPL"
end
end
private
def open_new_trade_modal
click_link "new_trade_account_#{@account.id}"
end
def within_trades(&block)
within "#" + dom_id(@account, "trades"), &block
end
def visit_account_trades
visit account_url(@account, tab: "trades")
end
end

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

10
vendor/javascript/@floating-ui--dom.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
function getNodeName(e){return isNode(e)?(e.nodeName||"").toLowerCase():"#document"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return e instanceof Node||e instanceof getWindow(e).Node}function isElement(e){return e instanceof Element||e instanceof getWindow(e).Element}function isHTMLElement(e){return e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement}function isShadowRoot(e){return typeof ShadowRoot!=="undefined"&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&!["inline","contents"].includes(r)}function isTableElement(e){return["table","td","th"].includes(getNodeName(e))}function isTopLayer(e){return[":popover-open",":modal"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return n.transform!=="none"||n.perspective!=="none"||!!n.containerType&&n.containerType!=="normal"||!t&&!!n.backdropFilter&&n.backdropFilter!=="none"||!t&&!!n.filter&&n.filter!=="none"||["transform","perspective","filter"].some((e=>(n.willChange||"").includes(e)))||["paint","layout","strict","content"].some((e=>(n.contain||"").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS==="undefined"||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function isLastTraversableNode(e){return["html","body","#document"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)==="html")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit};

View File

@@ -0,0 +1,2 @@
const t=["top","right","bottom","left"];const e=["start","end"];const n=t.reduce(((t,n)=>t.concat(n,n+"-"+e[0],n+"-"+e[1])),[]);const i=Math.min;const o=Math.max;const g=Math.round;const c=Math.floor;const createCoords=t=>({x:t,y:t});const s={left:"right",right:"left",bottom:"top",top:"bottom"};const r={start:"end",end:"start"};function clamp(t,e,n){return o(t,i(e,n))}function evaluate(t,e){return typeof t==="function"?t(e):t}function getSide(t){return t.split("-")[0]}function getAlignment(t){return t.split("-")[1]}function getOppositeAxis(t){return t==="x"?"y":"x"}function getAxisLength(t){return t==="y"?"height":"width"}function getSideAxis(t){return["top","bottom"].includes(getSide(t))?"y":"x"}function getAlignmentAxis(t){return getOppositeAxis(getSideAxis(t))}function getAlignmentSides(t,e,n){n===void 0&&(n=false);const i=getAlignment(t);const o=getAlignmentAxis(t);const g=getAxisLength(o);let c=o==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";e.reference[g]>e.floating[g]&&(c=getOppositePlacement(c));return[c,getOppositePlacement(c)]}function getExpandedPlacements(t){const e=getOppositePlacement(t);return[getOppositeAlignmentPlacement(t),e,getOppositeAlignmentPlacement(e)]}function getOppositeAlignmentPlacement(t){return t.replace(/start|end/g,(t=>r[t]))}function getSideList(t,e,n){const i=["left","right"];const o=["right","left"];const g=["top","bottom"];const c=["bottom","top"];switch(t){case"top":case"bottom":return n?e?o:i:e?i:o;case"left":case"right":return e?g:c;default:return[]}}function getOppositeAxisPlacements(t,e,n,i){const o=getAlignment(t);let g=getSideList(getSide(t),n==="start",i);if(o){g=g.map((t=>t+"-"+o));e&&(g=g.concat(g.map(getOppositeAlignmentPlacement)))}return g}function getOppositePlacement(t){return t.replace(/left|right|bottom|top/g,(t=>s[t]))}function expandPaddingObject(t){return{top:0,right:0,bottom:0,left:0,...t}}function getPaddingObject(t){return typeof t!=="number"?expandPaddingObject(t):{top:t,right:t,bottom:t,left:t}}function rectToClientRect(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}export{e as alignments,clamp,createCoords,evaluate,expandPaddingObject,c as floor,getAlignment,getAlignmentAxis,getAlignmentSides,getAxisLength,getExpandedPlacements,getOppositeAlignmentPlacement,getOppositeAxis,getOppositeAxisPlacements,getOppositePlacement,getPaddingObject,getSide,getSideAxis,o as max,i as min,n as placements,rectToClientRect,g as round,t as sides};