1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Add sync states throughout app

This commit is contained in:
Zach Gollwitzer 2025-05-13 15:49:02 -04:00
parent 3cab7164d3
commit 9d8d6bd86b
34 changed files with 295 additions and 166 deletions

View file

@ -241,6 +241,15 @@
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
} }
@keyframes shiny-wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
} }
/* Specific override for strong tags in prose under dark mode */ /* Specific override for strong tags in prose under dark mode */
@ -429,5 +438,3 @@
} }
} }

View file

@ -85,3 +85,7 @@
background-color: var(--color-alpha-black-900); background-color: var(--color-alpha-black-900);
} }
} }
@utility bg-loader {
@apply bg-surface-inset animate-pulse;
}

View file

@ -1,6 +1,7 @@
/* Custom shadow borders used for surfaces / containers */ /* Custom shadow borders used for surfaces / containers */
@utility shadow-border-xs { @utility shadow-border-xs {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50); box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
transform: translateZ(0);
@variant theme-dark { @variant theme-dark {
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50); box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);
@ -9,6 +10,7 @@
@utility shadow-border-sm { @utility shadow-border-sm {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50); box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
transform: translateZ(0);
@variant theme-dark { @variant theme-dark {
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50); box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);
@ -17,6 +19,7 @@
@utility shadow-border-md { @utility shadow-border-md {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50); box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
transform: translateZ(0);
@variant theme-dark { @variant theme-dark {
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50); box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);
@ -25,6 +28,7 @@
@utility shadow-border-lg { @utility shadow-border-lg {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50); box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
transform: translateZ(0);
@variant theme-dark { @variant theme-dark {
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50); box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);
@ -33,6 +37,7 @@
@utility shadow-border-xl { @utility shadow-border-xl {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50); box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
transform: translateZ(0);
@variant theme-dark { @variant theme-dark {
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50); box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);

View file

@ -26,14 +26,6 @@ class AccountsController < ApplicationController
render layout: false render layout: false
end end
def sync_all
unless family.syncing?
family.sync_later
end
redirect_back_or_to accounts_path
end
private private
def family def family
Current.family Current.family

View file

@ -14,6 +14,12 @@ module AutoSync
return false unless Current.family.present? return false unless Current.family.present?
return false unless Current.family.accounts.active.any? return false unless Current.family.accounts.active.any?
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current should_sync = (Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
if should_sync
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_synced_at}"
end
should_sync
end end
end end

View file

@ -46,8 +46,6 @@ module Notifiable
[ { partial: "shared/notifications/alert", locals: { message: data } } ] [ { partial: "shared/notifications/alert", locals: { message: data } } ]
when "cta" when "cta"
[ resolve_cta(data) ] [ resolve_cta(data) ]
when "loading"
[ { partial: "shared/notifications/loading", locals: { message: data } } ]
when "notice" when "notice"
messages = Array(data) messages = Array(data)
messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } } messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } }

View file

@ -61,6 +61,10 @@ class Account < ApplicationRecord
end end
end end
def syncing?
true
end
def institution_domain def institution_domain
url_string = plaid_account&.plaid_item&.institution_url url_string = plaid_account&.plaid_item&.institution_url
return nil unless url_string.present? return nil unless url_string.present?

View file

@ -22,20 +22,25 @@ class BalanceSheet
end end
def classification_groups def classification_groups
asset_groups = account_groups("asset")
liability_groups = account_groups("liability")
[ [
ClassificationGroup.new( ClassificationGroup.new(
key: "asset", key: "asset",
display_name: "Assets", display_name: "Assets",
icon: "plus", icon: "plus",
total_money: total_assets_money, total_money: total_assets_money,
account_groups: account_groups("asset") account_groups: asset_groups,
syncing?: asset_groups.any?(&:syncing?)
), ),
ClassificationGroup.new( ClassificationGroup.new(
key: "liability", key: "liability",
display_name: "Debts", display_name: "Debts",
icon: "minus", icon: "minus",
total_money: total_liabilities_money, total_money: total_liabilities_money,
account_groups: account_groups("liability") account_groups: liability_groups,
syncing?: liability_groups.any?(&:syncing?)
) )
] ]
end end
@ -57,6 +62,7 @@ class BalanceSheet
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100, weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
missing_rates?: accounts.any? { |a| a.missing_rates? }, missing_rates?: accounts.any? { |a| a.missing_rates? },
color: accountable.color, color: accountable.color,
syncing?: accounts.any?(&:is_syncing),
accounts: accounts.map do |account| accounts: accounts.map do |account|
account.define_singleton_method(:weight) do account.define_singleton_method(:weight) do
classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100 classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
@ -76,9 +82,13 @@ class BalanceSheet
family.currency family.currency
end end
def syncing?
classification_groups.any? { |group| group.syncing? }
end
private private
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true) ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true) AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
def active_accounts def active_accounts
family.accounts.active.with_attached_logo family.accounts.active.with_attached_logo
@ -87,9 +97,11 @@ class BalanceSheet
def totals_query def totals_query
@totals_query ||= active_accounts @totals_query ||= active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ])) .joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
.joins("LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND (syncs.status = 'pending' OR syncs.status = 'syncing')")
.select( .select(
"accounts.*", "accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance", "SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
"COUNT(syncs.id) > 0 as is_syncing",
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ]) ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
) )
.group(:classification, :accountable_type, :id) .group(:classification, :accountable_type, :id)

