diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 4af14181..c665a763 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -20,7 +20,6 @@ } .pcr-color-palette{ height: 12em !important; - width: 21.5rem !important; } .pcr-palette{ border-radius: 10px !important; diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css index f7244692..2c57f18f 100644 --- a/app/assets/tailwind/maybe-design-system/background-utils.css +++ b/app/assets/tailwind/maybe-design-system/background-utils.css @@ -78,14 +78,6 @@ } } -@utility bg-divider { - @apply bg-alpha-black-100; - - @variant theme-dark { - @apply bg-alpha-white-100; - } -} - @utility bg-overlay { background-color: --alpha(var(--color-gray-100) / 50%); diff --git a/app/assets/tailwind/maybe-design-system/border-utils.css b/app/assets/tailwind/maybe-design-system/border-utils.css index 94c54a55..8fcc1c9c 100644 --- a/app/assets/tailwind/maybe-design-system/border-utils.css +++ b/app/assets/tailwind/maybe-design-system/border-utils.css @@ -63,6 +63,10 @@ } } +@utility border-divider { + @apply border-tertiary; +} + @utility border-subdued { @apply border-alpha-black-50; @@ -85,4 +89,4 @@ @variant theme-dark { @apply border-red-400; } -} \ No newline at end of file +} diff --git a/app/components/menu_item_component.html.erb b/app/components/menu_item_component.html.erb index 273c4551..cb71dc6d 100644 --- a/app/components/menu_item_component.html.erb +++ b/app/components/menu_item_component.html.erb @@ -1,5 +1,5 @@ <% if variant == :divider %> -
+ <%= render "shared/ruler", classes: "my-1" %> <% else %>
<%= wrapper do %> diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index c566d30e..32bca350 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -6,6 +6,23 @@ class PagesController < ApplicationController @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.active.with_attached_logo + period_param = params[:cashflow_period] + @cashflow_period = if period_param.present? + begin + Period.from_key(period_param) + rescue Period::InvalidKeyError + Period.last_30_days + end + else + Period.last_30_days + end + + family_currency = Current.family.currency + income_totals = Current.family.income_statement.income_totals(period: @cashflow_period) + expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period) + + @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency) + @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] end @@ -31,4 +48,100 @@ class PagesController < ApplicationController def github_provider Provider::Registry.get_provider(:github) end + + def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol) + nodes = [] + links = [] + node_indices = {} # Memoize node indices by a unique key: "type_categoryid" + + # Helper to add/find node and return its index + add_node = ->(unique_key, display_name, value, percentage, color) { + node_indices[unique_key] ||= begin + nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color } + nodes.size - 1 + end + } + + total_income_val = income_totals.total.to_f.round(2) + total_expense_val = expense_totals.total.to_f.round(2) + + # --- Create Central Cash Flow Node --- + cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)") + + # --- Process Income Side (Top-level categories only) --- + income_totals.category_totals.each do |ct| + # Skip subcategories โ€“ only include root income categories + next if ct.category.parent_id.present? + + val = ct.total.to_f.round(2) + next if val.zero? + + percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1) + + node_display_name = ct.category.name + node_color = ct.category.color.presence || Category::COLORS.sample + + current_cat_idx = add_node.call( + "income_#{ct.category.id}", + node_display_name, + val, + percentage_of_total_income, + node_color + ) + + links << { + source: current_cat_idx, + target: cash_flow_idx, + value: val, + color: node_color, + percentage: percentage_of_total_income + } + end + + # --- Process Expense Side (Top-level categories only) --- + expense_totals.category_totals.each do |ct| + # Skip subcategories โ€“ only include root expense categories to keep Sankey shallow + next if ct.category.parent_id.present? + + val = ct.total.to_f.round(2) + next if val.zero? + + percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1) + + node_display_name = ct.category.name + node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR + + current_cat_idx = add_node.call( + "expense_#{ct.category.id}", + node_display_name, + val, + percentage_of_total_expense, + node_color + ) + + links << { + source: cash_flow_idx, + target: current_cat_idx, + value: val, + color: node_color, + percentage: percentage_of_total_expense + } + end + + # --- Process Surplus --- + leftover = (total_income_val - total_expense_val).round(2) + if leftover.positive? + percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1) + surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)") + links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus } + end + + # Update Cash Flow and Income node percentages (relative to total income) + if node_indices["cash_flow_node"] + nodes[node_indices["cash_flow_node"]][:percentage] = 100.0 + end + # No primary income node anymore, percentages are on individual income cats relative to total_income_val + + { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol } + end end diff --git a/app/javascript/controllers/category_controller.js b/app/javascript/controllers/category_controller.js index fdd47729..ec0ab645 100644 --- a/app/javascript/controllers/category_controller.js +++ b/app/javascript/controllers/category_controller.js @@ -15,6 +15,7 @@ export default class extends Controller { "validationMessage", "selection", "colorPickerRadioBtn", + "popup", ]; static values = { @@ -36,6 +37,7 @@ export default class extends Controller { this.colorInputTarget.reportValidity(); e.target.open = true; } + this.updatePopupPosition() }); this.selectedIcon = null; @@ -43,6 +45,8 @@ export default class extends Controller { if (!this.presetColorsValue.includes(this.colorInputTarget.value)) { this.colorPickerRadioBtnTarget.checked = true; } + + document.addEventListener("mousedown", this.handleOutsideClick); } initPicker() { @@ -209,6 +213,7 @@ export default class extends Controller { this.colorsSectionTarget.classList.add("hidden"); this.paletteSectionTarget.classList.remove("hidden"); this.pickerSectionTarget.classList.remove("hidden"); + this.updatePopupPosition(); this.picker.show(); } @@ -216,6 +221,7 @@ export default class extends Controller { this.colorsSectionTarget.classList.remove("hidden"); this.paletteSectionTarget.classList.add("hidden"); this.pickerSectionTarget.classList.add("hidden"); + this.updatePopupPosition() if (this.picker) { this.picker.destroyAndRemove(); } @@ -229,6 +235,27 @@ export default class extends Controller { } } + handleOutsideClick = (event) => { + if (this.detailsTarget.open && !this.detailsTarget.contains(event.target)) { + this.detailsTarget.open = false; + } + }; + + updatePopupPosition() { + const popup = this.popupTarget; + popup.style.top = ""; + popup.style.bottom = ""; + + const rect = popup.getBoundingClientRect(); + const overflow = rect.bottom > window.innerHeight; + + if (overflow) { + popup.style.bottom = "0px"; + } else { + popup.style.bottom = ""; + } + } + #backgroundColor(color) { return `color-mix(in oklab, ${color} 10%, transparent)`; } diff --git a/app/javascript/controllers/preserve_scroll_controller.js b/app/javascript/controllers/preserve_scroll_controller.js new file mode 100644 index 00000000..8bf20e52 --- /dev/null +++ b/app/javascript/controllers/preserve_scroll_controller.js @@ -0,0 +1,44 @@ +import { Controller } from "@hotwired/stimulus" + +/* + https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i + modified to add support for horizontal scrolling + + only requirement is that the element has an id + */ +export default class extends Controller { + static scrollPositions = {} + + connect() { + this.preserveScrollBound = this.preserveScroll.bind(this) + this.restoreScrollBound = this.restoreScroll.bind(this) + + window.addEventListener("turbo:before-cache", this.preserveScrollBound) + window.addEventListener("turbo:before-render", this.restoreScrollBound) + window.addEventListener("turbo:render", this.restoreScrollBound) + } + + disconnect() { + window.removeEventListener("turbo:before-cache", this.preserveScrollBound) + window.removeEventListener("turbo:before-render", this.restoreScrollBound) + window.removeEventListener("turbo:render", this.restoreScrollBound) + } + + preserveScroll() { + if (!this.element.id) return + + this.constructor.scrollPositions[this.element.id] = { + top: this.element.scrollTop, + left: this.element.scrollLeft + } + } + + restoreScroll(event) { + if (!this.element.id) return + + if (this.constructor.scrollPositions[this.element.id]) { + this.element.scrollTop = this.constructor.scrollPositions[this.element.id].top + this.element.scrollLeft = this.constructor.scrollPositions[this.element.id].left + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js new file mode 100644 index 00000000..9601b088 --- /dev/null +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -0,0 +1,204 @@ +import { Controller } from "@hotwired/stimulus"; +import * as d3 from "d3"; +import { sankey, sankeyLinkHorizontal } from "d3-sankey"; + +// Connects to data-controller="sankey-chart" +export default class extends Controller { + static values = { + data: Object, + nodeWidth: { type: Number, default: 15 }, + nodePadding: { type: Number, default: 20 }, + currencySymbol: { type: String, default: "$" } + }; + + connect() { + this.resizeObserver = new ResizeObserver(() => this.#draw()); + this.resizeObserver.observe(this.element); + this.#draw(); + } + + disconnect() { + this.resizeObserver?.disconnect(); + } + + #draw() { + const { nodes = [], links = [] } = this.dataValue || {}; + + if (!nodes.length || !links.length) return; + + // Clear previous SVG + d3.select(this.element).selectAll("svg").remove(); + + const width = this.element.clientWidth || 600; + const height = this.element.clientHeight || 400; + + const svg = d3 + .select(this.element) + .append("svg") + .attr("width", width) + .attr("height", height); + + const sankeyGenerator = sankey() + .nodeWidth(this.nodeWidthValue) + .nodePadding(this.nodePaddingValue) + .extent([ + [16, 16], + [width - 16, height - 16], + ]); + + const sankeyData = sankeyGenerator({ + nodes: nodes.map((d) => Object.assign({}, d)), + links: links.map((d) => Object.assign({}, d)), + }); + + // Define gradients for links + const defs = svg.append("defs"); + + sankeyData.links.forEach((link, i) => { + const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`; + + const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => { + let colorStr = nodeColorInput || "var(--color-gray-400)"; + if (colorStr === "var(--color-success)") { + colorStr = "#10A861"; // Hex for --color-green-600 + } + // Add other CSS var to hex mappings here if needed + + if (colorStr.startsWith("var(--")) { // Unmapped CSS var, use as is (likely solid) + return colorStr; + } + + const d3Color = d3.color(colorStr); + return d3Color ? d3Color.copy({ opacity: opacity }) : "var(--color-gray-400)"; + }; + + const sourceStopColor = getStopColorWithOpacity(link.source.color); + const targetStopColor = getStopColorWithOpacity(link.target.color); + + const gradient = defs.append("linearGradient") + .attr("id", gradientId) + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", link.source.x1) + .attr("x2", link.target.x0); + + gradient.append("stop") + .attr("offset", "0%") + .attr("stop-color", sourceStopColor); + + gradient.append("stop") + .attr("offset", "100%") + .attr("stop-color", targetStopColor); + }); + + // Draw links + svg + .append("g") + .attr("fill", "none") + .selectAll("path") + .data(sankeyData.links) + .join("path") + .attr("d", (d) => { + const sourceX = d.source.x1; + const targetX = d.target.x0; + const path = d3.linkHorizontal()({ + source: [sourceX, d.y0], + target: [targetX, d.y1] + }); + return path; + }) + .attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`) + .attr("stroke-width", (d) => Math.max(1, d.width)) + .append("title") + .text((d) => `${nodes[d.source.index].name} โ†’ ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`); + + // Draw nodes + const node = svg + .append("g") + .selectAll("g") + .data(sankeyData.nodes) + .join("g"); + + const cornerRadius = 8; + + node.append("path") + .attr("d", (d) => { + const x0 = d.x0; + const y0 = d.y0; + const x1 = d.x1; + const y1 = d.y1; + const h = y1 - y0; + // const w = x1 - x0; // Not directly used in path string, but good for context + + // Dynamic corner radius based on node height, maxed at 8 + const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2)); + + const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0); + const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0); + + if (isSourceNode) { // Round left corners, flat right for "Total Income" + if (h < effectiveCornerRadius * 2) { + return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`; + } + return `M ${x0 + effectiveCornerRadius},${y0} + L ${x1},${y0} + L ${x1},${y1} + L ${x0 + effectiveCornerRadius},${y1} + Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius} + L ${x0},${y0 + effectiveCornerRadius} + Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`; + } + + if (isTargetNode) { // Flat left corners, round right for Categories/Surplus + if (h < effectiveCornerRadius * 2) { + return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`; + } + return `M ${x0},${y0} + L ${x1 - effectiveCornerRadius},${y0} + Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius} + L ${x1},${y1 - effectiveCornerRadius} + Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1} + L ${x0},${y1} Z`; + } + + // Fallback for intermediate nodes (e.g., "Cash Flow") - draw as a simple sharp-cornered rectangle + return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`; + }) + .attr("fill", (d) => d.color || "var(--color-gray-400)") + .attr("stroke", (d) => { + // If a node has an explicit color assigned (even if it's a gray variable), + // it gets no stroke. Only truly un-colored nodes (falling back to default fill) + // would get a stroke, but our current data structure assigns colors to all nodes. + if (d.color) { + return "none"; + } + return "var(--color-gray-500)"; // Fallback, likely unused with current data + }); + + const stimulusControllerInstance = this; + node + .append("text") + .attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)) + .attr("y", (d) => (d.y1 + d.y0) / 2) + .attr("dy", "-0.2em") + .attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end")) + .attr("class", "text-xs font-medium text-primary fill-current") + .each(function (d) { + const textElement = d3.select(this); + textElement.selectAll("tspan").remove(); + + // Node Name on the first line + textElement.append("tspan") + .text(d.name); + + // Financial details on the second line + const financialDetailsTspan = textElement.append("tspan") + .attr("x", textElement.attr("x")) + .attr("dy", "1.2em") + .attr("class", "font-mono text-secondary") + .style("font-size", "0.65rem"); // Explicitly set smaller font size + + financialDetailsTspan.append("tspan") + .text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })); + }); + } +} \ No newline at end of file diff --git a/app/javascript/controllers/scroll_on_connect_controller.js b/app/javascript/controllers/scroll_on_connect_controller.js new file mode 100644 index 00000000..c03cdbcb --- /dev/null +++ b/app/javascript/controllers/scroll_on_connect_controller.js @@ -0,0 +1,41 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + selector: { type: String, default: "[aria-current=\"page\"]" }, + delay: { type: Number, default: 500 } + } + + connect() { + setTimeout(() => { + this.scrollToActiveItem() + }, this.delayValue) + } + + scrollToActiveItem() { + const activeItem = this.element?.querySelector(this.selectorValue) + + + if (!activeItem) return + + const scrollContainer = this.element + const containerRect = scrollContainer.getBoundingClientRect() + const activeItemRect = activeItem.getBoundingClientRect() + + const scrollPositionX = (activeItemRect.left + scrollContainer.scrollLeft) - + (containerRect.width / 2) + + (activeItemRect.width / 2) + + const scrollPositionY = (activeItemRect.top + scrollContainer.scrollTop) - + (containerRect.height / 2) + + (activeItemRect.height / 2) + + + // Smooth scroll to position + scrollContainer.scrollTo({ + top: Math.max(0, scrollPositionY), + left: Math.max(0, scrollPositionX), + behavior: 'smooth' + }) + } +} \ No newline at end of file diff --git a/app/javascript/controllers/transaction_form_controller.js b/app/javascript/controllers/transaction_form_controller.js deleted file mode 100644 index d4ad50b1..00000000 --- a/app/javascript/controllers/transaction_form_controller.js +++ /dev/null @@ -1,22 +0,0 @@ -import {Controller} from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["expenseCategories", "incomeCategories"] - - connect() { - this.updateCategories() - } - - updateCategories(event) { - const natureField = this.element.querySelector('input[name="account_entry[nature]"]:checked') - const natureValue = natureField ? natureField.value : 'outflow' - - if (natureValue === 'inflow') { - this.expenseCategoriesTarget.classList.add('hidden') - this.incomeCategoriesTarget.classList.remove('hidden') - } else { - this.expenseCategoriesTarget.classList.remove('hidden') - this.incomeCategoriesTarget.classList.add('hidden') - } - } -} diff --git a/app/javascript/shims/d3-array-default.js b/app/javascript/shims/d3-array-default.js new file mode 100644 index 00000000..1b1e088e --- /dev/null +++ b/app/javascript/shims/d3-array-default.js @@ -0,0 +1,3 @@ +import * as d3Array from "d3-array-src"; +export * from "d3-array-src"; +export default d3Array; \ No newline at end of file diff --git a/app/javascript/shims/d3-shape-default.js b/app/javascript/shims/d3-shape-default.js new file mode 100644 index 00000000..23920eda --- /dev/null +++ b/app/javascript/shims/d3-shape-default.js @@ -0,0 +1,3 @@ +import * as d3Shape from "d3-shape-src"; +export * from "d3-shape-src"; +export default d3Shape; \ No newline at end of file diff --git a/app/jobs/security_health_check_job.rb b/app/jobs/security_health_check_job.rb new file mode 100644 index 00000000..387dcd9b --- /dev/null +++ b/app/jobs/security_health_check_job.rb @@ -0,0 +1,7 @@ +class SecurityHealthCheckJob < ApplicationJob + queue_as :scheduled + + def perform + Security::HealthChecker.check_all + end +end diff --git a/app/models/market_data_importer.rb b/app/models/market_data_importer.rb index 9eaf5964..86c3c235 100644 --- a/app/models/market_data_importer.rb +++ b/app/models/market_data_importer.rb @@ -22,7 +22,8 @@ class MarketDataImporter return end - Security.where.not(exchange_operating_mic: nil).find_each do |security| + # Import all securities that aren't marked as "offline" (i.e. they're available from the provider) + Security.online.find_each do |security| security.import_provider_prices( start_date: get_first_required_price_date(security), end_date: end_date, diff --git a/app/models/provider/security_concept.rb b/app/models/provider/security_concept.rb index d54b2011..fbc408c3 100644 --- a/app/models/provider/security_concept.rb +++ b/app/models/provider/security_concept.rb @@ -1,7 +1,7 @@ module Provider::SecurityConcept extend ActiveSupport::Concern - Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic) + Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code) SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic) Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic) diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 8d76fc72..ed2514ac 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -94,7 +94,8 @@ class Provider::Synth < Provider req.params["name"] = symbol req.params["dataset"] = "limited" req.params["country_code"] = country_code if country_code.present? - req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present? + # Synth uses mic_code, which encompasses both exchange_mic AND exchange_operating_mic (union) + req.params["mic_code"] = exchange_operating_mic if exchange_operating_mic.present? req.params["limit"] = 25 end @@ -106,6 +107,7 @@ class Provider::Synth < Provider name: security.dig("name"), logo_url: security.dig("logo_url"), exchange_operating_mic: security.dig("exchange", "operating_mic_code"), + country_code: security.dig("exchange", "country_code") ) end end @@ -131,7 +133,7 @@ class Provider::Synth < Provider end end - def fetch_security_price(symbol:, exchange_operating_mic:, date:) + def fetch_security_price(symbol:, exchange_operating_mic: nil, date:) with_provider_response do historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date) @@ -141,13 +143,13 @@ class Provider::Synth < Provider end end - def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:) + def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:) with_provider_response do params = { start_date: start_date, end_date: end_date, operating_mic_code: exchange_operating_mic - } + }.compact data = paginate( "#{base_url}/tickers/#{symbol}/open-close", diff --git a/app/models/security.rb b/app/models/security.rb index 6115aa4c..b6e85295 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -9,6 +9,8 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } + scope :online, -> { where(offline: false) } + def current_price @current_price ||= find_or_fetch_price return nil if @current_price.nil? @@ -21,13 +23,10 @@ class Security < ApplicationRecord name: name, logo_url: logo_url, exchange_operating_mic: exchange_operating_mic, + country_code: country_code ) end - def has_prices? - exchange_operating_mic.present? - end - private def upcase_symbols self.ticker = ticker.upcase diff --git a/app/models/security/health_checker.rb b/app/models/security/health_checker.rb new file mode 100644 index 00000000..241e3845 --- /dev/null +++ b/app/models/security/health_checker.rb @@ -0,0 +1,120 @@ +# There are hundreds of thousands of market securities that Maybe must handle. +# Due to the always-changing nature of the market, the health checker is responsible +# for periodically checking active securities to ensure we can still fetch prices for them. +# +# Each security goes through some basic health checks. If failed, this class is responsible for: +# - Marking failed attempts and incrementing the failed attempts counter +# - Marking the security offline if enough consecutive failed checks occur +# - When we move a security "offline", delete all prices for that security as we assume they are bad data +# +# The health checker is run daily through SecurityHealthCheckJob (see config/schedule.yml), but not all +# securities will be checked every day (we run in batches) +class Security::HealthChecker + MAX_CONSECUTIVE_FAILURES = 5 + HEALTH_CHECK_INTERVAL = 7.days + DAILY_BATCH_SIZE = 1000 + + class << self + def check_all + # No daily limit for unchecked securities (they are prioritized) + never_checked_scope.find_each do |security| + new(security).run_check + end + + # Daily limit for checked securities + due_for_check_scope.limit(DAILY_BATCH_SIZE).each do |security| + new(security).run_check + end + end + + private + # If a security has never had a health check, we prioritize it, regardless of batch size + def never_checked_scope + Security.where(last_health_check_at: nil) + end + + # Any securities not checked for 30 days are due + # We only process the batch size, which means some "due" securities will not be checked today + # This is by design, to prevent all securities from coming due at the same time + def due_for_check_scope + Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago) + .order(last_health_check_at: :asc) + end + end + + def initialize(security) + @security = security + end + + def run_check + Rails.logger.info("Running health check for #{security.ticker}") + + if latest_provider_price + handle_success + else + handle_failure + end + rescue => e + Sentry.capture_exception(e) do |scope| + scope.set_tags(security_id: @security.id) + end + ensure + security.update!(last_health_check_at: Time.current) + end + + private + attr_reader :security + + def provider + Security.provider + end + + def latest_provider_price + return nil unless provider.present? + + response = provider.fetch_security_price( + symbol: security.ticker, + exchange_operating_mic: security.exchange_operating_mic, + date: Date.current + ) + + return nil unless response.success? + + response.data.price + end + + # On success, reset any failure counters and ensure it is "online" + def handle_success + security.update!( + offline: false, + failed_fetch_count: 0, + failed_fetch_at: nil + ) + end + + def handle_failure + new_failure_count = security.failed_fetch_count.to_i + 1 + new_failure_at = Time.current + + if new_failure_count > MAX_CONSECUTIVE_FAILURES + convert_to_offline_security! + else + security.update!( + failed_fetch_count: new_failure_count, + failed_fetch_at: new_failure_at + ) + end + end + + # The "offline" state tells our MarketDataImporter (daily cron) to skip this security when fetching prices + def convert_to_offline_security! + Security.transaction do + security.update!( + offline: true, + failed_fetch_count: MAX_CONSECUTIVE_FAILURES + 1, + failed_fetch_at: Time.current + ) + security.prices.delete_all + end + end +end diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 7927d6e6..ca886420 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -10,9 +10,14 @@ module Security::Provided end def search_provider(symbol, country_code: nil, exchange_operating_mic: nil) - return [] if symbol.blank? || symbol.length < 2 + return [] if provider.nil? || symbol.blank? - response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic) + params = { + country_code: country_code, + exchange_operating_mic: exchange_operating_mic + }.compact_blank + + response = provider.search_securities(symbol, **params) if response.success? response.data.map do |provider_security| @@ -22,6 +27,7 @@ module Security::Provided name: provider_security.name, logo_url: provider_security.logo_url, exchange_operating_mic: provider_security.exchange_operating_mic, + country_code: provider_security.country_code ) end else @@ -37,7 +43,11 @@ module Security::Provided # Make sure we have a data provider before fetching return nil unless provider.present? - response = provider.fetch_security_price(self, date: date) + response = provider.fetch_security_price( + symbol: ticker, + exchange_operating_mic: exchange_operating_mic, + date: date + ) return nil unless response.success? # Provider error diff --git a/app/models/security/resolver.rb b/app/models/security/resolver.rb index fc6cea6d..2b0e13bc 100644 --- a/app/models/security/resolver.rb +++ b/app/models/security/resolver.rb @@ -1,48 +1,156 @@ class Security::Resolver def initialize(symbol, exchange_operating_mic: nil, country_code: nil) - @symbol = symbol + @symbol = validate_symbol!(symbol) @exchange_operating_mic = exchange_operating_mic @country_code = country_code end + # Attempts several paths to resolve a security: + # 1. Exact match in DB + # 2. Search provider for an exact match + # 3. Search provider for close match, ranked by relevance + # 4. Create offline security if no match is found in either DB or provider def resolve - return nil unless symbol + return nil if symbol.blank? - exact_match = Security.find_by( - ticker: symbol, - exchange_operating_mic: exchange_operating_mic - ) - - exact_match if exact_match.present? + exact_match_from_db || + exact_match_from_provider || + close_match_from_provider || + offline_security end private attr_reader :symbol, :exchange_operating_mic, :country_code - - def fetch_from_provider - return nil unless Security.provider.present? - result = Security.search_provider( - symbol, - exchange_operating_mic: exchange_operating_mic - ) - - return nil unless result.success? - - selection = if exchange_operating_mic.present? - result.data.find do |s| - s.ticker == symbol && s.exchange_operating_mic == exchange_operating_mic - end - else - result.data.sort_by - end - - unless selection.present? - - end - - selection + def validate_symbol!(symbol) + raise ArgumentError, "Symbol is required and cannot be blank" if symbol.blank? + symbol.strip.upcase end - def + def offline_security + security = Security.find_or_initialize_by( + ticker: symbol, + exchange_operating_mic: exchange_operating_mic, + ) + + security.assign_attributes( + country_code: country_code, + offline: true # This tells us that we shouldn't try to fetch prices later + ) + + security.save! + + security + end + + def exact_match_from_db + Security.find_by( + { + ticker: symbol, + exchange_operating_mic: exchange_operating_mic, + country_code: country_code.presence + }.compact + ) + end + + # If provided a ticker + exchange (and optionally, a country code), we can find exact matches + def exact_match_from_provider + # Without an exchange, we can never know if we have an exact match + return nil unless exchange_operating_mic.present? + + match = provider_search_result.find do |s| + ticker_matches = s.ticker.upcase.to_s == symbol.upcase.to_s + exchange_matches = s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s + + if country_code && exchange_operating_mic + ticker_matches && exchange_matches && s.country_code.upcase.to_s == country_code.upcase.to_s + else + ticker_matches && exchange_matches + end + end + + return nil unless match + + find_or_create_provider_match!(match) + end + + def close_match_from_provider + filtered_candidates = provider_search_result + + # If a country code is specified, we MUST find a match with the same code + if country_code.present? + filtered_candidates = filtered_candidates.select { |s| s.country_code.upcase.to_s == country_code.upcase.to_s } + end + + # 1. Prefer exact exchange_operating_mic matches (if one was provided) + # 2. Rank by country relevance (lower index in the list is more relevant) + # 3. Rank by exchange_operating_mic relevance (lower index in the list is more relevant) + sorted_candidates = filtered_candidates.sort_by do |s| + [ + exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1, + sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length, + sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length + ] + end + + match = sorted_candidates.first + + return nil unless match + + find_or_create_provider_match!(match) + end + + def find_or_create_provider_match!(match) + security = Security.find_or_initialize_by( + ticker: match.ticker, + exchange_operating_mic: match.exchange_operating_mic, + ) + + security.country_code = match.country_code + security.save! + + security + end + + def provider_search_result + params = { + exchange_operating_mic: exchange_operating_mic, + country_code: country_code + }.compact_blank + + @provider_search_result ||= Security.search_provider(symbol, **params) + end + + # Non-exhaustive list of common country codes for help in choosing "close" matches + # These are generally sorted by market cap. + def sorted_country_codes_by_relevance + %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO] + end + + # Non-exhaustive list of common exchange operating MICs for help in choosing "close" matches + # This is very US-centric since our prices provider and user base is a majority US-based + def sorted_exchange_operating_mics_by_relevance + [ + "XNYS", # New York Stock Exchange + "XNAS", # NASDAQ Stock Market + "XOTC", # OTC Markets Group (OTC Link) + "OTCM", # OTC Markets Group + "OTCN", # OTC Bulletin Board + "OTCI", # OTC International + "OPRA", # Options Price Reporting Authority + "MEMX", # Members Exchange + "IEXA", # IEX All-Market + "IEXG", # IEX Growth Market + "EDXM", # Cboe EDGX Exchange (Equities) + "XCME", # CME Group (Derivatives) + "XCBT", # Chicago Board of Trade + "XPUS", # Nasdaq PSX (U.S.) + "XPSE", # Nasdaq PHLX (U.S.) + "XTRD", # Nasdaq TRF (Trade Reporting Facility) + "XTXD", # FINRA TRACE (Trade Reporting) + "XARC", # NYSE Arca + "XBOX", # BOX Options Exchange + "XBXO" # BZX Options (Cboe) + ] + end end diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index 2a9ba46a..15e76920 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -1,7 +1,7 @@ class Security::SynthComboboxOption include ActiveModel::Model - attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic + attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code def id "#{symbol}|#{exchange_operating_mic}" # submitted by combobox as value diff --git a/app/models/trade_builder.rb b/app/models/trade_builder.rb index cf9800e5..72d9aa01 100644 --- a/app/models/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -129,9 +129,9 @@ class TradeBuilder def security ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ] - Security.find_or_create_by!( - ticker: ticker_symbol, + Security::Resolver.new( + ticker_symbol, exchange_operating_mic: exchange_operating_mic - ) + ).resolve end end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index e7a57f64..67fac05d 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -76,42 +76,25 @@ class TradeImport < Import end private - def find_or_create_security(ticker:, exchange_operating_mic:) - # Normalize empty string to nil for consistency - exchange_operating_mic = nil if exchange_operating_mic.blank? + def find_or_create_security(ticker: nil, exchange_operating_mic: nil) + return nil unless ticker.present? - # First try to find an exact match in our DB, or if no exchange_operating_mic is provided, find by ticker only - internal_security = if exchange_operating_mic.present? - Security.find_by(ticker:, exchange_operating_mic:) - else - Security.find_by(ticker:) - end + # Avoids resolving the same security over and over again (resolver potentially makes network calls) + @security_cache ||= {} - return internal_security if internal_security.present? + cache_key = [ ticker, exchange_operating_mic ].compact.join(":") - # If security prices provider isn't properly configured or available, create with nil exchange_operating_mic - return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) unless Security.provider.present? + security = @security_cache[cache_key] - # Cache provider responses so that when we're looping through rows and importing, - # we only hit our provider for the unique combinations of ticker / exchange_operating_mic - cache_key = [ ticker, exchange_operating_mic ] - @provider_securities_cache ||= {} + return security if security.present? - provider_security = @provider_securities_cache[cache_key] ||= begin - Security.search_provider( - ticker, - exchange_operating_mic: exchange_operating_mic - ).first - end + security = Security::Resolver.new( + ticker, + exchange_operating_mic: exchange_operating_mic.presence + ).resolve - return Security.find_or_create_by!(ticker: ticker&.upcase, exchange_operating_mic: nil) if provider_security.nil? + @security_cache[cache_key] = security - Security.find_or_create_by!(ticker: provider_security[:ticker]&.upcase, exchange_operating_mic: provider_security[:exchange_operating_mic]&.upcase) do |security| - security.name = provider_security[:name] - security.country_code = provider_security[:country_code] - security.logo_url = provider_security[:logo_url] - security.exchange_acronym = provider_security[:exchange_acronym] - security.exchange_mic = provider_security[:exchange_mic] - end + security end end diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb index 26633918..b5393856 100644 --- a/app/views/accounts/_accountable_group.html.erb +++ b/app/views/accounts/_accountable_group.html.erb @@ -50,7 +50,6 @@ <% else %>
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> - <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
diff --git a/app/views/accounts/index/_account_groups.erb b/app/views/accounts/index/_account_groups.erb index 51ba4d8b..841e60c2 100644 --- a/app/views/accounts/index/_account_groups.erb +++ b/app/views/accounts/index/_account_groups.erb @@ -11,9 +11,12 @@

