Support uncategorized investments (#593)

* Support uncategorized investments

* FIX sankey id collision

* Fix reports

* Fix hardcoded string and i8n

* FIX plurals

* Remove spending patterns section

add net worth section to reports
This commit is contained in:
soky srm
2026-01-09 19:45:42 +01:00
committed by GitHub
parent 76dc91377c
commit a4f70f4d4a
11 changed files with 306 additions and 212 deletions

View File

@@ -154,7 +154,9 @@ class PagesController < ApplicationController
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
color = ct.category.color.presence || Category::COLORS.sample
idx = add_node.call("income_#{ct.category.id}", ct.category.name, val, percentage, color)
# Use name as fallback key for synthetic categories (no id)
node_key = "income_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
end
@@ -168,7 +170,9 @@ class PagesController < ApplicationController
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
idx = add_node.call("expense_#{ct.category.id}", ct.category.name, val, percentage, color)
# Use name as fallback key for synthetic categories (no id)
node_key = "expense_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
@@ -198,7 +202,8 @@ class PagesController < ApplicationController
currency: ct.currency,
percentage: ct.weight.round(1),
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
icon: ct.category.lucide_icon
icon: ct.category.lucide_icon,
clickable: !ct.category.other_investments?
}
end

View File

@@ -31,8 +31,8 @@ class ReportsController < ApplicationController
# Build trend data (last 6 months)
@trends_data = build_trends_data
# Spending patterns (weekday vs weekend)
@spending_patterns = build_spending_patterns
# Net worth metrics
@net_worth_metrics = build_net_worth_metrics
# Transactions breakdown
@transactions = build_transactions_breakdown
@@ -124,11 +124,19 @@ class ReportsController < ApplicationController
def build_reports_sections
all_sections = [
{
key: "net_worth",
title: "reports.net_worth.title",
partial: "reports/net_worth",
locals: { net_worth_metrics: @net_worth_metrics },
visible: Current.family.accounts.any?,
collapsible: true
},
{
key: "trends_insights",
title: "reports.trends.title",
partial: "reports/trends_insights",
locals: { trends_data: @trends_data, spending_patterns: @spending_patterns },
locals: { trends_data: @trends_data },
visible: Current.family.transactions.any?,
collapsible: true
},
@@ -310,61 +318,6 @@ class ReportsController < ApplicationController
trends
end
def build_spending_patterns
# Analyze weekday vs weekend spending
weekday_total = 0
weekend_total = 0
weekday_count = 0
weekend_count = 0
# Build query matching income_statement logic:
# Expenses are transactions with positive amounts, regardless of category
expense_transactions = Transaction
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where(kind: [ "standard", "loan_payment" ])
.where("entries.amount > 0") # Positive amount = expense (matching income_statement logic)
# Sum up amounts by weekday vs weekend
expense_transactions.each do |transaction|
entry = transaction.entry
amount = entry.amount.abs
if entry.date.wday.in?([ 0, 6 ]) # Sunday or Saturday
weekend_total += amount
weekend_count += 1
else
weekday_total += amount
weekday_count += 1
end
end
weekday_avg = weekday_count.positive? ? (weekday_total / weekday_count) : 0
weekend_avg = weekend_count.positive? ? (weekend_total / weekend_count) : 0
{
weekday_total: weekday_total,
weekend_total: weekend_total,
weekday_avg: weekday_avg,
weekend_avg: weekend_avg,
weekday_count: weekday_count,
weekend_count: weekend_count
}
end
def default_spending_patterns
{
weekday_total: 0,
weekend_total: 0,
weekday_avg: 0,
weekend_avg: 0,
weekday_count: 0,
weekend_count: 0
}
end
def build_transactions_breakdown
# Base query: all transactions in the period
# Exclude transfers, one-time, and CC payments (matching income_statement logic)
@@ -379,25 +332,55 @@ class ReportsController < ApplicationController
# Apply filters
transactions = apply_transaction_filters(transactions)
# Get trades in the period (matching income_statement logic)
trades = Trade
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Get sort parameters
sort_by = params[:sort_by] || "amount"
sort_direction = params[:sort_direction] || "desc"
# Group by category and type
all_transactions = transactions.to_a
grouped_data = {}
family_currency = Current.family.currency
all_transactions.each do |transaction|
# Process transactions
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
category_name = transaction.category&.name || "Uncategorized"
category_color = transaction.category&.color || "#9CA3AF"
category_color = transaction.category&.color || Category::UNCATEGORIZED_COLOR
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type, category_color ]
grouped_data[key] ||= { total: 0, count: 0 }
grouped_data[key][:count] += 1
grouped_data[key][:total] += entry.amount.abs
grouped_data[key][:total] += converted_amount
end
# Process trades
trades.each do |trade|
entry = trade.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
# Use "Other Investments" for trades without category
category_name = trade.category&.name || Category.other_investments_name
category_color = trade.category&.color || Category::OTHER_INVESTMENTS_COLOR
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type, category_color ]
grouped_data[key] ||= { total: 0, count: 0 }
grouped_data[key][:count] += 1
grouped_data[key][:total] += converted_amount
end
# Convert to array
@@ -438,6 +421,39 @@ class ReportsController < ApplicationController
}
end
def build_net_worth_metrics
balance_sheet = Current.family.balance_sheet
currency = Current.family.currency
# Current net worth
current_net_worth = balance_sheet.net_worth
total_assets = balance_sheet.assets.total
total_liabilities = balance_sheet.liabilities.total
# Get net worth series for the period to calculate change
# The series.trend gives us the change from first to last value in the period
net_worth_series = balance_sheet.net_worth_series(period: @period)
trend = net_worth_series&.trend
# Get asset and liability groups for breakdown
asset_groups = balance_sheet.assets.account_groups.map do |group|
{ name: group.name, total: Money.new(group.total, currency) }
end.reject { |g| g[:total].zero? }
liability_groups = balance_sheet.liabilities.account_groups.map do |group|
{ name: group.name, total: Money.new(group.total, currency) }
end.reject { |g| g[:total].zero? }
{
current_net_worth: Money.new(current_net_worth, currency),
total_assets: Money.new(total_assets, currency),
total_liabilities: Money.new(total_liabilities, currency),
trend: trend,
asset_groups: asset_groups,
liability_groups: liability_groups
}
end
def apply_transaction_filters(transactions)
# Filter by category (including subcategories)
if params[:filter_category_id].present?
@@ -533,9 +549,19 @@ class ReportsController < ApplicationController
transactions = apply_transaction_filters(transactions)
# Group transactions by category, type, and month
breakdown = {}
# Get trades in the period (matching income_statement logic)
trades = Trade
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: [])
# Group by category, type, and month
breakdown = {}
family_currency = Current.family.currency
# Process transactions
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
@@ -543,11 +569,33 @@ class ReportsController < ApplicationController
category_name = transaction.category&.name || "Uncategorized"
month_key = entry.date.beginning_of_month
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type ]
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
breakdown[key][:months][month_key] ||= 0
breakdown[key][:months][month_key] += entry.amount.abs
breakdown[key][:total] += entry.amount.abs
breakdown[key][:months][month_key] += converted_amount
breakdown[key][:total] += converted_amount
end
# Process trades
trades.each do |trade|
entry = trade.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
# Use "Other Investments" for trades without category
category_name = trade.category&.name || Category.other_investments_name
month_key = entry.date.beginning_of_month
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type ]
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }
breakdown[key][:months][month_key] ||= 0
breakdown[key][:months][month_key] += converted_amount
breakdown[key][:total] += converted_amount
end
# Convert to array and sort by type and total (descending)