View file

@ -39,15 +39,13 @@ class Family < ApplicationRecord
broadcast_remove target: "syncing-notice" broadcast_remove target: "syncing-notice"
end end
# If family has any syncs pending/syncing within the last 10 minutes, we show a persistent "syncing" notice. # If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running.
# Ignore syncs older than 10 minutes as they are considered "stale"
def syncing? def syncing?
Sync.where( Sync.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'")
"(syncable_type = 'Family' AND syncable_id = ?) OR .joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR .where("syncs.syncable_id = ? OR accounts.family_id = ? OR plaid_items.family_id = ?", id, id, id)
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))", .incomplete
id, id, id .exists?
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
end end
def assigned_merchants def assigned_merchants

View file

@ -52,6 +52,14 @@ class PlaidItem < ApplicationRecord
DestroyJob.perform_later(self) DestroyJob.perform_later(self)
end end
def syncing?
Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
.joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id")
.where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id)
.incomplete
.exists?
end
def auto_match_categories! def auto_match_categories!
if family.categories.none? if family.categories.none?
family.categories.bootstrap! family.categories.bootstrap!

View file

@ -17,6 +17,7 @@ class Subscription < ApplicationRecord
validates :stripe_id, presence: true, if: :active? validates :stripe_id, presence: true, if: :active?
validates :trial_ends_at, presence: true, if: :trialing? validates :trial_ends_at, presence: true, if: :trialing?
validates :family_id, uniqueness: true
class << self class << self
def new_trial_ends_at def new_trial_ends_at

View file

@ -20,6 +20,8 @@ class Sync < ApplicationRecord
state :completed state :completed
state :failed state :failed
after_all_transitions :log_status_change
event :start, after_commit: :report_warnings do event :start, after_commit: :report_warnings do
transitions from: :pending, to: :syncing transitions from: :pending, to: :syncing
end end
@ -41,6 +43,7 @@ class Sync < ApplicationRecord
end end
def perform def perform
Rails.logger.tagged("Sync", id, syncable_type, syncable_id) do
start! start!
begin begin
@ -51,6 +54,7 @@ class Sync < ApplicationRecord
handle_error(e) handle_error(e)
end end
end end
end
# If the sync doesn't have any in-progress children, finalize it. # If the sync doesn't have any in-progress children, finalize it.
def attempt_finalization def attempt_finalization
@ -68,6 +72,10 @@ class Sync < ApplicationRecord
end end
private private
def log_status_change
Rails.logger.info("changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})")
end
def has_failed_children? def has_failed_children?
children.failed.any? children.failed.any?
end end

View file