<%= totals_by_currency(collection: accounts, money_method: :balance_money) %>

<% end %>
-
- <% accounts.each do |account| %> +
+ <% accounts.each_with_index do |account, index| %> <%= render account %> + <% unless index == accounts.count - 1 %> + <%= render "shared/ruler" %> + <% end %> <% end %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 704c6789..ab65dd4c 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -75,16 +75,14 @@
-
-
- <% calculator = Balance::TrendCalculator.new(@account.balances) %> +
+ <% calculator = Balance::TrendCalculator.new(@account.balances) %> - <%= entries_by_date(@entries) do |entries| %> - <% entries.each_with_index do |entry, index| %> - <%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %> - <% end %> + <%= entries_by_date(@entries) do |entries| %> + <% entries.each_with_index do |entry, index| %> + <%= render entry, balance_trend: index == 0 ? calculator.trend_for(entry.date) : nil, view_ctx: "account" %> <% end %> -
+ <% end %>
diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb index 784f2020..5b4adf1d 100644 --- a/app/views/budgets/_actuals_summary.html.erb +++ b/app/views/budgets/_actuals_summary.html.erb @@ -12,7 +12,7 @@
<% budget.income_category_totals.each do |category_total| %> -
+
<% end %>
@@ -38,7 +38,7 @@
<% budget.expense_category_totals.each do |category_total| %> -
+
<% end %>
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb index 5b9e1a1c..99437302 100644 --- a/app/views/budgets/_budget_categories.html.erb +++ b/app/views/budgets/_budget_categories.html.erb @@ -34,9 +34,7 @@
-
-
-
+ <%= render "shared/ruler" %> <% end %> <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %> diff --git a/app/views/categories/_category_list_group.html.erb b/app/views/categories/_category_list_group.html.erb index dc343d7f..17482848 100644 --- a/app/views/categories/_category_list_group.html.erb +++ b/app/views/categories/_category_list_group.html.erb @@ -7,8 +7,8 @@

