From b5b2d335fd956f9a06403da8d5ae8bbfa37a6ca9 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 20 Feb 2024 09:07:55 -0500 Subject: [PATCH] Add Live Data to Account Page (#464) * Add trends, time series, seed data * Remove test data * Replace old view values with helpers * Fix tooltip bugs in D3 chart * Fix tests * Fix smoke test * Add CRUD actions for valuations * Scaffold out inline editing with Turbo --- app/controllers/accounts_controller.rb | 35 +--- app/controllers/valuations_controller.rb | 62 +++++++ app/helpers/application_form_builder.rb | 2 +- app/helpers/application_helper.rb | 34 ++++ app/helpers/valuations_helper.rb | 2 + .../controllers/line_chart_controller.js | 170 +++++++++--------- app/models/account.rb | 44 ++++- app/models/adjustment.rb | 3 + app/models/appraisal.rb | 3 + app/models/trend.rb | 26 +++ app/models/valuation.rb | 5 + app/views/accounts/show.html.erb | 93 +++++----- app/views/valuations/_form_row.html.erb | 15 ++ app/views/valuations/create.html.erb | 4 + app/views/valuations/create.turbo_stream.erb | 3 + app/views/valuations/destroy.html.erb | 4 + app/views/valuations/destroy.turbo_stream.erb | 2 + app/views/valuations/edit.html.erb | 8 + app/views/valuations/new.html.erb | 8 + app/views/valuations/show.html.erb | 4 + app/views/valuations/update.html.erb | 4 + config/routes.rb | 4 +- .../20240215201527_create_valuations.rb | 16 ++ db/schema.rb | 15 +- db/seeds.rb | 74 ++++++++ .../controllers/valuations_controller_test.rb | 19 ++ test/fixtures/valuations.yml | 13 ++ test/models/valuation_test.rb | 7 + 28 files changed, 512 insertions(+), 167 deletions(-) create mode 100644 app/controllers/valuations_controller.rb create mode 100644 app/helpers/valuations_helper.rb create mode 100644 app/models/adjustment.rb create mode 100644 app/models/appraisal.rb create mode 100644 app/models/trend.rb create mode 100644 app/models/valuation.rb create mode 100644 app/views/valuations/_form_row.html.erb create mode 100644 app/views/valuations/create.html.erb create mode 100644 app/views/valuations/create.turbo_stream.erb create mode 100644 app/views/valuations/destroy.html.erb create mode 100644 app/views/valuations/destroy.turbo_stream.erb create mode 100644 app/views/valuations/edit.html.erb create mode 100644 app/views/valuations/new.html.erb create mode 100644 app/views/valuations/show.html.erb create mode 100644 app/views/valuations/update.html.erb create mode 100644 db/migrate/20240215201527_create_valuations.rb create mode 100644 test/controllers/valuations_controller_test.rb create mode 100644 test/fixtures/valuations.yml create mode 100644 test/models/valuation_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 5417530b..e1c4d234 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,9 +9,7 @@ class AccountsController < ApplicationController end def show - # Temporary while dummy data is being used - # @account = Current.family.accounts.find(params[:id]) - @account = sample_account + @account = Current.family.accounts.find(params[:id]) end def create @@ -25,40 +23,9 @@ class AccountsController < ApplicationController end end - - private def account_params params.require(:account).permit(:name, :accountable_type, :original_balance, :original_currency, :subtype) end - - def sample_account - OpenStruct.new( - id: 1, - name: "Sample Account", - original_balance: BigDecimal("1115181"), - original_currency: "USD", - converted_balance: BigDecimal("1115181"), # Assuming conversion rate is 1 for simplicity - converted_currency: "USD", - dollar_change: BigDecimal("1553.43"), # Added dollar change - percent_change: BigDecimal("0.9"), # Added percent change - subtype: "Checking", - accountable_type: "Depository", - balances: sample_balances - ) - end - - def sample_balances - 4.times.map do |i| - OpenStruct.new( - date: "Feb #{12 + i} 2024", - description: "Manually entered", - amount: BigDecimal("1000") + (i * BigDecimal("100")), - change: i == 3 ? -50 : (i == 2 ? 0 : 100 + (i * 10)), - percentage_change: i == 3 ? -5 : (i == 2 ? 0 : 10 + i), - icon: i == 3 ? "arrow-down" : (i == 2 ? "minus" : (i.even? ? "arrow-down" : "arrow-up")) - ) - end - end end diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb new file mode 100644 index 00000000..6e9bc9ef --- /dev/null +++ b/app/controllers/valuations_controller.rb @@ -0,0 +1,62 @@ +class ValuationsController < ApplicationController + before_action :authenticate_user! + + def create + @account = Current.family.accounts.find(params[:account_id]) + + # TODO: handle STI once we allow for different types of valuations + @valuation = @account.valuations.new(valuation_params.merge(type: "Appraisal", currency: Current.family.currency)) + if @valuation.save + respond_to do |format| + format.html { redirect_to account_path(@account), notice: "Valuation created" } + format.turbo_stream + end + else + render :new, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotUnique + flash.now[:error] = "Valuation already exists for this date" + render :new, status: :unprocessable_entity + end + + def show + @valuation = Current.family.accounts.find(params[:account_id]).valuations.find(params[:id]) + end + + def edit + @valuation = Valuation.find(params[:id]) + end + + def update + @valuation = Valuation.find(params[:id]) + if @valuation.update(valuation_params) + redirect_to account_path(@valuation.account), notice: "Valuation updated" + else + render :edit, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotUnique + flash.now[:error] = "Valuation already exists for this date" + render :edit, status: :unprocessable_entity + end + + def destroy + @valuation = Valuation.find(params[:id]) + account = @valuation.account + @valuation.destroy + + respond_to do |format| + format.html { redirect_to account_path(account), notice: "Valuation deleted" } + format.turbo_stream + end + end + + def new + @account = Current.family.accounts.find(params[:account_id]) + @valuation = @account.valuations.new + end + + private + def valuation_params + params.require(:valuation).permit(:date, :value) + end +end diff --git a/app/helpers/application_form_builder.rb b/app/helpers/application_form_builder.rb index 1fad76b3..0f560255 100644 --- a/app/helpers/application_form_builder.rb +++ b/app/helpers/application_form_builder.rb @@ -8,7 +8,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder (field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector| class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 - def #{selector}(method, options) + def #{selector}(method, options = {}) default_options = { class: "form-field__input" } merged_options = default_options.merge(options) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1090869d..e87992e7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -48,6 +48,40 @@ module ApplicationHelper end end + # Styles to use when displaying a change in value + def trend_styles(trend_direction) + bg_class, text_class, symbol, icon = case trend_direction + when "up" + [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ] + when "down" + [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ] + when "flat" + [ "bg-gray-500/5", "text-gray-500", "", "minus" ] + else + raise ArgumentError, "Invalid trend direction: #{trend_direction}" + end + + { bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon } + end + + def trend_label(date_range) + start_date, end_date = date_range.values_at(:start, :end) + days_apart = (end_date - start_date).to_i + + case days_apart + when 1 + "vs. yesterday" + when 7 + "vs. last week" + when 30, 31 + "vs. last month" + when 365, 366 + "vs. last year" + else + "from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}" + end + end + def format_currency(number, options = {}) user_currency_preference = Current.family.try(:currency) || "USD" diff --git a/app/helpers/valuations_helper.rb b/app/helpers/valuations_helper.rb new file mode 100644 index 00000000..9fe5ad7f --- /dev/null +++ b/app/helpers/valuations_helper.rb @@ -0,0 +1,2 @@ +module ValuationsHelper +end diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js index 4dc93236..e38a43b9 100644 --- a/app/javascript/controllers/line_chart_controller.js +++ b/app/javascript/controllers/line_chart_controller.js @@ -4,82 +4,78 @@ import * as d3 from "d3"; // Connects to data-controller="line-chart" export default class extends Controller { + static values = { series: Array }; + connect() { - this.drawChart(); + this.renderChart(this.seriesValue); + document.addEventListener("turbo:load", this.renderChart.bind(this)); } - drawChart() { - // TODO: Replace with live data through controller targets - const data = [ - { - date: new Date(2021, 0, 1), - value: 985000, - formatted: "$985,000", - change: { value: "$0", direction: "none", percentage: "0%" }, + disconnect() { + document.removeEventListener("turbo:load", this.renderChart.bind(this)); + } + + renderChart() { + this.drawChart(this.seriesValue); + } + + trendStyles(trendDirection) { + return { + up: { + icon: "↑", + color: tailwindColors.success, }, - { - date: new Date(2021, 1, 1), - value: 990000, - formatted: "$990,000", - change: { value: "$5,000", direction: "up", percentage: "0.51%" }, + down: { + icon: "↓", + color: tailwindColors.error, }, - { - date: new Date(2021, 2, 1), - value: 995000, - formatted: "$995,000", - change: { value: "$5,000", direction: "up", percentage: "0.51%" }, + flat: { + icon: "→", + color: tailwindColors.gray[500], }, - { - date: new Date(2021, 3, 1), - value: 1000000, - formatted: "$1,000,000", - change: { value: "$5,000", direction: "up", percentage: "0.50%" }, + }[trendDirection]; + } + + /** + * @param {Array} balances - An array of objects where each object represents a balance entry. Each object should have the following properties: + * - date: {Date} The date of the balance entry. + * - value: {number} The numerical value of the balance. + * - formatted: {string} The formatted string representation of the balance value. + * - trend: {Object} An object containing information about the trend compared to the previous balance entry. It should have: + * - amount: {number} The numerical difference in value from the previous entry. + * - direction: {string} A string indicating the direction of the trend ("up", "down", or "flat"). + * - percent: {number} The percentage change from the previous entry. + */ + drawChart(balances) { + const data = balances.map((b) => ({ + ...b, + value: +b.value, + date: new Date(b.date), + styles: this.trendStyles(b.trend.direction), + formatted: { + value: Intl.NumberFormat("en-US", { + style: "currency", + currency: b.currency || "USD", + }).format(b.value), + change: Intl.NumberFormat("en-US", { + style: "currency", + currency: b.currency || "USD", + signDisplay: "always", + }).format(b.trend.amount), }, - { - date: new Date(2021, 4, 1), - value: 1005000, - formatted: "$997,000", - change: { value: "$3,000", direction: "down", percentage: "-0.30%" }, - }, - { - date: new Date(2021, 5, 1), - value: 1010000, - formatted: "$1,010,000", - change: { value: "$5,000", direction: "up", percentage: "0.50%" }, - }, - { - date: new Date(2021, 6, 1), - value: 1050000, - formatted: "$1,050,000", - change: { value: "$40,000", direction: "up", percentage: "3.96%" }, - }, - { - date: new Date(2021, 7, 1), - value: 1080000, - formatted: "$1,080,000", - change: { value: "$30,000", direction: "up", percentage: "2.86%" }, - }, - { - date: new Date(2021, 8, 1), - value: 1100000, - formatted: "$1,100,000", - change: { value: "$20,000", direction: "up", percentage: "1.85%" }, - }, - { - date: new Date(2021, 9, 1), - value: 1115181, - formatted: "$1,115,181", - change: { value: "$15,181", direction: "up", percentage: "1.38%" }, - }, - ]; + })); + + const chartContainer = d3.select("#lineChart"); + + // Clear any existing chart + chartContainer.selectAll("svg").remove(); const initialDimensions = { - width: document.querySelector("#lineChart").clientWidth, - height: document.querySelector("#lineChart").clientHeight, + width: chartContainer.node().clientWidth, + height: chartContainer.node().clientHeight, }; - const svg = d3 - .select("#lineChart") + const svg = chartContainer .append("svg") .attr("width", initialDimensions.width) .attr("height", initialDimensions.height) @@ -182,13 +178,22 @@ export default class extends Controller { .on("mousemove", (event) => { tooltip.style("opacity", 1); + const tooltipWidth = 250; // Estimate or dynamically calculate the tooltip width + const pageWidth = document.body.clientWidth; + const tooltipX = event.pageX + 10; + const overflowX = tooltipX + tooltipWidth - pageWidth; + const [xPos] = d3.pointer(event); - const x0 = bisectDate(data, x.invert(xPos)); + const x0 = bisectDate(data, x.invert(xPos), 1); const d0 = data[x0 - 1]; const d1 = data[x0]; const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0; + // Adjust tooltip position based on overflow + const adjustedX = + overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX; + g.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time g.append("circle") .attr("class", "data-point-circle") @@ -196,14 +201,16 @@ export default class extends Controller { .attr("cy", y(d.value)) .attr("r", 8) .attr("fill", tailwindColors.green[500]) - .attr("fill-opacity", "0.1"); + .attr("fill-opacity", "0.1") + .attr("pointer-events", "none"); g.append("circle") .attr("class", "data-point-circle") .attr("cx", x(d.date)) .attr("cy", y(d.value)) .attr("r", 3) - .attr("fill", tailwindColors.green[500]); + .attr("fill", tailwindColors.green[500]) + .attr("pointer-events", "none"); tooltip .html( @@ -213,30 +220,15 @@ export default class extends Controller {
- ${d.formatted} ${ - d.change.direction === "up" - ? "+" - : d.change.direction === "down" - ? "-" - : "" - }${d.change.value} (${d.change.percentage}) - -
` + ${d.formatted.value} ${d.formatted.change} (${d.trend.percent}%) + ` ) - .style("left", event.pageX + 10 + "px") + .style("left", adjustedX + "px") .style("top", event.pageY - 10 + "px"); g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time diff --git a/app/models/account.rb b/app/models/account.rb index 61eeebd4..1b0608bf 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,6 +1,7 @@ class Account < ApplicationRecord belongs_to :family - has_many :account_balances + has_many :balances, class_name: "AccountBalance" + has_many :valuations delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy @@ -8,6 +9,15 @@ class Account < ApplicationRecord before_create :check_currency + # Show all valuations in history table (no date range filtering) + def valuations_with_trend + series_for(valuations, :value) + end + + def balances_with_trend(date_range = default_date_range) + series_for(balances, :balance, date_range) + end + def check_currency if self.original_currency == self.family.currency self.converted_balance = self.original_balance @@ -17,4 +27,36 @@ class Account < ApplicationRecord self.converted_currency = self.family.currency end end + + private + + def default_date_range + { start: 30.days.ago.to_date, end: Date.today } + end + + # TODO: probably a better abstraction for this in the future + def series_for(collection, value_attr, date_range = {}) + collection = filtered_by_date_for(collection, date_range) + overall_trend = Trend.new(collection.last&.send(value_attr), collection.first&.send(value_attr)) + + collection_with_trends = [ nil, *collection ].each_cons(2).map do |previous, current| + { + current: current, + previous: previous, + date: current.date, + currency: current.currency, + value: current.send(value_attr), + trend: Trend.new(current.send(value_attr), previous&.send(value_attr)) + } + end + + { date_range: date_range, trend: overall_trend, series: collection_with_trends } + end + + def filtered_by_date_for(association, date_range) + scope = association + scope = scope.where("date >= ?", date_range[:start]) if date_range[:start] + scope = scope.where("date <= ?", date_range[:end]) if date_range[:end] + scope.order(:date).to_a + end end diff --git a/app/models/adjustment.rb b/app/models/adjustment.rb new file mode 100644 index 00000000..1cef0cca --- /dev/null +++ b/app/models/adjustment.rb @@ -0,0 +1,3 @@ +# Used for manual account value adjustments (e.g. to correct for a missing transaction) +class Adjustment < Valuation +end diff --git a/app/models/appraisal.rb b/app/models/appraisal.rb new file mode 100644 index 00000000..ca58791a --- /dev/null +++ b/app/models/appraisal.rb @@ -0,0 +1,3 @@ +# Used to update the value of an account based on a manual or external appraisal (i.e. Zillow) +class Appraisal < Valuation +end diff --git a/app/models/trend.rb b/app/models/trend.rb new file mode 100644 index 00000000..0d59ee33 --- /dev/null +++ b/app/models/trend.rb @@ -0,0 +1,26 @@ +class Trend + attr_reader :current, :previous + + def initialize(current, previous) + @current = current + @previous = previous + end + + def direction + return "flat" unless @previous + return "up" if @current > @previous + return "down" if @current < @previous + "flat" + end + + def amount + return 0 if @previous.nil? + @current - @previous + end + + def percent + return 0 if @previous.nil? + return Float::INFINITY if @previous == 0 + ((@current - @previous).abs / @previous.to_f * 100).round(1) + end +end diff --git a/app/models/valuation.rb b/app/models/valuation.rb new file mode 100644 index 00000000..70dff5de --- /dev/null +++ b/app/models/valuation.rb @@ -0,0 +1,5 @@ +# STI model to represent a point-in-time "valuation" of an account's value +# Types include: Appraisal, Adjustment +class Valuation < ApplicationRecord + belongs_to :account +end diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 3c73ac33..25a82cfc 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -1,3 +1,5 @@ +<% balances = @account.balances_with_trend %> +<% balance_styles = trend_styles(balances[:trend].direction) %>
@@ -22,24 +24,19 @@

