diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 119f883aa..8d62c1525 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG RUBY_VERSION=3.4.7 +ARG RUBY_VERSION=3.4.9 FROM ruby:${RUBY_VERSION}-slim-bookworm ENV DEBIAN_FRONTEND=noninteractive diff --git a/.ruby-version b/.ruby-version index 2aa513199..7bcbb3808 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +3.4.9 diff --git a/AGENTS.md b/AGENTS.md index c5359f025..399375924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,15 @@ Reviewers escalate violations of (2)–(3) to close/rewrite; (1) and (4) are req If you need to add a new securities price provider (Tiingo, EODHD, Binance-style crypto, etc.), see [adding-a-securities-provider.md](./docs/llm-guides/adding-a-securities-provider.md) for the full walkthrough — provider class, registry wiring, MIC handling, settings UI, locales, and tests. +## Debug Logging for Provider Syncs + +When a provider sync/import path hits a recoverable error or suspicious partial response that support may need to inspect later, prefer `DebugLogEntry.capture(...)` over `Rails.logger.*`. + +- Record support-relevant diagnostics in the debug log so they surface in the super-admin-friendly `/settings/debug` UI. +- Include `category`, `level`, `message`, `source`, `provider_key`, and useful structured `metadata`. +- Attach `family` and `account_provider` when available so support can filter and trace the affected connection. +- Reserve raw Rails logging for low-value local noise; anything operators may need should go to the debug log. + ## Providers: Pending Transactions and FX Metadata (SimpleFIN/Plaid/Lunchflow) - Pending detection diff --git a/CLAUDE.md b/CLAUDE.md index 4fc9fcf10..5464699de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,12 @@ Sidekiq handles asynchronous tasks: - AI chat responses (`AssistantResponseJob`) - Scheduled maintenance via sidekiq-cron +### Debug Logging for Provider Syncs +- Prefer `DebugLogEntry.capture(...)` over `Rails.logger.*` for provider sync/import failures, partial responses, and other support-relevant diagnostics. +- Record support-relevant incidents in the super-admin `/settings/debug` UI rather than leaving them only in raw application logs. +- Include `category`, `level`, `message`, `source`, `provider_key`, and structured `metadata`. +- Attach `family` and `account_provider` whenever possible so support can filter to the affected provider connection. + ### Frontend Architecture - **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript - **ViewComponents**: Reusable UI components in `app/components/` diff --git a/Dockerfile b/Dockerfile index c165b18f7..f691c470b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.7 +ARG RUBY_VERSION=3.4.9 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Dockerfile.preview b/Dockerfile.preview index f7cf1a2df..06a958a5e 100644 --- a/Dockerfile.preview +++ b/Dockerfile.preview @@ -3,7 +3,7 @@ # Preview Dockerfile for Cloudflare Containers # Includes PostgreSQL and Redis for self-contained development testing -ARG RUBY_VERSION=3.4.7 +ARG RUBY_VERSION=3.4.9 FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base WORKDIR /rails diff --git a/Gemfile.lock b/Gemfile.lock index b4169c29f..27f5c7a08 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -963,7 +963,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.9p82 BUNDLED WITH 2.6.7 diff --git a/app/components/DS/disclosure.html.erb b/app/components/DS/disclosure.html.erb index c323aa76c..8cd25bcfc 100644 --- a/app/components/DS/disclosure.html.erb +++ b/app/components/DS/disclosure.html.erb @@ -33,7 +33,7 @@ <% end %> <% end %> -
+ <%= tag.div class: body_class.presence do %> <%= content %> -
+ <% end %> <% end %> diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index b32433a6f..bff18f332 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -3,7 +3,7 @@ class DS::Disclosure < DesignSystemComponent VARIANTS = %i[default card card_inset inline].freeze - attr_reader :title, :align, :open, :variant, :summary_class_override, :opts + attr_reader :title, :align, :open, :variant, :summary_class_override, :body_class, :opts # `:default` — bg-surface summary, no chrome on the `
`. Use # for inline expanders that sit inside a parent card (the summary @@ -28,12 +28,18 @@ class DS::Disclosure < DesignSystemComponent # In card / inline variants, callers should pass their own # `summary_content` slot; the built-in title rendering assumes the # `:default` shape. - def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, **opts) + # `body_class:` styles the wrapper around the disclosure body. Defaults + # to `mt-2` (the standard gap below the summary). Pass `nil`/`""` to drop + # it — e.g. when the body is an absolutely-positioned popover whose + # wrapper would otherwise add ~8px of normal-flow margin and shove + # siblings down on open (see `goals/_color_picker`). + def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, body_class: "mt-2", **opts) @title = title @align = align.to_sym @open = open @variant = variant&.to_sym @summary_class_override = summary_class + @body_class = body_class @opts = opts raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 90a1f1054..844b5e066 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,24 @@ class PagesController < ApplicationController include Periodable + # Per-widget dashboard layout guardrails. Deterministic defaults the masonry + # packer reads; users may override a grow widget's height via presets. + # col_span: "single" | "full" (full spans both columns in 2-col mode) + # grow: true for charts that should fill an allotted height, + # false for content-sized widgets (tables, stat grids) + # min_height: floor in px + DASHBOARD_SECTION_LAYOUTS = { + "cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true }, + "outflows_donut" => { col_span: "single", grow: false, min_height: 0 }, + "investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }, + "net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true }, + "balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true } + }.freeze + + # Selectable height presets (px) for grow widgets. + DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze + DEFAULT_HEIGHT_PRESET = "auto" + skip_authentication only: %i[redis_configuration_error privacy terms] before_action :ensure_intro_guest!, only: :intro @@ -78,8 +96,9 @@ class PagesController < ApplicationController def preferences_params prefs = params.require(:preferences) {}.tap do |permitted| - permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections] - permitted["section_order"] = prefs[:section_order] if prefs[:section_order] + permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h) + permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array) + permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h) end end @@ -89,6 +108,7 @@ class PagesController < ApplicationController key: "cashflow_sankey", title: "pages.dashboard.cashflow_sankey.title", partial: "pages/dashboard/cashflow_sankey", + layout: section_layout("cashflow_sankey"), locals: { sankey_data: @cashflow_sankey_data, period: @period }, visible: @accounts.any?, collapsible: true @@ -97,6 +117,7 @@ class PagesController < ApplicationController key: "outflows_donut", title: "pages.dashboard.outflows_donut.title", partial: "pages/dashboard/outflows_donut", + layout: section_layout("outflows_donut"), locals: { outflows_data: @outflows_data, period: @period }, visible: @accounts.any? && @outflows_data[:categories].present?, collapsible: true @@ -105,6 +126,7 @@ class PagesController < ApplicationController key: "investment_summary", title: "pages.dashboard.investment_summary.title", partial: "pages/dashboard/investment_summary", + layout: section_layout("investment_summary"), locals: { investment_statement: @investment_statement, period: @period }, visible: @accounts.any? && @investment_statement.investment_accounts.any?, collapsible: true @@ -113,6 +135,7 @@ class PagesController < ApplicationController key: "net_worth_chart", title: "pages.dashboard.net_worth_chart.title", partial: "pages/dashboard/net_worth_chart", + layout: section_layout("net_worth_chart"), locals: { balance_sheet: @balance_sheet, period: @period }, visible: @accounts.any?, collapsible: true @@ -121,6 +144,7 @@ class PagesController < ApplicationController key: "balance_sheet", title: "pages.dashboard.balance_sheet.title", partial: "pages/dashboard/balance_sheet", + layout: section_layout("balance_sheet"), locals: { balance_sheet: @balance_sheet }, visible: @accounts.any?, collapsible: true @@ -141,6 +165,22 @@ class PagesController < ApplicationController ordered_sections end + # Resolves a section's layout guardrails, applying the user's height preset + # override (falling back to the deterministic default) for grow widgets. + def section_layout(key) + base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false }) + preset = Current.user.dashboard_section_height(key) + preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset) + + col_span = base[:col_span] + if base[:width_toggle] + user_span = Current.user.dashboard_section_width(key) + col_span = user_span if %w[single full].include?(user_span) + end + + base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset)) + end + def github_provider Provider::Registry.get_provider(:github) end diff --git a/app/controllers/settings/securities_controller.rb b/app/controllers/settings/securities_controller.rb index d53bddd77..6d32a0785 100644 --- a/app/controllers/settings/securities_controller.rb +++ b/app/controllers/settings/securities_controller.rb @@ -8,5 +8,7 @@ class Settings::SecuritiesController < ApplicationController ] @oidc_identities = Current.user.oidc_identities.order(:provider) @webauthn_credentials = Current.user.webauthn_credentials.order(created_at: :asc) + @encryption_unconfigured = Rails.application.config.app_mode.self_hosted? && + !ActiveRecordEncryptionConfig.explicitly_configured? end end diff --git a/app/javascript/controllers/dashboard_masonry_controller.js b/app/javascript/controllers/dashboard_masonry_controller.js new file mode 100644 index 000000000..99c2343b1 --- /dev/null +++ b/app/javascript/controllers/dashboard_masonry_controller.js @@ -0,0 +1,82 @@ +import { Controller } from "@hotwired/stimulus"; + +// Packs variable-height dashboard cards into a tight masonry layout by computing +// each card's grid row span from its measured height, letting `grid-auto-flow: +// dense` fill the gaps. Active only when the grid is actually multi-column; in +// single-column mode it clears the spans and normal block flow takes over. +// +// The DOM stays a single flat list of
children, so +// the dashboard-sortable controller (drag / keyboard reorder) is unaffected — +// reordering just moves a node and the CSS re-packs. +export default class extends Controller { + connect() { + this._scheduleLayout = this._scheduleLayout.bind(this); + this._frame = null; + this._resizeObserver = new ResizeObserver(this._scheduleLayout); + + this._cards().forEach((card) => this._resizeObserver.observe(card)); + this._scheduleLayout(); + + // Re-pack after the dashboard turbo frame re-renders (period change, reorder + // save) and on viewport changes that may cross the column breakpoint. + this.element.addEventListener("turbo:frame-load", this._scheduleLayout); + window.addEventListener("resize", this._scheduleLayout); + } + + disconnect() { + this._resizeObserver?.disconnect(); + this.element.removeEventListener("turbo:frame-load", this._scheduleLayout); + window.removeEventListener("resize", this._scheduleLayout); + if (this._frame) cancelAnimationFrame(this._frame); + this._cards().forEach((card) => { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + }); + } + + _cards() { + return Array.from( + this.element.querySelectorAll(":scope > [data-section-key]"), + ); + } + + _scheduleLayout() { + if (this._frame) cancelAnimationFrame(this._frame); + this._frame = requestAnimationFrame(() => this._layout()); + } + + _layout() { + const styles = getComputedStyle(this.element); + const columns = styles.gridTemplateColumns + .split(" ") + .filter(Boolean).length; + + // Single column: let natural block flow handle it, clear any stale spans. + if (columns < 2) { + this._cards().forEach((card) => { + if (card.dataset.masonrySpan) { + card.style.gridRowEnd = ""; + delete card.dataset.masonrySpan; + } + }); + return; + } + + // The row gap is zeroed in masonry mode (2xl:gap-y-0). We reproduce a uniform + // inter-card gap by padding each card's span with empty tracks equal to the + // column gap. Integer row-spans over a non-zero row gap otherwise overshoot + // by up to one gap's worth of slack, giving visibly uneven vertical gaps. + const rowUnit = Number.parseFloat(styles.gridAutoRows) || 1; + const gap = Number.parseFloat(styles.columnGap) || 0; + const gapTracks = Math.round(gap / rowUnit); + + this._cards().forEach((card) => { + const height = card.getBoundingClientRect().height; + const span = Math.max(1, Math.ceil(height / rowUnit) + gapTracks); + if (card.dataset.masonrySpan !== String(span)) { + card.style.gridRowEnd = `span ${span}`; + card.dataset.masonrySpan = String(span); + } + }); + } +} diff --git a/app/javascript/controllers/dashboard_widget_size_controller.js b/app/javascript/controllers/dashboard_widget_size_controller.js new file mode 100644 index 000000000..57c87cf5d --- /dev/null +++ b/app/javascript/controllers/dashboard_widget_size_controller.js @@ -0,0 +1,110 @@ +import { Controller } from "@hotwired/stimulus"; + +// Per-widget layout picker (mounted on the
menu). Optimistically applies +// the choice for instant feedback, then persists it fire-and-forget — mirroring how +// collapse / reorder already save dashboard preferences: +// +// - Height: sets the `--dash-widget-h` CSS var; the chart redraws via its own +// ResizeObserver and dashboard-masonry re-packs from the height change. +// - Width: toggles the `2xl:col-span-2` class; the CSS grid re-flows and a +// synthetic resize nudges dashboard-masonry to re-pack. +export default class extends Controller { + static values = { sectionKey: String }; + + connect() { + this._closeOnOutsideClick = this._closeOnOutsideClick.bind(this); + document.addEventListener("click", this._closeOnOutsideClick); + } + + disconnect() { + document.removeEventListener("click", this._closeOnOutsideClick); + } + + // Keep Enter/Space/arrow keydowns inside the menu from bubbling to the + // section-level dashboard-sortable handler, which would otherwise hijack them + // to toggle keyboard reorder mode. + stopKeydown(event) { + event.stopPropagation(); + } + + selectHeight(event) { + const { preset, height } = event.currentTarget.dataset; + const section = this._section(); + if (section && height) { + section.style.setProperty("--dash-widget-h", `${height}px`); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ height: preset }); + } + + selectWidth(event) { + const { colSpan } = event.currentTarget.dataset; + const section = this._section(); + if (section) { + section.classList.toggle("2xl:col-span-2", colSpan === "full"); + } + this._markSelected(event.currentTarget); + this.element.open = false; + this._save({ col_span: colSpan }); + // Width change re-flows the grid; nudge dashboard-masonry to re-pack. + window.dispatchEvent(new Event("resize")); + } + + _section() { + return this.element.closest("[data-section-key]"); + } + + // Move the active state to the clicked segment within its segmented control, + // mirroring DS::SegmentedControl's class + aria-pressed contract. + _markSelected(button) { + const group = button.closest(".segmented-control"); + if (!group) return; + group.querySelectorAll(".segmented-control__segment").forEach((el) => { + const on = el === button; + el.classList.toggle("segmented-control__segment--active", on); + el.setAttribute("aria-pressed", on ? "true" : "false"); + }); + } + + _closeOnOutsideClick(event) { + if (this.element.open && !this.element.contains(event.target)) { + this.element.open = false; + } + } + + async _save(payload) { + const csrfToken = document.querySelector('meta[name="csrf-token"]'); + if (!csrfToken) { + console.error("[Dashboard Widget Size] CSRF token not found."); + return; + } + + try { + const response = await fetch("/dashboard/preferences", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content, + }, + body: JSON.stringify({ + preferences: { + dashboard_section_layout: { [this.sectionKeyValue]: payload }, + }, + }), + }); + + if (!response.ok) { + console.error( + "[Dashboard Widget Size] Failed to save layout:", + response.status, + ); + } + } catch (error) { + console.error( + "[Dashboard Widget Size] Network error saving layout:", + error, + ); + } + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index 25727230f..104f5cd39 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,4 +1,18 @@ class ApplicationJob < ActiveJob::Base + # Defer enqueuing until the surrounding Active Record transaction commits, so a + # job is never picked up by a worker before the records it references (by + # GlobalID) are committed and visible on the worker's connection — and is + # dropped entirely if the transaction rolls back. Without this, a job enqueued + # inside a transaction can be dequeued before COMMIT, fail to load its + # arguments (ActiveJob::DeserializationError), and be silently dropped by the + # `discard_on` below — the "stuck sync" regression for SyncJob enqueued in + # Syncable#sync_later. + # + # This is the Rails 8.2 default. On 8.1 the global + # `config.active_job.enqueue_after_transaction_commit` toggle is non-functional, + # so we set the class attribute on the base job, which every job inherits. + self.enqueue_after_transaction_commit = true + retry_on ActiveRecord::Deadlocked discard_on ActiveJob::DeserializationError queue_as :low_priority # default queue diff --git a/app/jobs/destroy_job.rb b/app/jobs/destroy_job.rb index ba937e61f..8d9483536 100644 --- a/app/jobs/destroy_job.rb +++ b/app/jobs/destroy_job.rb @@ -1,6 +1,10 @@ class DestroyJob < ApplicationJob queue_as :low_priority - self.enqueue_after_transaction_commit = :never + # Inherits enqueue_after_transaction_commit = true from ApplicationJob. (This + # previously read `= :never`, the removed Rails 7.2 symbol API; under 8.1 that + # symbol is truthy, so it already deferred — the explicit line was dead and + # misleading.) Deferring is correct here: destroy after the enclosing + # transaction commits, never against an uncommitted/rolled-back record. def perform(model) model.destroy diff --git a/app/models/coinstats_item/importer.rb b/app/models/coinstats_item/importer.rb index 8c2075daf..2a5f77b32 100644 --- a/app/models/coinstats_item/importer.rb +++ b/app/models/coinstats_item/importer.rb @@ -149,8 +149,17 @@ class CoinstatsItem::Importer response = coinstats_provider.get_wallet_balances(wallets_param) response.success? ? response.data : FETCH_FAILED rescue => e - Rails.logger.warn "CoinstatsItem::Importer - Bulk balance fetch failed: #{e.message}" - FETCH_FAILED + log_debug_event( + level: "warn", + message: "CoinStats bulk balance fetch failed", + metadata: { + coinstats_item_id: coinstats_item.id, + wallets: wallets.map { |wallet| "#{wallet[:blockchain]}:#{wallet[:address]}" }, + error_class: e.class.name, + error_message: e.message + } + ) + nil end def fetch_portfolio_coins_for_exchange(linked_accounts) @@ -244,15 +253,38 @@ class CoinstatsItem::Importer return { success: false, error: "Missing address or blockchain" } end - # Extract balance data for this specific wallet from the bulk response - balance_data = if bulk_balance_data.present? - coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain) + if bulk_balance_data.nil? + log_debug_event( + level: "warn", + message: "CoinStats wallet balance sync preserved existing snapshot after bulk fetch failure", + account_provider: coinstats_account.account_provider, + metadata: { + coinstats_item_id: coinstats_item.id, + coinstats_account_id: coinstats_account.id, + wallet_address: address, + blockchain: blockchain, + reason: "bulk_balance_data_missing" + } + ) + elsif wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain) + log_debug_event( + level: "warn", + message: "CoinStats wallet balance sync preserved existing snapshot because wallet was missing from bulk response", + account_provider: coinstats_account.account_provider, + metadata: { + coinstats_item_id: coinstats_item.id, + coinstats_account_id: coinstats_account.id, + wallet_address: address, + blockchain: blockchain, + reason: "wallet_missing_from_bulk_response" + } + ) else - [] - end + balance_data = coinstats_provider.extract_wallet_balance(bulk_balance_data, address, blockchain) - # Update the coinstats account with new balance data - coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account)) + # Update the coinstats account with new balance data + coinstats_account.upsert_coinstats_snapshot!(normalize_balance_data(balance_data, coinstats_account)) + end # Extract and merge transactions from bulk response transactions_count = fetch_and_merge_transactions(coinstats_account, address, blockchain, bulk_transactions_data) @@ -260,6 +292,30 @@ class CoinstatsItem::Importer { success: true, transactions_count: transactions_count } end + def wallet_missing_from_bulk_balance_data?(bulk_balance_data, address, blockchain) + return true unless bulk_balance_data.is_a?(Array) + + bulk_balance_data.none? do |entry| + entry = entry.with_indifferent_access + entry[:address]&.downcase == address&.downcase && + (entry[:connectionId]&.downcase == blockchain&.downcase || + entry[:blockchain]&.downcase == blockchain&.downcase) + end + end + + def log_debug_event(level:, message:, metadata:, account_provider: nil) + DebugLogEntry.capture( + category: "provider_sync_error", + level: level, + message: message, + source: self.class.name, + provider_key: "coinstats", + family: coinstats_item.family, + account_provider: account_provider, + metadata: metadata + ) + end + def update_exchange_account(coinstats_account, portfolio_coins_data:, portfolio_transactions_data:) portfolio_id = exchange_portfolio_id_for(coinstats_account) balance_data = portfolio_coins_data[portfolio_id] diff --git a/app/models/concerns/enrichable.rb b/app/models/concerns/enrichable.rb index a354d4245..8b01698e2 100644 --- a/app/models/concerns/enrichable.rb +++ b/app/models/concerns/enrichable.rb @@ -41,20 +41,20 @@ module Enrichable end # Convenience method for a single attribute - def enrich_attribute(attr, value, source:, metadata: {}) - enrich_attributes({ attr => value }, source:, metadata:) + def enrich_attribute(attr, value, source:, metadata: {}, ignore_locks: false) + enrich_attributes({ attr => value }, source:, metadata:, ignore_locks:) end # Enriches and logs all attributes that: - # - Are not locked + # - Are not locked (unless ignore_locks: true, e.g. an explicit rule re-apply) # - Are not ignored # - Have changed value from the last saved value # Returns true if any attributes were actually changed, false otherwise - def enrich_attributes(attrs, source:, metadata: {}) + def enrich_attributes(attrs, source:, metadata: {}, ignore_locks: false) # Track current values before modification for virtual attributes (like tag_ids) current_values = {} enrichable_attrs = Array(attrs).reject do |attr_key, attr_value| - if locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) + if (locked?(attr_key) && !ignore_locks) || ignored_enrichable_attributes.include?(attr_key) true else # For virtual attributes (like tag_ids), use the getter method diff --git a/app/models/family/syncer.rb b/app/models/family/syncer.rb index 66721a1a2..9952e2d55 100644 --- a/app/models/family/syncer.rb +++ b/app/models/family/syncer.rb @@ -1,29 +1,6 @@ class Family::Syncer attr_reader :family - # Registry of item association names that participate in family sync. - # Each model must: - # 1. Include Syncable - # 2. Define a `syncable` scope (items ready for auto-sync) - # - # To add a new provider: add its association name here. - # The model handles its own "ready to sync" logic via the syncable scope. - SYNCABLE_ITEM_ASSOCIATIONS = %i[ - plaid_items - simplefin_items - lunchflow_items - akahu_items - enable_banking_items - indexa_capital_items - coinbase_items - coinstats_items - mercury_items - brex_items - binance_items - snaptrade_items - sophtron_items - ].freeze - def initialize(family) @family = family end @@ -49,14 +26,25 @@ class Family::Syncer private - # Collects all syncable items from registered providers + manual accounts. - # Each provider model defines its own `syncable` scope that encapsulates - # the "ready to sync" business logic (active, configured, etc.) + # Collect all syncable provider items via reflection so new `*_items` + # integrations participate in nightly family sync as soon as they include + # Syncable and expose a `syncable` scope. def child_syncables - provider_items = SYNCABLE_ITEM_ASSOCIATIONS.flat_map do |association| + provider_items = syncable_item_associations.flat_map do |association| family.public_send(association).syncable end provider_items + family.accounts.manual end + + def syncable_item_associations + Family.reflect_on_all_associations(:has_many).filter_map do |association| + next unless association.name.to_s.end_with?("_items") + next unless association.klass.included_modules.include?(Syncable) + + association.name + rescue NameError + nil + end + end end diff --git a/app/models/rule/action_executor/exclude_transaction.rb b/app/models/rule/action_executor/exclude_transaction.rb index e56edadc5..79f1b0658 100644 --- a/app/models/rule/action_executor/exclude_transaction.rb +++ b/app/models/rule/action_executor/exclude_transaction.rb @@ -19,7 +19,8 @@ class Rule::ActionExecutor::ExcludeTransaction < Rule::ActionExecutor txn.entry.enrich_attribute( :excluded, true, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/rule/action_executor/set_investment_activity_label.rb b/app/models/rule/action_executor/set_investment_activity_label.rb index 21e421292..3ae422518 100644 --- a/app/models/rule/action_executor/set_investment_activity_label.rb +++ b/app/models/rule/action_executor/set_investment_activity_label.rb @@ -24,7 +24,8 @@ class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor txn.enrich_attribute( :investment_activity_label, value, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/rule/action_executor/set_transaction_category.rb b/app/models/rule/action_executor/set_transaction_category.rb index 790cc8ae2..689372f00 100644 --- a/app/models/rule/action_executor/set_transaction_category.rb +++ b/app/models/rule/action_executor/set_transaction_category.rb @@ -22,7 +22,8 @@ class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor txn.enrich_attribute( :category_id, category.id, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/rule/action_executor/set_transaction_merchant.rb b/app/models/rule/action_executor/set_transaction_merchant.rb index 0b0d43ad3..06bdf1b5d 100644 --- a/app/models/rule/action_executor/set_transaction_merchant.rb +++ b/app/models/rule/action_executor/set_transaction_merchant.rb @@ -21,7 +21,8 @@ class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor txn.enrich_attribute( :merchant_id, merchant.id, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/rule/action_executor/set_transaction_name.rb b/app/models/rule/action_executor/set_transaction_name.rb index 4d4c84d8a..737c258f0 100644 --- a/app/models/rule/action_executor/set_transaction_name.rb +++ b/app/models/rule/action_executor/set_transaction_name.rb @@ -24,7 +24,8 @@ class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor txn.entry.enrich_attribute( :name, value, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/rule/action_executor/set_transaction_tags.rb b/app/models/rule/action_executor/set_transaction_tags.rb index 80166950e..00ceff10f 100644 --- a/app/models/rule/action_executor/set_transaction_tags.rb +++ b/app/models/rule/action_executor/set_transaction_tags.rb @@ -26,7 +26,8 @@ class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor txn.enrich_attribute( :tag_ids, merged_tag_ids, - source: "rule" + source: "rule", + ignore_locks: ignore_attribute_locks ) end end diff --git a/app/models/user.rb b/app/models/user.rb index cf1c257c1..1955a64fb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -314,6 +314,16 @@ class User < ApplicationRecord preferences&.[]("section_order") || default_dashboard_section_order end + # Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default. + def dashboard_section_height(section_key) + preferences&.dig("dashboard_section_layout", section_key, "height") + end + + # Per-widget column-span override ("single" | "full"); nil = use default. + def dashboard_section_width(section_key) + preferences&.dig("dashboard_section_layout", section_key, "col_span") + end + def update_dashboard_preferences(prefs) # Use pessimistic locking to ensure atomic read-modify-write # This prevents race conditions when multiple sections are collapsed quickly @@ -324,7 +334,9 @@ class User < ApplicationRecord prefs.each do |key, value| if value.is_a?(Hash) updated_prefs[key] ||= {} - updated_prefs[key] = updated_prefs[key].merge(value) + # deep_merge so a partial update of one nested dimension (e.g. a widget's + # col_span) doesn't clobber a sibling dimension (e.g. its height). + updated_prefs[key] = updated_prefs[key].deep_merge(value) else updated_prefs[key] = value end diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb index dc5292222..784091394 100644 --- a/app/views/goals/_color_picker.html.erb +++ b/app/views/goals/_color_picker.html.erb @@ -14,16 +14,20 @@ <% end %> - <%# Raw
instead of DS::Disclosure: the disclosure wraps its body - in an `mt-2` div that lives in normal flow, which pushed the form down - ~8px every time this absolutely-positioned popover opened. %> -
- " - class="list-none cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300"> + <%# `body_class: nil` drops DS::Disclosure's default `mt-2` body wrapper: + the body here is an absolutely-positioned popover, so that wrapper's + normal-flow margin would otherwise push the form down ~8px on open. %> + <%= render DS::Disclosure.new( + body_class: nil, + summary_class: "list-none cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300", + data: { + color_icon_picker_target: "details", + action: "mousedown->color-icon-picker#handleOutsideClick" + }) do |disclosure| %> + <% disclosure.with_summary_content do %> + <%= t("goals.color_picker.trigger_label") %> <%= icon("pen", size: "xs") %> - + <% end %>
@@ -69,6 +73,6 @@
-
+ <% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index c2fd65fa4..c804f0c1b 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -36,12 +36,14 @@ <% end %> -
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> + <% two_column = Current.user.dashboard_two_column? %> +
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable dashboard-masonry" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="<%= t("pages.dashboard.sections_aria_label") %>"> <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %>
" + style="<%= "--dash-widget-h: #{section[:layout][:height_px]}px;" if section[:layout][:grow] %>" data-dashboard-sortable-target="section" data-section-key="<%= section[:key] %>" data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>" @@ -85,6 +87,57 @@ <%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %> <% end %> + <% layout = section[:layout] %> + <% show_width = layout[:width_toggle] && two_column %> + <% if layout[:grow] || show_width %> + + <% end %>
diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 50d05f649..681aa2341 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -115,9 +115,9 @@ <%= t(".col_cost") %> - + <% @llm_usages.each do |usage| %> - "> + "> <%= usage.created_at.strftime("%b %d, %Y %I:%M %p") %> @@ -131,8 +131,8 @@ <% if usage.failed? %>
- <%= icon "alert-circle", class: "w-4 h-4 text-red-600 theme-dark:text-red-400" %> - <%= t(".failed") %> + <%= icon "alert-circle", size: "sm", color: "destructive" %> + <%= t(".failed") %>