<%= categories.count %>

-
-
+
+
<% Category::Group.for(categories).each_with_index do |group, idx| %> <%= render group.category %> @@ -17,7 +17,7 @@ <% end %> <% unless idx == Category::Group.for(categories).count - 1 %> - <%= render "categories/ruler" %> + <%= render "shared/ruler" %> <% end %> <% end %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 2c62d739..b0f75b8f 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -2,20 +2,20 @@
<%= styled_form_with model: category, class: "space-y-4" do |f| %> -
+
<%= render partial: "color_avatar", locals: { category: category } %> -
+
<%= icon("pen", size: "sm") %> -
+
">

Color

-
+
<% Category::COLORS.each do |color| %>
-
+

Icon

<% Category.icon_codes.each do |icon| %> diff --git a/app/views/categories/_ruler.html.erb b/app/views/categories/_ruler.html.erb deleted file mode 100644 index 600fcda1..00000000 --- a/app/views/categories/_ruler.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index 5f630e74..e15c0976 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -22,7 +22,7 @@
-
+
<% if @categories.any? %>
<% if @categories.incomes.any? %> diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 3b25083b..4329918f 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -40,7 +40,7 @@ <% end %>
-
+ <%= render "shared/ruler", classes: "my-2" %>
<% if @transaction.category %> diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb index c4be93ee..5f4d44b5 100644 --- a/app/views/chats/_chat_nav.html.erb +++ b/app/views/chats/_chat_nav.html.erb @@ -9,6 +9,8 @@ variant: "icon", icon: "menu", href: path, + frame: chat_frame, + text: "All chats" ) %>
@@ -24,7 +26,8 @@ variant: "link", text: "Edit chat title", href: edit_chat_path(chat, ctx: "chat"), - icon: "pencil", data: { turbo_frame: dom_id(chat, "title") }) %> + icon: "pencil", + frame: dom_id(chat, "title")) %> <% menu.with_item( variant: "button", diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb index 8560ef97..1b8f6d1d 100644 --- a/app/views/chats/index.html.erb +++ b/app/views/chats/index.html.erb @@ -2,32 +2,31 @@ <%= turbo_frame_tag chat_frame do %>
<% if @chats.any? %> - - <% end %> - -
- <% if @chats.any? %> -

Chats

+
+
+

Chats

+ <%= render LinkComponent.new( + id: "new-chat", + icon: "plus", + variant: "icon", + href: new_chat_path, + frame: chat_frame, + text: "New chat" + ) %> +
<%= render @chats %>
- <% else %> +
+ <% else %> +

Chats

<%= render "chats/ai_greeting" %>
- <%= render "messages/chat_form" %> - <% end %> -
+
+ <% end %>
<% end %>
diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb index fd1311b3..8bee5429 100644 --- a/app/views/chats/new.html.erb +++ b/app/views/chats/new.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag chat_frame do %> -
+
<%= render "chats/chat_nav", chat: @chat %>
diff --git a/app/views/credit_cards/_form.html.erb b/app/views/credit_cards/_form.html.erb index 2405b77b..ef3d5d3d 100644 --- a/app/views/credit_cards/_form.html.erb +++ b/app/views/credit_cards/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (account:, url:) %> <%= render "accounts/form", account: account, url: url do |form| %> -
+ <%= render "shared/ruler", classes: "my-4" %>
<%= form.fields_for :accountable do |credit_card_form| %> diff --git a/app/views/entries/_entry_group.html.erb b/app/views/entries/_entry_group.html.erb index 36aeb27a..2ee8954c 100644 --- a/app/views/entries/_entry_group.html.erb +++ b/app/views/entries/_entry_group.html.erb @@ -21,7 +21,7 @@
<% end %>
-
+
<%= content %>
diff --git a/app/views/entries/_ruler.html.erb b/app/views/entries/_ruler.html.erb deleted file mode 100644 index 31c6ee6c..00000000 --- a/app/views/entries/_ruler.html.erb +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/app/views/family_merchants/_family_merchant.html.erb b/app/views/family_merchants/_family_merchant.html.erb index f47d64c9..7614699c 100644 --- a/app/views/family_merchants/_family_merchant.html.erb +++ b/app/views/family_merchants/_family_merchant.html.erb @@ -1,6 +1,6 @@ <%# locals: (family_merchant:) %> -
+
<% if family_merchant.logo_url %>
diff --git a/app/views/family_merchants/_ruler.html.erb b/app/views/family_merchants/_ruler.html.erb deleted file mode 100644 index 530d99eb..00000000 --- a/app/views/family_merchants/_ruler.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index 944b4bb9..7438aa2b 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -9,7 +9,7 @@ ) %> -
+
<% if @merchants.any? %>
@@ -18,9 +18,9 @@