View File

@@ -31,10 +31,15 @@ class Category < ApplicationRecord
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
UNCATEGORIZED_COLOR = "#737373"
OTHER_INVESTMENTS_COLOR = "#e99537"
TRANSFER_COLOR = "#444CE7"
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
# Synthetic category name keys for i18n
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
class Group
attr_reader :category, :subcategories
@@ -82,12 +87,30 @@ class Category < ApplicationRecord
def uncategorized
new(
name: "Uncategorized",
name: I18n.t(UNCATEGORIZED_NAME_KEY),
color: UNCATEGORIZED_COLOR,
lucide_icon: "circle-dashed"
)
end
def other_investments
new(
name: I18n.t(OTHER_INVESTMENTS_NAME_KEY),
color: OTHER_INVESTMENTS_COLOR,
lucide_icon: "trending-up"
)
end
# Helper to get the localized name for uncategorized
def uncategorized_name
I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Helper to get the localized name for other investments
def other_investments_name
I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
private
def default_categories
[
@@ -142,6 +165,21 @@ class Category < ApplicationRecord
subcategory? ? "#{parent.name} > #{name}" : name
end
# Predicate: is this the synthetic "Uncategorized" category?
def uncategorized?
!persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Predicate: is this the synthetic "Other Investments" category?
def other_investments?
!persisted? && name == I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
# Predicate: is this any synthetic (non-persisted) category?
def synthetic?
uncategorized? || other_investments?
end
private
def category_level_limit
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)