Total Value

- <%# TODO: Will need a normalized way to split a formatted monetary value into these 3 parts %> + <%# TODO: Will need a better way to split a formatted monetary value into these 3 parts %>

<%= number_to_currency(@account.converted_balance)[0] %> <%= number_with_delimiter(@account.converted_balance.round) %> .<%= number_to_currency(@account.converted_balance, precision: 2)[-2, 2] %>

- <% if @account.dollar_change == 0 %> -

- No change vs last month -

+ <% if balances[:trend].amount == 0 %> +

No change vs. prior period

<% else %> -

- <%= @account.dollar_change > 0 ? '+' : '-' %><%= number_to_currency(@account.dollar_change.abs, precision: 2) %> - - (<%= lucide_icon(@account.dollar_change > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> - <%= @account.percent_change %>%) - - vs last month +

+ <%= balance_styles[:symbol] %><%= number_to_currency(balances[:trend].amount.abs, precision: 2) %> + (<%= lucide_icon(balances[:trend].amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= balances[:trend].percent %>%) + <%= trend_label(balances[:date_range]) %>

<% end %>
@@ -51,64 +48,80 @@
-
+
-

History

- + New entry + <% end %>
-
- DATE -
+
DATE
VALUE
CHANGE
- <% @account.balances.each_with_index do |balance, index| %> -
+ <% series = @account.valuations_with_trend[:series].reverse_each %> + <% series.with_index do |valuation, index| %> + <% valuation_styles = trend_styles(valuation[:trend].direction) %> + <%= turbo_frame_tag dom_id(valuation[:current]) do %>
-
- <%= lucide_icon(balance.icon, class: "w-4 h-4 #{balance.change > 0 ? 'text-green-500' : balance.change < 0 ? 'text-red-500' : 'text-gray-500'}") %> +
+ <%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 #{valuation_styles[:text_class]}") %>
-

<%= balance.date %>

-

<%= balance.description %>

+

<%= valuation[:date] %>

+ <%# TODO: Add descriptive name of valuation %> +

Manually entered

-
<%= number_to_currency(balance.amount, precision: 2) %>
+
<%= format_currency(valuation[:value]) %>
- <% if balance.change == 0 %> + <% if valuation[:trend].amount == 0 %> No change <% else %> - <%= balance.change > 0 ? '+' : '' %>$<%= balance.change.abs %> - - (<%= lucide_icon(balance.icon, class: "w-4 h-4 align-text-bottom inline") %> <%= balance.percentage_change %>%) + <%= valuation_styles[:symbol] %><%= format_currency(valuation[:trend].amount.abs) %> + (<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation[:trend].percent %>%) <% end %>
-
- <%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %> +
+ +
- <% if index < @account.balances.size - 1 %> -
- <% end %> -
+
+ <% if index < series.size - 1 %> +
+ <% end %> +
+ <% end %> + <% end %> + <%= turbo_frame_tag dom_id(Valuation.new) do %> <% end %>
-
+ <%= link_to new_account_valuation_path(@account), data: { turbo_frame: "new_valuation" }, class: "hover:bg-white w-full text-sm flex items-center justify-center gap-2 text-gray-500 px-4 py-2 rounded-md" do %> + <%= lucide_icon("plus", class: "w-4 h-4") %> New entry + <% end %>
diff --git a/app/views/valuations/_form_row.html.erb b/app/views/valuations/_form_row.html.erb new file mode 100644 index 00000000..8c0ddb9a --- /dev/null +++ b/app/views/valuations/_form_row.html.erb @@ -0,0 +1,15 @@ +<%# + Locals: + - f: form object for valuation + - form_icon: string representing the icon to be displayed + - submit_button_text: string representing the text on the submit button +%> +
+
+ <%= lucide_icon(form_icon, class: "w-4 h-4 text-gray-500") %> +
+ <%= f.date_field :date, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5" %> + <%= f.number_field :value, step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs px-3 py-1.5" %> + <%= link_to "Cancel", account_path(@valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %> + <%= f.submit submit_button_text, class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %> +
diff --git a/app/views/valuations/create.html.erb b/app/views/valuations/create.html.erb new file mode 100644 index 00000000..f9aa4ce8 --- /dev/null +++ b/app/views/valuations/create.html.erb @@ -0,0 +1,4 @@ +
+

Valuations#create

+

Find me in app/views/valuations/create.html.erb

+
diff --git a/app/views/valuations/create.turbo_stream.erb b/app/views/valuations/create.turbo_stream.erb new file mode 100644 index 00000000..aeae48ed --- /dev/null +++ b/app/views/valuations/create.turbo_stream.erb @@ -0,0 +1,3 @@ +<%# TODO: We need a way to determine the order the new valuation needs to be in the array, calculate the trend, and append it to the right spot %> +<%= turbo_stream.update Valuation.new, "" %> +<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: "Valuation created" } %> diff --git a/app/views/valuations/destroy.html.erb b/app/views/valuations/destroy.html.erb new file mode 100644 index 00000000..1bbc64cf --- /dev/null +++ b/app/views/valuations/destroy.html.erb @@ -0,0 +1,4 @@ +
+

Valuations#destroy

+

Find me in app/views/valuations/destroy.html.erb

+
diff --git a/app/views/valuations/destroy.turbo_stream.erb b/app/views/valuations/destroy.turbo_stream.erb new file mode 100644 index 00000000..fad56f55 --- /dev/null +++ b/app/views/valuations/destroy.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.remove @valuation %> +<%= turbo_stream.append "notification-tray", partial: "shared/notification", locals: { type: "success", content: "Valuation deleted" } %> diff --git a/app/views/valuations/edit.html.erb b/app/views/valuations/edit.html.erb new file mode 100644 index 00000000..0c9f5bd0 --- /dev/null +++ b/app/views/valuations/edit.html.erb @@ -0,0 +1,8 @@ +
+

Edit Valuation: <%= @valuation.type %>

+ <%= turbo_frame_tag dom_id(@valuation) do %> + <%= form_with model: @valuation, url: valuation_path(@valuation), scope: :valuation do |f| %> + <%= render 'form_row', f: f, form_icon: "pencil-line", submit_button_text: "Update" %> + <% end %> + <% end %> +
diff --git a/app/views/valuations/new.html.erb b/app/views/valuations/new.html.erb new file mode 100644 index 00000000..7ceeb4b8 --- /dev/null +++ b/app/views/valuations/new.html.erb @@ -0,0 +1,8 @@ +
+

Add Valuation: <%= @account.name %>

+ <%= turbo_frame_tag dom_id(Valuation.new) do %> + <%= form_with model: [@account, @valuation], url: account_valuations_path(@account), scope: :valuation do |f| %> + <%= render 'form_row', f: f, form_icon: "plus", submit_button_text: "Add" %> + <% end %> + <% end %> +
diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb new file mode 100644 index 00000000..8ed05622 --- /dev/null +++ b/app/views/valuations/show.html.erb @@ -0,0 +1,4 @@ +
+

Valuation: <%= @valuation.type %>

+

Find me in app/views/valuations/show.html.erb

+
diff --git a/app/views/valuations/update.html.erb b/app/views/valuations/update.html.erb new file mode 100644 index 00000000..52863970 --- /dev/null +++ b/app/views/valuations/update.html.erb @@ -0,0 +1,4 @@ +
+

Valuations#update

+

Find me in app/views/valuations/update.html.erb

+
diff --git a/config/routes.rb b/config/routes.rb index 5ba54a28..184c6834 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,7 +7,9 @@ Rails.application.routes.draw do resource :password resource :settings, only: %i[edit update] - resources :accounts + resources :accounts, shallow: true do + resources :valuations + end scope "accounts/new" do scope "bank" do diff --git a/db/migrate/20240215201527_create_valuations.rb b/db/migrate/20240215201527_create_valuations.rb new file mode 100644 index 00000000..1a04cc9c --- /dev/null +++ b/db/migrate/20240215201527_create_valuations.rb @@ -0,0 +1,16 @@ +class CreateValuations < ActiveRecord::Migration[7.2] + def change + create_table :valuations, id: :uuid do |t| + t.string :type, null: false + t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade } + t.date :date, null: false + t.decimal :value, precision: 19, scale: 4, null: false + t.string :currency, default: "USD", null: false + + t.timestamps + end + + # Since all dates are daily (no concept of time of day), limit account to 1 valuation per day + add_index :valuations, [ :account_id, :date ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index c4cf32ff..7479f19b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_02_12_150110) do +ActiveRecord::Schema[7.2].define(version: 2024_02_15_201527) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -206,7 +206,20 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_12_150110) do t.index ["family_id"], name: "index_users_on_family_id" end + create_table "valuations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "type", null: false + t.uuid "account_id", null: false + t.date "date", null: false + t.decimal "value", precision: 19, scale: 4, null: false + t.string "currency", default: "USD", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "date"], name: "index_valuations_on_account_id_and_date", unique: true + t.index ["account_id"], name: "index_valuations_on_account_id" + end + add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "accounts", "families" add_foreign_key "users", "families" + add_foreign_key "valuations", "accounts", on_delete: :cascade end diff --git a/db/seeds.rb b/db/seeds.rb index 2de3ca9c..23187d3d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -8,6 +8,9 @@ # MovieGenre.find_or_create_by!(name: genre_name) # end +# https://github.com/rails/rails/issues/29112#issuecomment-320653056 +ApplicationRecord.reset_column_information + # Create the default user family = Family.create_or_find_by(name: "The Maybe Family") puts "Family created: #{family.name}" @@ -22,3 +25,74 @@ puts "User created: #{user.email} for family: #{family.name}" # Create default currency Currency.find_or_create_by(iso_code: "USD", name: "United States Dollar") + +current_balance = 350000 + +account = Account.create_or_find_by(name: "Seed Property Account", accountable: Account::Property.new, family: family, original_balance: current_balance, original_currency: "USD") +puts "Account created: #{account.name}" + +# Represent user-defined "Valuations" at various dates +appraisals = [ + { date: Date.today - 30, balance: 300000 }, + { date: Date.today - 22, balance: 300700 }, + { date: Date.today - 17, balance: 301400 }, + { date: Date.today - 10, balance: 300000 }, + { date: Date.today - 3, balance: 301900 } +] + +# In prod, this would be calculated from the current balance and the appraisals with a background job +# Hardcoded for readability +balances = [ + { date: Date.today - 30, balance: 300000 }, + { date: Date.today - 29, balance: 300000 }, + { date: Date.today - 28, balance: 300000 }, + { date: Date.today - 27, balance: 300000 }, + { date: Date.today - 26, balance: 300000 }, + { date: Date.today - 25, balance: 300000 }, + { date: Date.today - 24, balance: 300000 }, + { date: Date.today - 23, balance: 300000 }, + { date: Date.today - 22, balance: 300700 }, + { date: Date.today - 21, balance: 300700 }, + { date: Date.today - 20, balance: 300700 }, + { date: Date.today - 19, balance: 300700 }, + { date: Date.today - 18, balance: 300700 }, + { date: Date.today - 17, balance: 301400 }, + { date: Date.today - 16, balance: 301400 }, + { date: Date.today - 15, balance: 301400 }, + { date: Date.today - 14, balance: 301400 }, + { date: Date.today - 13, balance: 301400 }, + { date: Date.today - 12, balance: 301400 }, + { date: Date.today - 11, balance: 301400 }, + { date: Date.today - 10, balance: 300000 }, + { date: Date.today - 9, balance: 300000 }, + { date: Date.today - 8, balance: 300000 }, + { date: Date.today - 7, balance: 300000 }, + { date: Date.today - 6, balance: 300000 }, + { date: Date.today - 5, balance: 300000 }, + { date: Date.today - 4, balance: 300000 }, + { date: Date.today - 3, balance: 301900 }, + { date: Date.today - 2, balance: 301900 }, + { date: Date.today - 1, balance: 301900 }, + { date: Date.today, balance: 302000 } +] + + +appraisals.each do |appraisal| + Appraisal.find_or_create_by( + account_id: account.id, + date: appraisal[:date] + ) do |appraisal_record| + appraisal_record.value = appraisal[:balance] + appraisal_record.currency = "USD" + end +end + +balances.each do |balance| + AccountBalance.find_or_create_by( + account_id: account.id, + date: balance[:date] + ) do |balance_record| + balance_record.balance = balance[:balance] + balance_record.currency = "USD" + end +end diff --git a/test/controllers/valuations_controller_test.rb b/test/controllers/valuations_controller_test.rb new file mode 100644 index 00000000..177341e4 --- /dev/null +++ b/test/controllers/valuations_controller_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class ValuationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:bob) + @account = accounts(:dylan_checking) + end + + test "new" do + get new_account_valuation_url(@account) + assert_response :success + end + + test "create" do + assert_difference("Valuation.count") do + post account_valuations_url(@account), params: { valuation: { value: 1, date: Date.current, type: "Appraisal" } } + end + end +end diff --git a/test/fixtures/valuations.yml b/test/fixtures/valuations.yml new file mode 100644 index 00000000..e51bcdbd --- /dev/null +++ b/test/fixtures/valuations.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + type: "Appraisal" + value: 9.99 + date: 2024-02-15 + account: dylan_checking + +two: + type: "Appraisal" + value: 9.99 + date: 2024-02-15 + account: richards_savings diff --git a/test/models/valuation_test.rb b/test/models/valuation_test.rb new file mode 100644 index 00000000..0b775e65 --- /dev/null +++ b/test/models/valuation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ValuationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end