<%= @merchants.count %>

-
-
- <%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "family_merchants/ruler" %> +
+
+ <%= render partial: "family_merchants/family_merchant", collection: @merchants, spacer_template: "shared/ruler" %>
diff --git a/app/views/holdings/_ruler.html.erb b/app/views/holdings/_ruler.html.erb deleted file mode 100644 index 31c6ee6c..00000000 --- a/app/views/holdings/_ruler.html.erb +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/app/views/holdings/index.html.erb b/app/views/holdings/index.html.erb index 2fcd0714..d200e54f 100644 --- a/app/views/holdings/index.html.erb +++ b/app/views/holdings/index.html.erb @@ -13,7 +13,7 @@ <% end %>
-
+
<%= tag.p t(".name"), class: "col-span-4" %> <%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %> @@ -22,13 +22,12 @@ <%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
-
+
<%= render "holdings/cash", account: @account %> - - <%= render "holdings/ruler" %> + <%= render "shared/ruler" %> <% if @account.current_holdings.any? %> - <%= render partial: "holdings/holding", collection: @account.current_holdings, spacer_template: "ruler" %> + <%= render partial: "holdings/holding", collection: @account.current_holdings, spacer_template: "shared/ruler" %> <% end %>
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb index a0b1d965..88a75dce 100644 --- a/app/views/imports/_import.html.erb +++ b/app/views/imports/_import.html.erb @@ -1,4 +1,4 @@ -
+
<%= link_to import_path(import), class: "text-sm text-primary hover:underline" do %> diff --git a/app/views/imports/_ready.html.erb b/app/views/imports/_ready.html.erb index 225c0dd1..d093694f 100644 --- a/app/views/imports/_ready.html.erb +++ b/app/views/imports/_ready.html.erb @@ -16,7 +16,7 @@ <% import.dry_run.each do |key, count| %> <% resource = dry_run_resource(key) %> -
+
<%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %> <%= icon resource.icon, color: "current" %> @@ -29,7 +29,7 @@
<% if key != import.dry_run.keys.last %> -
+ <%= render "shared/ruler" %> <% end %> <% end %>
diff --git a/app/views/imports/_table.html.erb b/app/views/imports/_table.html.erb index 4b7c32f5..5cc3a49c 100644 --- a/app/views/imports/_table.html.erb +++ b/app/views/imports/_table.html.erb @@ -1,5 +1,5 @@ <%# locals: (headers: [], rows: [], caption: nil) %> -
+
<% if caption %>
@@ -8,40 +8,38 @@