@ -30,9 +30,13 @@
<% end %> <% end %>
</div> </div>
<div class="flex items-center gap-8"> <div class="flex items-center gap-8">
<% if account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div>
<% else %>
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>"> <p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>">
<%= format_money account.balance_money %> <%= format_money account.balance_money %>
</p> </p>
<% end %>
<% unless account.scheduled_for_deletion? %> <% unless account.scheduled_for_deletion? %>
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> <%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>

View file

@ -40,7 +40,6 @@
full_width: true, full_width: true,
class: "justify-start" class: "justify-start"
) %> ) %>
<div> <div>
<% family.balance_sheet.account_groups("asset").each do |group| %> <% family.balance_sheet.account_groups("asset").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %> <%= render "accounts/accountable_group", account_group: group %>
@ -60,7 +59,6 @@
full_width: true, full_width: true,
class: "justify-start" class: "justify-start"
) %> ) %>
<div> <div>
<% family.balance_sheet.account_groups("liability").each do |group| %> <% family.balance_sheet.account_groups("liability").each do |group| %>
<%= render "accounts/accountable_group", account_group: group %> <%= render "accounts/accountable_group", account_group: group %>
@ -80,7 +78,6 @@
frame: :modal, frame: :modal,
class: "justify-start" class: "justify-start"
) %> ) %>
<div> <div>
<% family.balance_sheet.account_groups.each do |group| %> <% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group %> <%= render "accounts/accountable_group", account_group: group %>

View file

@ -3,13 +3,21 @@
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %> <%= render DisclosureComponent.new(title: account_group.name, align: :left, open: account_group.accounts.any? { |account| page_active?(account_path(account)) }) do |disclosure| %>
<% disclosure.with_summary_content do %> <% disclosure.with_summary_content do %>
<div class="ml-auto text-right grow"> <div class="ml-auto text-right grow">
<% if account_group.syncing? %>
<div class="space-y-1">
<div class="h-5 w-24 rounded ml-auto bg-loader"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
</div>
<% else %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %> <%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %> <%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy" do %>
<div class="flex items-center w-8 h-4 ml-auto"> <div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-surface-inset"></div> <div class="w-6 h-px bg-loader"></div>
</div> </div>
<% end %> <% end %>
<% end %>
</div> </div>
<% end %> <% end %>
@ -29,17 +37,28 @@
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %> <%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div> </div>
<% if account_group.syncing? %>
<div class="ml-auto text-right grow h-10">
<div class="space-y-1">
<div class="h-5 w-24 bg-loader rounded ml-auto"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
</div>
</div>
<% else %>
<div class="ml-auto text-right grow h-10"> <div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %> <%= 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 %> <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
<div class="flex items-center w-8 h-5 ml-auto"> <div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-surface-inset"></div> <div class="w-6 h-px bg-loader"></div>
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% end %>
</div> </div>
<div class="my-2"> <div class="my-2">

View file

@ -1,5 +1,7 @@
<div class="h-10"> <div class="px-4">
<div class="bg-loader rounded-md h-5 w-32"></div>
</div> </div>
<div class="h-64 flex items-center justify-center">
<p class="text-secondary animate-pulse text-sm">Loading...</p> <div class="p-4 h-52 flex items-center justify-center">
<div class="bg-loader rounded-md h-full w-full"></div>
</div> </div>

View file

@ -2,6 +2,9 @@
<% trend = series.trend %> <% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<% if @account.syncing?%>
<%= render "accounts/chart_loader" %>
<% else %>
<div class="px-4"> <div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %> <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div> </div>
@ -19,4 +22,5 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %>
<% end %> <% end %>

View file