View File

@@ -68,13 +68,22 @@ class IncomeStatement
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
other_investments_category = family.categories.other_investments
category_totals = [ *categories, uncategorized_category ].map do |category|
category_totals = [ *categories, uncategorized_category, other_investments_category ].map do |category|
subcategory = categories.find { |c| c.id == category.parent_id }
parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
parent_category_total = if category.uncategorized?
# Regular uncategorized: NULL category_id and NOT uncategorized investment
totals.select { |t| t.category_id.nil? && !t.is_uncategorized_investment }&.sum(&:total) || 0
elsif category.other_investments?
# Other investments: NULL category_id AND is_uncategorized_investment
totals.select { |t| t.category_id.nil? && t.is_uncategorized_investment }&.sum(&:total) || 0
else
totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0
end
children_totals = if category == uncategorized_category
children_totals = if category.synthetic?
0
else
totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0

View File

@@ -15,13 +15,14 @@ class IncomeStatement::Totals
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
transactions_count: row["transactions_count"]
transactions_count: row["transactions_count"],
is_uncategorized_investment: row["is_uncategorized_investment"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count, :is_uncategorized_investment)
def query_sql
ActiveRecord::Base.sanitize_sql_array([
@@ -37,6 +38,7 @@ class IncomeStatement::Totals
category_id,
parent_category_id,
classification,
is_uncategorized_investment,
SUM(total) as total,
SUM(entry_count) as transactions_count
FROM (
@@ -44,7 +46,7 @@ class IncomeStatement::Totals
UNION ALL
#{trades_subquery_sql}
) combined
GROUP BY category_id, parent_category_id, classification;
GROUP BY category_id, parent_category_id, classification, is_uncategorized_investment;
SQL
end
@@ -56,7 +58,8 @@ class IncomeStatement::Totals
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as transactions_count
COUNT(ae.id) as transactions_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
@@ -81,7 +84,8 @@ class IncomeStatement::Totals
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count
COUNT(ae.id) as entry_count,
false as is_uncategorized_investment
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
@@ -101,14 +105,15 @@ class IncomeStatement::Totals
def trades_subquery_sql
# Get trades for the same family and date range as transactions
# Only include trades that have a category assigned
# Trades without categories appear as "Uncategorized Investments" (separate from regular uncategorized)
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count
COUNT(ae.id) as entry_count,
CASE WHEN t.category_id IS NULL THEN true ELSE false END as is_uncategorized_investment
FROM trades t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
JOIN accounts a ON a.id = ae.account_id
@@ -122,8 +127,7 @@ class IncomeStatement::Totals
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.date BETWEEN :start_date AND :end_date
AND t.category_id IS NOT NULL
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
SQL
end

View File

@@ -70,13 +70,9 @@
</div>
<div class="py-3 shadow-border-xs rounded-lg bg-container font-medium text-sm max-w-full">
<% outflows_data[:categories].each_with_index do |category, idx| %>
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
data: {
turbo_frame: "_top",
category_id: category[:id],
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
} do %>
<%
category_content = capture do
%>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="h-6 w-6 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
style="
@@ -102,6 +98,24 @@
<span class="text-sm text-secondary whitespace-nowrap w-10 lg:w-15"><%= category[:percentage] %>%</span>
</div>
<% end %>
<% if category[:clickable] != false %>
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
class: "flex items-center justify-between mx-3 p-3 rounded-lg cursor-pointer group gap-3",
data: {
turbo_frame: "_top",
category_id: category[:id],
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
} do %>
<%= category_content %>
<% end %>
<% else %>
<div class="flex items-center justify-between mx-3 p-3 rounded-lg group gap-3"
data-category-id="<%= category[:id] %>"
data-action="mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment">
<%= category_content %>
</div>
<% end %>
<% if idx < outflows_data[:categories].size - 1 %>
<%= render "shared/ruler", classes: "mx-3 lg:mx-4" %>
<% end %>

View File

@@ -0,0 +1,73 @@
<div class="space-y-6">
<%# Main Net Worth Stats %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<%# Current Net Worth %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.current_net_worth") %></p>
<p class="text-xl font-semibold <%= net_worth_metrics[:current_net_worth] >= 0 ? "text-success" : "text-destructive" %>">
<%= net_worth_metrics[:current_net_worth].format %>
</p>
</div>
<%# Period Change %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.period_change") %></p>
<% if net_worth_metrics[:trend] %>
<% trend = net_worth_metrics[:trend] %>
<p class="text-xl font-semibold" style="color: <%= trend.color %>">
<%= trend.value.format(signify_positive: true) %>
</p>
<p class="text-xs" style="color: <%= trend.color %>">
<%= trend.value >= 0 ? "+" : "" %><%= trend.percent_formatted %>
</p>
<% else %>
<p class="text-xl font-semibold text-tertiary">--</p>
<% end %>
</div>
<%# Assets vs Liabilities %>
<div class="p-4 bg-surface-inset rounded-lg">
<p class="text-xs text-tertiary mb-1"><%= t("reports.net_worth.assets_vs_liabilities") %></p>
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold text-success"><%= net_worth_metrics[:total_assets].format %></span>
<span class="text-xs text-tertiary">-</span>
<span class="text-lg font-semibold text-destructive"><%= net_worth_metrics[:total_liabilities].format %></span>
</div>
</div>
</div>
<%# Asset/Liability Breakdown %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<%# Assets Summary %>
<div>
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_assets") %></h3>
<div class="space-y-2">
<% net_worth_metrics[:asset_groups].each do |group| %>
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
<span class="text-sm text-primary"><%= group[:name] %></span>
<span class="text-sm font-medium text-success"><%= group[:total].format %></span>
</div>
<% end %>
<% if net_worth_metrics[:asset_groups].empty? %>
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_assets") %></p>
<% end %>
</div>
</div>
<%# Liabilities Summary %>
<div>
<h3 class="text-sm font-medium text-secondary mb-3"><%= t("reports.net_worth.total_liabilities") %></h3>
<div class="space-y-2">
<% net_worth_metrics[:liability_groups].each do |group| %>
<div class="flex items-center justify-between py-2 border-b border-tertiary/50">
<span class="text-sm text-primary"><%= group[:name] %></span>
<span class="text-sm font-medium text-destructive"><%= group[:total].format %></span>
</div>
<% end %>
<% if net_worth_metrics[:liability_groups].empty? %>
<p class="text-sm text-tertiary py-2"><%= t("reports.net_worth.no_liabilities") %></p>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -85,7 +85,7 @@
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
@@ -139,7 +139,7 @@
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= group[:count] %> <%= t("reports.transactions_breakdown.table.transactions") %>)</span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">

View File

@@ -1,4 +1,4 @@
<div class="space-y-8">
<div>
<%# Month-over-Month Trends %>
<div>
<h3 class="text-sm font-medium text-secondary mb-4">
@@ -78,121 +78,5 @@
</div>
<% end %>
</div>
<%# Spending Patterns %>
<div>
<h3 class="text-sm font-medium text-secondary mb-4">
<%= t("reports.trends.spending_patterns") %>
</h3>
<% if spending_patterns[:weekday_count] + spending_patterns[:weekend_count] > 0 %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<%# Weekday Spending %>
<div class="p-6 bg-surface-inset rounded-lg">
<div class="flex items-center gap-2 mb-4">
<%= icon("calendar", class: "w-5 h-5 text-primary") %>
<h4 class="font-medium text-primary"><%= t("reports.trends.weekday_spending") %></h4>
</div>
<div class="space-y-3">
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
<p class="text-xl font-semibold text-primary">
<%= Money.new(spending_patterns[:weekday_total], Current.family.currency).format %>
</p>
</div>
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
<p class="text-base font-medium text-secondary">
<%= Money.new(spending_patterns[:weekday_avg], Current.family.currency).format %>
</p>
</div>
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
<p class="text-base font-medium text-secondary">
<%= spending_patterns[:weekday_count] %>
</p>
</div>
</div>
</div>
<%# Weekend Spending %>
<div class="p-6 bg-surface-inset rounded-lg">
<div class="flex items-center gap-2 mb-4">
<%= icon("calendar-check", class: "w-5 h-5 text-primary") %>
<h4 class="font-medium text-primary"><%= t("reports.trends.weekend_spending") %></h4>
</div>
<div class="space-y-3">
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.total") %></p>
<p class="text-xl font-semibold text-primary">
<%= Money.new(spending_patterns[:weekend_total], Current.family.currency).format %>
</p>
</div>
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.avg_per_transaction") %></p>
<p class="text-base font-medium text-secondary">
<%= Money.new(spending_patterns[:weekend_avg], Current.family.currency).format %>
</p>
</div>
<div>
<p class="text-xs text-tertiary mb-1"><%= t("reports.trends.transactions") %></p>
<p class="text-base font-medium text-secondary">
<%= spending_patterns[:weekend_count] %>
</p>
</div>
</div>
</div>
</div>
<%# Comparison Insight %>
<% if spending_patterns[:weekday_avg] > 0 && spending_patterns[:weekend_avg] > 0 %>
<div class="mt-4 p-4 bg-container rounded-lg border border-tertiary">
<div class="flex items-start gap-3">
<%= icon("lightbulb", class: "w-5 h-5 text-warning mt-0.5") %>
<div>
<p class="text-sm font-medium text-primary mb-1">
<%= t("reports.trends.insight_title") %>
</p>
<p class="text-sm text-secondary">
<%
weekday = spending_patterns[:weekday_avg].to_f
weekend = spending_patterns[:weekend_avg].to_f
if weekend > weekday
percent_diff = ((weekend - weekday) / weekday * 100).round(0)
if percent_diff > 20
message = t("reports.trends.insight_higher_weekend", percent: percent_diff)
else
message = t("reports.trends.insight_similar")
end
elsif weekday > weekend
percent_diff = ((weekday - weekend) / weekend * 100).round(0)
if percent_diff > 20
message = t("reports.trends.insight_higher_weekday", percent: percent_diff)
else
message = t("reports.trends.insight_similar")
end
else
message = t("reports.trends.insight_similar")
end
%>
<%= message %>
</p>
</div>
</div>
</div>
<% end %>
<% else %>
<div class="text-center py-8 text-tertiary">
<%= t("reports.trends.no_spending_data") %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
---
en:
models:
category:
uncategorized: Uncategorized
other_investments: Other Investments

View File

@@ -69,8 +69,8 @@ en:
add_transaction: Add Transaction
add_account: Add Account
transactions_breakdown:
title: Transactions Breakdown
no_transactions: No transactions found for the selected period and filters
title: Activity Breakdown
no_transactions: No activity found for the selected period and filters
filters:
title: Filters
category: Category
@@ -102,12 +102,25 @@ en:
expense: Expenses
income: Income
uncategorized: Uncategorized
transactions: transactions
entries:
one: entry
other: entries
percentage: "% of Total"
pagination:
showing: Showing %{count} transactions
showing:
one: Showing %{count} entry
other: Showing %{count} entries
previous: Previous
next: Next
net_worth:
title: Net Worth
current_net_worth: Current Net Worth
period_change: Period Change
assets_vs_liabilities: Assets vs Liabilities
total_assets: Assets
total_liabilities: Liabilities
no_assets: No assets
no_liabilities: No liabilities
investment_performance:
title: Investment Performance
portfolio_value: Portfolio Value