<%= caption %>

<% end %> -
-
- - +
+
+ + + <% headers.each_with_index do |header, index| %> + + <% end %> + + + + <% rows.each_with_index do |row, row_index| %> - <% headers.each_with_index do |header, index| %> - <% end %> - - - <% rows.each_with_index do |row, row_index| %> - - <% row.each_with_index do |(header, value), col_index| %> - - <% end %> - - <% end %> - -
+ <%= index == headers.length - 1 ? "rounded-tr-lg" : "" %> + <%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %> + "> + <%= header %> +
- <%= index == headers.length - 1 ? "rounded-tr-md" : "" %> - <%= index < headers.length - 1 ? "border-r border-r-alpha-black-200" : "" %> + <% row.each_with_index do |(header, value), col_index| %> + + <%= !caption && row_index == rows.length - 1 && col_index == 0 ? "rounded-bl-md" : "" %> + <%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? "rounded-br-md" : "" %> "> - <%= header %> - + <%= value %> +
- <%= !caption && row_index == rows.length - 1 && col_index == 0 ? "rounded-bl-md" : "" %> - <%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? "rounded-br-md" : "" %> - "> - <%= value %> -
-
+ <% end %> + +
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 108f3dc5..1a81ecdb 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -18,7 +18,7 @@

<%= t(".imports") %> ยท <%= @imports.size %>