@ -2,17 +2,6 @@
<h1 class="text-xl"><%= t(".accounts") %></h1> <h1 class="text-xl"><%= t(".accounts") %></h1>
<div class="flex items-center gap-5"> <div class="flex items-center gap-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<% if Rails.env.development? %>
<%= render ButtonComponent.new(
text: "Sync all",
href: sync_all_accounts_path,
method: :post,
variant: "outline",
disabled: Current.family.syncing?,
icon: "refresh-cw",
) %>
<% end %>
<%= render LinkComponent.new( <%= render LinkComponent.new(
text: "New account", text: "New account",
href: new_account_path(return_to: accounts_path), href: new_account_path(return_to: accounts_path),

View file

@ -6,9 +6,12 @@
<p><%= Accountable.from_type(group).display_name %></p> <p><%= Accountable.from_type(group).display_name %></p>
<span class="text-subdued mx-2">&middot;</span> <span class="text-subdued mx-2">&middot;</span>
<p><%= accounts.count %></p> <p><%= accounts.count %></p>
<% unless accounts.any?(&:syncing?) %>
<p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p> <p class="ml-auto"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>
<% end %>
</div> </div>
<div class="bg-container"> <div class="bg-container rounded-md">
<% accounts.each do |account| %> <% accounts.each do |account| %>
<%= render account %> <%= render account %>
<% end %> <% end %>

View file

@ -3,15 +3,22 @@
<% period = @period || Period.last_30_days %> <% period = @period || Period.last_30_days %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2"> <div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2"> <div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
<div class="space-y-2 w-full"> <div class="space-y-2 w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<%= tag.p title || default_value_title, class: "text-sm font-medium text-secondary" %> <%= tag.p title || default_value_title, class: "text-sm font-medium text-secondary" %>
<% unless account.syncing? %>
<%= tooltip %> <%= tooltip %>
<% end %>
</div> </div>
<% if account.syncing? %>
<div class="bg-loader rounded-md h-7 w-20"></div>
<% else %>
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %> <%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% end %>
</div> </div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> <%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>

View file

@ -19,8 +19,13 @@
<div class="col-span-2 flex justify-end items-center gap-2"> <div class="col-span-2 flex justify-end items-center gap-2">
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %> <% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
<% if account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full"></div>
<% else %>
<%= render "shared/progress_circle", progress: cash_weight %> <%= render "shared/progress_circle", progress: cash_weight %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %> <%= tag.p number_to_percentage(cash_weight, precision: 1) %>
<% end %>
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">
@ -28,7 +33,13 @@
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">
<% if account.syncing? %>
<div class="flex justify-end">
<div class="w-16 h-6 bg-loader rounded-full"></div>
</div>
<% else %>
<%= tag.p format_money account.cash_balance_money %> <%= tag.p format_money account.cash_balance_money %>
<% end %>
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">

View file

@ -17,7 +17,9 @@
</div> </div>
<div class="col-span-2 flex justify-end items-center gap-2"> <div class="col-span-2 flex justify-end items-center gap-2">
<% if holding.weight %> <% if holding.account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full"></div>
<% elsif holding.weight %>
<%= render "shared/progress_circle", progress: holding.weight %> <%= render "shared/progress_circle", progress: holding.weight %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %> <%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %> <% else %>
@ -26,21 +28,39 @@
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">
<% if holding.account.syncing? %>
<div class="flex justify-end">
<div class="w-16 h-6 bg-loader rounded-full"></div>
</div>
<% else %>
<%= tag.p format_money holding.avg_cost %> <%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %> <%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
<% end %>
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">
<% if holding.account.syncing? %>
<div class="flex flex-col gap-2 items-end">
<div class="w-16 h-4 bg-loader rounded-full"></div>
<div class="w-16 h-2 bg-loader rounded-full"></div>
</div>
<% else %>
<% if holding.amount_money %> <% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %> <%= tag.p format_money holding.amount_money %>
<% else %> <% else %>
<%= tag.p "--", class: "text-secondary" %> <%= tag.p "--", class: "text-secondary" %>
<% end %> <% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %> <%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
<% end %>
</div> </div>
<div class="col-span-2 text-right"> <div class="col-span-2 text-right">
<% if holding.trend %> <% if holding.account.syncing? %>
<div class="flex flex-col gap-2 items-end">
<div class="w-16 h-4 bg-loader rounded-full"></div>
<div class="w-16 h-2 bg-loader rounded-full"></div>
</div>
<% elsif holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %> <%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %> <%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %> <% else %>

View file

@ -23,10 +23,6 @@
<%= render_flash_notifications %> <%= render_flash_notifications %>
<div id="cta"></div> <div id="cta"></div>
<% if Current.family&.syncing? %>
<%= render "shared/notifications/loading", id: "syncing-notice", message: "Syncing accounts data..." %>
<% end %>
</div> </div>
</div> </div>

View file

@ -25,11 +25,14 @@
<div class="w-full space-y-6 pb-24"> <div class="w-full space-y-6 pb-24">
<% if Current.family.accounts.any? %> <% if Current.family.accounts.any? %>
<section class="bg-container py-4 rounded-xl shadow-border-xs px-0.5"> <section class="bg-container py-4 rounded-xl shadow-border-xs">
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %> <%= render partial: "pages/dashboard/net_worth_chart", locals: {
balance_sheet: @balance_sheet,
period: @period
} %>
</section> </section>
<% else %> <% else %>
<section class="p-0.5"> <section>
<%= render "pages/dashboard/no_accounts_graph_placeholder" %> <%= render "pages/dashboard/no_accounts_graph_placeholder" %>
</section> </section>
<% end %> <% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (balance_sheet:) %> <%# locals: (balance_sheet:) %>
<div class="space-y-4 overflow-x-auto p-0.5"> <div class="space-y-4">
<% balance_sheet.classification_groups.each do |classification_group| %> <% balance_sheet.classification_groups.each do |classification_group| %>
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4"> <div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5"> <h2 class="text-lg font-medium inline-flex items-center gap-1.5">
@ -11,17 +11,28 @@
<% if classification_group.account_groups.any? %> <% if classification_group.account_groups.any? %>
<span class="text-secondary">&middot;</span> <span class="text-secondary">&middot;</span>
<% if classification_group.syncing? %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="bg-loader w-full h-full rounded-md"></div>
</div>
<% else %>
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span> <span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
<% end %> <% end %>
<% end %>
</h2> </h2>
<% if classification_group.account_groups.any? %> <% if classification_group.account_groups.any? %>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex gap-1"> <div class="flex gap-1">
<% classification_group.account_groups.each do |account_group| %> <% classification_group.account_groups.each do |account_group| %>
<div class="h-1.5 rounded-sm" style="width: <%= account_group.weight %>%; background-color: <%= account_group.color %>;"></div> <div class="h-1.5 rounded-sm" style="width: <%= account_group.weight %>%; background-color: <%= account_group.color %>;"></div>
<% end %> <% end %>
</div> </div>
<% if classification_group.syncing? %>
<p class="text-xs text-subdued animate-pulse">Calculating latest balance data...</p>
<% else %>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<% classification_group.account_groups.each do |account_group| %> <% classification_group.account_groups.each do |account_group| %>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
@ -31,6 +42,7 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<% end%>
</div> </div>
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto"> <div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
@ -56,6 +68,17 @@
<p><%= account_group.name %></p> <p><%= account_group.name %></p>
</div> </div>
<% if account_group.syncing? %>
<div class="flex items-center justify-between text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
<div class="w-40 shrink-0 flex justify-end">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
</div>
<% else %>
<div class="flex items-center justify-between text-right gap-6"> <div class="flex items-center justify-between text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2"> <div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %> <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
@ -65,6 +88,7 @@
<p><%= format_money(account_group.total_money) %></p> <p><%= format_money(account_group.total_money) %></p>
</div> </div>
</div> </div>
<% end %>
</summary> </summary>
<div> <div>
@ -76,6 +100,17 @@
<%= link_to account.name, account_path(account) %> <%= link_to account.name, account_path(account) %>
</div> </div>
<% if account.syncing? %>
<div class="ml-auto flex items-center text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
<div class="w-40 shrink-0 flex justify-end">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
</div>
<% else %>
<div class="ml-auto flex items-center text-right gap-6"> <div class="ml-auto flex items-center text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2"> <div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
@ -85,6 +120,7 @@
<p><%= format_money(account.balance_money) %></p> <p><%= format_money(account.balance_money) %></p>
</div> </div>
</div> </div>
<% end %>
</div> </div>
<% if idx < account_group.accounts.size - 1 %> <% if idx < account_group.accounts.size - 1 %>

View file

@ -1,17 +1,28 @@
<%# locals: (series:, period:) %> <%# locals: (balance_sheet:, period:) %>
<% series = balance_sheet.net_worth_series(period: period) %>
<div class="flex justify-between gap-4 px-4"> <div class="flex justify-between gap-4 px-4">
<div class="space-y-2"> <div class="space-y-2">
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p> <p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
<% if balance_sheet.syncing? %>
<div class="flex flex-col gap-2">
<div class="bg-loader rounded-md h-7 w-20"></div>
<div class="bg-loader rounded-md h-5 w-32"></div>
</div>
<% else %>
<p class="text-primary -space-x-0.5 text-3xl font-medium"> <p class="text-primary -space-x-0.5 text-3xl font-medium">
<%= series.current.format %> <%= series.current.format %>
</p> </p>
<% if series.trend.nil? %> <% if series.trend.nil? %>
<p class="text-sm text-secondary"><%= t(".data_not_available") %></p> <p class="text-sm text-secondary"><%= t(".data_not_available") %></p>
<% else %> <% else %>
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %> <%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
<% end %> <% end %>
<% end %>
</div> </div>
</div> </div>
@ -24,14 +35,20 @@
<% end %> <% end %>
</div> </div>
<% if series.any? %> <% if balance_sheet.syncing? %>
<div class="w-full flex items-center justify-center p-4 h-52">
<div class="bg-loader rounded-md h-full w-full"></div>
</div>
<% else %>
<% if series.any? %>
<div <div
id="netWorthChart" id="netWorthChart"
class="w-full flex-1 min-h-52" class="w-full flex-1 min-h-52"
data-controller="time-series-chart" data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div> data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %> <% else %>
<div class="w-full h-full flex items-center justify-center"> <div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p> <p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div> </div>
<% end %>
<% end %> <% end %>

View file

@ -1,9 +0,0 @@
<%# locals: (message:, id: nil) %>
<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs" do %>
<div class="h-5 w-5 shrink-0 p-px text-primary">
<%= icon "loader", class: "animate-pulse" %>
</div>
<%= tag.p message, class: "text-primary text-sm font-medium" %>
<% end %>

View file

@ -4,9 +4,6 @@
<div class="flex items-center gap-5"> <div class="flex items-center gap-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<%= render MenuComponent.new do |menu| %> <%= render MenuComponent.new do |menu| %>
<% if Rails.env.development? %>
<% menu.with_item(variant: "button", text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %>
<% end %>
<% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %> <% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %>
<% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %>
<% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %>

View file

@ -1,3 +1,4 @@
Rails.application.configure do Rails.application.configure do
Rack::MiniProfiler.config.skip_paths = [ "/design-system" ] Rack::MiniProfiler.config.skip_paths = [ "/design-system" ]
Rack::MiniProfiler.config.max_traces_to_show = 30
end end

View file

@ -105,10 +105,6 @@ Rails.application.routes.draw do
end end
resources :accounts, only: %i[index new], shallow: true do resources :accounts, only: %i[index new], shallow: true do
collection do
post :sync_all
end
member do member do
post :sync post :sync
get :chart get :chart

2
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_05_12_171654) do ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"

View file

@ -15,9 +15,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
post sync_account_path(@account) post sync_account_path(@account)
assert_redirected_to account_path(@account) assert_redirected_to account_path(@account)
end end
test "can sync all accounts" do
post sync_all_accounts_path
assert_redirected_to accounts_path
end
end end

View file

@ -32,8 +32,6 @@ class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
end end
test "users who have already trialed cannot create a new subscription" do test "users who have already trialed cannot create a new subscription" do
@family.start_trial_subscription!
assert_no_difference "Subscription.count" do assert_no_difference "Subscription.count" do
post subscription_path post subscription_path
end end

View file

@ -2,7 +2,7 @@ require "test_helper"
class SubscriptionTest < ActiveSupport::TestCase class SubscriptionTest < ActiveSupport::TestCase
setup do setup do
@family = families(:empty) @family = Family.create!(name: "Test Family")
end end
test "can create subscription without stripe details if trial" do test "can create subscription without stripe details if trial" do