- <%= render partial: "imports/import", collection: @imports.ordered %> + <%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %>
<% end %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index ac739056..a1fcbe0c 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -21,9 +21,7 @@ <%= icon("chevron-right") %> <% end %> -
-
-
+ <%= render "shared/ruler" %> <% end %> @@ -43,9 +41,7 @@ <%= icon("chevron-right") %> <% end %> -
-
-
+ <%= render "shared/ruler" %> <% end %> @@ -65,9 +61,7 @@ <%= icon("chevron-right") %> <% end %> -
-
-
+ <%= render "shared/ruler" %> <% end %> @@ -87,9 +81,7 @@ <%= icon("chevron-right") %> <% end %> -
-
-
+ <%= render "shared/ruler" %> <% end %> @@ -105,9 +97,7 @@ <%= icon("chevron-right") %> <% end %> -
-
-
+ <%= render "shared/ruler" %> <% end %> diff --git a/app/views/loans/_form.html.erb b/app/views/loans/_form.html.erb index 6af5715f..48d7a0ce 100644 --- a/app/views/loans/_form.html.erb +++ b/app/views/loans/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (account:, url:) %> <%= render "accounts/form", account: account, url: url do |form| %> -
+ <%= render "shared/ruler", classes: "my-4" %>
<%= form.fields_for :accountable do |loan_form| %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 1f2f347e..82de3bd1 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -31,13 +31,21 @@ period: @period } %>
+
+ <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> +
+ + <%= turbo_frame_tag "cashflow_sankey_section" do %> +
+ <%= render partial: "pages/dashboard/cashflow_sankey", locals: { + sankey_data: @cashflow_sankey_data, + period: @cashflow_period + } %> +
+ <% end %> <% else %>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %>
<% end %> - -
- <%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %> -
diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 6b4ea525..7a71afe7 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -58,10 +58,13 @@
-
- <% classification_group.account_groups.each do |account_group| %> -
- +
+ <% classification_group.account_groups.each_with_index do |account_group, idx| %> +
+ <%= idx == classification_group.account_groups.size - 1 ? "rounded-b-lg" : "" %> + "> +
<%= icon("chevron-right", class: "group-open:rotate-90") %> @@ -124,13 +127,14 @@
<% if idx < account_group.accounts.size - 1 %> -
-
-
+ <%= render "shared/ruler", classes: "ml-21 mr-4" %> <% end %> <% end %>
+ <% unless idx == classification_group.account_groups.size - 1 %> + <%= render "shared/ruler", classes: "mx-4 group-ruler" %> + <% end %> <% end %>
@@ -149,3 +153,10 @@
<% end %>
+ +<%# Custom style for hiding ruler when details are open %> + diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb new file mode 100644 index 00000000..96a40636 --- /dev/null +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -0,0 +1,44 @@ +<%# locals: (sankey_data:, period:) %> +
+
+

+ Cashflow +

+ + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "cashflow_sankey_section" } do |form| %> + <%= form.select :cashflow_period, + Period.as_options, + { selected: period.key }, + data: { "auto-submit-form-target": "auto" }, + class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> + <% end %> +
+ + <% if sankey_data[:links].present? %> +
+
+
+ <% else %> +
+
+ <%= render FilledIconComponent.new( + variant: :container, + icon: "activity" # cashflow placeholder icon + ) %> + +

No cash flow data for this time period

+

Add transactions to display cash flow data or expand the time period

+ <%= render LinkComponent.new( + text: "Add transaction", + icon: "plus", + href: new_transaction_path, + frame: :modal + ) %> +
+
+ <% end %> +
diff --git a/app/views/properties/_form.html.erb b/app/views/properties/_form.html.erb index 61b6cb59..ea7b7ae0 100644 --- a/app/views/properties/_form.html.erb +++ b/app/views/properties/_form.html.erb @@ -5,7 +5,7 @@ Property::SUBTYPES.map { |k, v| [v[:long], k] }, { label: true, prompt: t("properties.form.subtype_prompt"), include_blank: t("properties.form.none") } %> -
+ <%= render "shared/ruler", classes: "my-4" %>
<%= form.fields_for :accountable do |property_form| %> diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb index 1dbf9641..3157a904 100644 --- a/app/views/rules/_rule.html.erb +++ b/app/views/rules/_rule.html.erb @@ -1,5 +1,6 @@ <%# locals: (rule:) %> -
"> +
"> +
<% if rule.name.present? %>

<%= rule.name %>

diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index c6ca513f..dc6e97aa 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -29,7 +29,7 @@

<% end %> -
+
<% if @rules.any? %>
@@ -58,13 +58,8 @@
-
- <% @rules.each_with_index do |rule, idx| %> - <%= render "rule", rule: rule %> - <% unless idx == @rules.size - 1 %> -
- <% end %> - <% end %> +
+ <%= render partial: "rule", collection: @rules, spacer_template: "shared/ruler" %>
diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 687d1858..ef2fd8ba 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -1,11 +1,24 @@
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> -
- - <%= combobox_security.name.presence || combobox_security.symbol %> - - - <%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %> - +
+
+ + <%= combobox_security.name.presence || combobox_security.symbol %> + + + <%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %> + +
+ <% if combobox_security.country_code.present? %> +
+ <%= image_tag("https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg", + class: "h-4 rounded-sm", # h-3 (12px) matches text-xs, w-5 for 3:5 aspect ratio + alt: "#{combobox_security.country_code.upcase} flag", + title: combobox_security.country_code.upcase) %> + + <%= combobox_security.country_code.upcase %> + +
+ <% end %>
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 5aa2c258..e63f5ea2 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -48,7 +48,7 @@ nav_sections = [

<%= section[:header] %>

-
+ <%= render "shared/ruler", classes: "w-full" %>
    <% section[:items].each do |item| %> @@ -67,7 +67,7 @@ nav_sections = [ <% end %>
-
<% @users.each do |user| %> -
+
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
diff --git a/app/views/shared/_ruler.html.erb b/app/views/shared/_ruler.html.erb new file mode 100644 index 00000000..faf96d8e --- /dev/null +++ b/app/views/shared/_ruler.html.erb @@ -0,0 +1,2 @@ +<%# locals: (classes: nil) %> +
"> diff --git a/app/views/subscriptions/upgrade.html.erb b/app/views/subscriptions/upgrade.html.erb index e1edb17f..ff1babb1 100644 --- a/app/views/subscriptions/upgrade.html.erb +++ b/app/views/subscriptions/upgrade.html.erb @@ -2,7 +2,14 @@