diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 938f57d5c..c7439e481 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -12,6 +12,7 @@ @import "./google-sign-in.css"; @import "./date-picker-dark-mode.css"; +@import "./print-report.css"; @layer components { .pcr-app{ diff --git a/app/assets/tailwind/print-report.css b/app/assets/tailwind/print-report.css new file mode 100644 index 000000000..1917349a9 --- /dev/null +++ b/app/assets/tailwind/print-report.css @@ -0,0 +1,296 @@ +/* + Print Report Styles + Tufte-inspired styling for the printable financial report. + Uses design system tokens where applicable. +*/ + +/* Print Body & Container */ +.print-body { + background: var(--color-white); + color: var(--color-gray-900); + font-family: var(--font-sans); + line-height: 1.5; +} + +.print-container { + max-width: 680px; + margin: 0 auto; + padding: 32px 24px; +} + +.tufte-report { + font-size: 11px; + color: var(--color-gray-900); +} + +/* Header */ +.tufte-header { + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 2px solid var(--color-gray-900); +} + +.tufte-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 4px 0; + color: var(--color-gray-900); + letter-spacing: -0.3px; +} + +.tufte-period { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--color-gray-600); + margin-top: 2px; +} + +.tufte-meta { + font-size: 10px; + color: var(--color-gray-500); + margin: 8px 0 0 0; +} + +/* Sections */ +.tufte-section { + margin-bottom: 24px; +} + +.tufte-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--color-gray-900); + margin: 0 0 12px 0; + border-bottom: 1px solid var(--color-gray-200); + padding-bottom: 6px; +} + +.tufte-subsection { + font-size: 11px; + font-weight: 600; + margin: 16px 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--color-gray-100); +} + +/* Metric Cards */ +.tufte-metric-card { + display: inline-block; + min-width: 100px; +} + +.tufte-metric-card-main { + display: block; +} + +.tufte-metric-card-label { + display: block; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-gray-500); + margin-bottom: 4px; +} + +.tufte-metric-card-value { + display: block; + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; + letter-spacing: -0.5px; +} + +.tufte-metric-card-change { + display: inline-block; + font-size: 10px; + font-weight: 500; + margin-top: 4px; + padding: 1px 4px; + border-radius: 2px; +} + +.tufte-metric-card-sm .tufte-metric-card-value { + font-size: 16px; +} + +.tufte-metric-card-sm .tufte-metric-card-label { + font-size: 9px; +} + +/* Metric Row (horizontal layout) */ +.tufte-metric-row { + display: flex; + gap: 32px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +/* Semantic Colors */ +.tufte-income { color: var(--color-green-700); } +.tufte-expense { color: var(--color-red-700); } +.tufte-muted { color: var(--color-gray-500); font-size: 10px; } +.tufte-up { color: var(--color-green-700); } +.tufte-down { color: var(--color-red-700); } + +/* Two Column Layout */ +.tufte-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + margin-top: 12px; +} + +/* Tables - Clean, readable style */ +.tufte-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.tufte-table thead th { + text-align: left; + font-weight: 600; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-gray-600); + padding: 8px 12px 8px 0; + border-bottom: 2px solid var(--color-gray-900); +} + +.tufte-table tbody td { + padding: 6px 12px 6px 0; + border-bottom: 1px solid var(--color-gray-200); + vertical-align: middle; +} + +.tufte-table tbody tr:last-child td { + border-bottom: none; +} + +.tufte-table tfoot td { + padding: 8px 12px 6px 0; + border-top: 2px solid var(--color-gray-900); + font-weight: 600; +} + +.tufte-table.tufte-compact thead th { + padding: 6px 8px 6px 0; +} + +.tufte-table.tufte-compact tbody td { + padding: 5px 8px 5px 0; +} + +.tufte-right { + text-align: right; + padding-right: 0 !important; +} + +.tufte-highlight { + background: var(--color-yellow-100); +} + +.tufte-highlight td:first-child { + font-weight: 600; +} + +/* Category Dots */ +.tufte-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +/* Footnotes */ +.tufte-footnote { + font-size: 10px; + color: var(--color-gray-500); + margin-top: 8px; + font-style: italic; +} + +/* Footer */ +.tufte-footer { + margin-top: 32px; + padding-top: 12px; + border-top: 1px solid var(--color-gray-200); + font-size: 10px; + color: var(--color-gray-500); + text-align: center; +} + +/* Print-specific overrides */ +@media print { + @page { + size: A4; + margin: 15mm 18mm; + } + + /* Scoped to .print-body to avoid affecting other pages when printing */ + .print-body { + font-size: 10px; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .print-container { + max-width: none; + padding: 0; + } + + .tufte-section { + page-break-inside: auto; + } + + .tufte-section-title { + page-break-after: avoid; + } + + .tufte-table { + page-break-inside: auto; + } + + .tufte-table thead { + display: table-header-group; + } + + .tufte-table tr { + page-break-inside: avoid; + } + + .tufte-two-col { + page-break-inside: avoid; + } + + .tufte-keep-together { + page-break-inside: avoid; + } + + .tufte-header { + page-break-after: avoid; + } + + /* Force colors in print */ + .tufte-income { color: var(--color-green-700) !important; } + .tufte-expense { color: var(--color-red-700) !important; } + .tufte-up { color: var(--color-green-700) !important; } + .tufte-down { color: var(--color-red-700) !important; } + + .tufte-footer { + page-break-before: avoid; + } + + .tufte-highlight { + background: var(--color-yellow-100) !important; + } +} diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index bfb833acb..91593f801 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -7,38 +7,7 @@ class ReportsController < ApplicationController before_action :authenticate_for_export, only: :export_transactions def index - @period_type = params[:period_type]&.to_sym || :monthly - @start_date = parse_date_param(:start_date) || default_start_date - @end_date = parse_date_param(:end_date) || default_end_date - - # Validate and fix date range if end_date is before start_date - validate_and_fix_date_range(show_flash: true) - - # Build the period - @period = Period.custom(start_date: @start_date, end_date: @end_date) - @previous_period = build_previous_period - - # Get aggregated data - @current_income_totals = Current.family.income_statement.income_totals(period: @period) - @current_expense_totals = Current.family.income_statement.expense_totals(period: @period) - - @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period) - @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period) - - # Calculate summary metrics - @summary_metrics = build_summary_metrics - - # Build trend data (last 6 months) - @trends_data = build_trends_data - - # Net worth metrics - @net_worth_metrics = build_net_worth_metrics - - # Transactions breakdown - @transactions = build_transactions_breakdown - - # Investment metrics (must be before build_reports_sections) - @investment_metrics = build_investment_metrics + setup_report_data(show_flash: true) # Build reports sections for collapsible/reorderable UI @reports_sections = build_reports_sections @@ -46,6 +15,12 @@ class ReportsController < ApplicationController @breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ] end + def print + setup_report_data(show_flash: false) + + render layout: "print" + end + def update_preferences if Current.user.update_reports_preferences(preferences_params) head :ok @@ -114,6 +89,44 @@ class ReportsController < ApplicationController end private + def setup_report_data(show_flash: false) + @period_type = params[:period_type]&.to_sym || :monthly + @start_date = parse_date_param(:start_date) || default_start_date + @end_date = parse_date_param(:end_date) || default_end_date + + # Validate and fix date range if end_date is before start_date + validate_and_fix_date_range(show_flash: show_flash) + + # Build the period + @period = Period.custom(start_date: @start_date, end_date: @end_date) + @previous_period = build_previous_period + + # Get aggregated data + @current_income_totals = Current.family.income_statement.income_totals(period: @period) + @current_expense_totals = Current.family.income_statement.expense_totals(period: @period) + + @previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period) + @previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period) + + # Calculate summary metrics + @summary_metrics = build_summary_metrics + + # Build trend data (last 6 months) + @trends_data = build_trends_data + + # Net worth metrics + @net_worth_metrics = build_net_worth_metrics + + # Transactions breakdown + @transactions = build_transactions_breakdown + + # Investment metrics + @investment_metrics = build_investment_metrics + + # Flags for view rendering + @has_accounts = Current.family.accounts.any? + end + def preferences_params prefs = params.require(:preferences) {}.tap do |permitted| diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb new file mode 100644 index 000000000..52b3016b5 --- /dev/null +++ b/app/helpers/reports_helper.rb @@ -0,0 +1,34 @@ +module ReportsHelper + # Generate SVG polyline points for a sparkline chart + # Returns empty string if fewer than 2 data points (can't draw a line with 1 point) + def sparkline_points(values, width: 60, height: 16) + return "" if values.nil? || values.length < 2 || values.all? { |v| v.nil? || v.zero? } + + nums = values.map(&:to_f) + max_val = nums.max + min_val = nums.min + range = max_val - min_val + range = 1.0 if range.zero? + + points = nums.each_with_index.map do |val, i| + x = (i.to_f / [ nums.length - 1, 1 ].max) * width + y = height - ((val - min_val) / range * (height - 2)) - 1 + "#{x.round(1)},#{y.round(1)}" + end + + points.join(" ") + end + + # Calculate cumulative net values from trends data + def cumulative_net_values(trends) + return [] if trends.nil? + + running = 0 + trends.map { |t| running += t[:net].to_i; running } + end + + # Check if trends data has enough points for sparklines (need at least 2) + def has_sparkline_data?(trends_data) + trends_data&.length.to_i >= 2 + end +end diff --git a/app/views/layouts/print.html.erb b/app/views/layouts/print.html.erb new file mode 100644 index 000000000..582f139bc --- /dev/null +++ b/app/views/layouts/print.html.erb @@ -0,0 +1,28 @@ + + + + <%= content_for(:title) || t("reports.print.document_title") %> + + <%= csrf_meta_tags %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + + + + + + + + + + + diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index 57cd109ba..8940b7551 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -17,37 +17,49 @@ <% end %> <%# Period Navigation Tabs %> -
- <%= render DS::Link.new( - text: t("reports.index.periods.monthly"), - variant: @period_type == :monthly ? "secondary" : "ghost", - href: reports_path(period_type: :monthly), - size: :sm - ) %> - <%= render DS::Link.new( - text: t("reports.index.periods.quarterly"), - variant: @period_type == :quarterly ? "secondary" : "ghost", - href: reports_path(period_type: :quarterly), - size: :sm - ) %> - <%= render DS::Link.new( - text: t("reports.index.periods.ytd"), - variant: @period_type == :ytd ? "secondary" : "ghost", - href: reports_path(period_type: :ytd), - size: :sm - ) %> - <%= render DS::Link.new( - text: t("reports.index.periods.last_6_months"), - variant: @period_type == :last_6_months ? "secondary" : "ghost", - href: reports_path(period_type: :last_6_months), - size: :sm - ) %> - <%= render DS::Link.new( - text: t("reports.index.periods.custom"), - variant: @period_type == :custom ? "secondary" : "ghost", - href: reports_path(period_type: :custom), - size: :sm - ) %> +
+
+ <%= render DS::Link.new( + text: t("reports.index.periods.monthly"), + variant: @period_type == :monthly ? "secondary" : "ghost", + href: reports_path(period_type: :monthly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.quarterly"), + variant: @period_type == :quarterly ? "secondary" : "ghost", + href: reports_path(period_type: :quarterly), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.ytd"), + variant: @period_type == :ytd ? "secondary" : "ghost", + href: reports_path(period_type: :ytd), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.last_6_months"), + variant: @period_type == :last_6_months ? "secondary" : "ghost", + href: reports_path(period_type: :last_6_months), + size: :sm + ) %> + <%= render DS::Link.new( + text: t("reports.index.periods.custom"), + variant: @period_type == :custom ? "secondary" : "ghost", + href: reports_path(period_type: :custom), + size: :sm + ) %> +
+ + <%# Print Report Button %> + <%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date), + target: "_blank", + rel: "noopener", + aria: { label: t("reports.index.print_report") }, + class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %> + <%= icon("printer", size: "sm") %> + + <% end %>
<%# Custom Date Range Picker (only shown when custom is selected) %> diff --git a/app/views/reports/print.html.erb b/app/views/reports/print.html.erb new file mode 100644 index 000000000..3add245f0 --- /dev/null +++ b/app/views/reports/print.html.erb @@ -0,0 +1,345 @@ +<% content_for :title do %> + <%= t("reports.print.document_title") %> - <%= @start_date.strftime("%B %d, %Y") %> to <%= @end_date.strftime("%B %d, %Y") %> +<% end %> + +
+ <%# Header %> +
+

<%= t("reports.print.title") %>

+ <%= @start_date.strftime("%B %d, %Y") %> – <%= @end_date.strftime("%B %d, %Y") %> +

<%= Current.family.name %> · <%= t("reports.print.generated_on", date: Time.current.strftime("%B %d, %Y")) %>

+
+ + <%# Summary %> +
+

<%= t("reports.print.summary.title") %>

+
+
+ <%= t("reports.print.summary.income") %> + <%= @summary_metrics[:current_income].format %> + <% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %> + "> + <%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %> + + <% end %> + <% if has_sparkline_data?(@trends_data) %> + + + + <% end %> +
+ +
+ <%= t("reports.print.summary.expenses") %> + <%= @summary_metrics[:current_expenses].format %> + <% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %> + "> + <%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %> + + <% end %> + <% if has_sparkline_data?(@trends_data) %> + + + + <% end %> +
+ +
+ <%= t("reports.print.summary.net_savings") %> + "><%= @summary_metrics[:net_savings].format %> + <% + # Calculate savings rate + savings_rate = @summary_metrics[:current_income].amount > 0 ? ((@summary_metrics[:net_savings].amount / @summary_metrics[:current_income].amount) * 100).round(0) : 0 + %> + <% if savings_rate != 0 %> + <%= t("reports.print.summary.of_income", percent: savings_rate) %> + <% end %> + <% if has_sparkline_data?(@trends_data) %> + + " stroke-width="1.5" /> + + <% end %> +
+ + <% if @summary_metrics[:budget_percent] %> +
+ <%= t("reports.print.summary.budget") %> + <%= @summary_metrics[:budget_percent] %>% + <%= t("reports.print.summary.used") %> +
+ <% end %> +
+
+ + <%# Net Worth %> + <% if @has_accounts %> +
+

<%= t("reports.print.net_worth.title") %>

+
+
+ <%= t("reports.print.net_worth.current_balance") %> + "> + <%= @net_worth_metrics[:current_net_worth].format %> + + <% if @net_worth_metrics[:trend] %> + + <%= @net_worth_metrics[:trend].value >= 0 ? "+" : "" %><%= @net_worth_metrics[:trend].value.format %> (<%= @net_worth_metrics[:trend].percent_formatted %>) <%= t("reports.print.net_worth.this_period") %> + + <% end %> + <% if has_sparkline_data?(@trends_data) %> + + " stroke-width="1.5" /> + + <% end %> +
+
+ +
+
+

<%= t("reports.print.net_worth.assets") %> <%= @net_worth_metrics[:total_assets].format %>

+ <% if @net_worth_metrics[:asset_groups].any? %> + + + <% @net_worth_metrics[:asset_groups].each do |group| %> + + + + + <% end %> + +
<%= group[:name] %><%= group[:total].format %>
+ <% end %> +
+
+

<%= t("reports.print.net_worth.liabilities") %> <%= @net_worth_metrics[:total_liabilities].format %>

+ <% if @net_worth_metrics[:liability_groups].any? %> + + + <% @net_worth_metrics[:liability_groups].each do |group| %> + + + + + <% end %> + +
<%= group[:name] %><%= group[:total].format %>
+ <% else %> +

<%= t("reports.print.net_worth.no_liabilities") %>

+ <% end %> +
+
+
+ <% end %> + + <%# Monthly Trends %> + <% if has_sparkline_data?(@trends_data) %> +
+

<%= t("reports.print.trends.title") %>

+ + + + + + + + + + + + <% @trends_data.each do |trend| %> + "> + + + + + + + <% end %> + + + <% + total_income = @trends_data.sum { |t| t[:income].to_d } + total_expenses = @trends_data.sum { |t| t[:expenses].to_d } + total_net = @trends_data.sum { |t| t[:net].to_d } + trends_count = @trends_data.length + avg_income = trends_count > 0 ? (total_income / trends_count) : 0 + avg_expenses = trends_count > 0 ? (total_expenses / trends_count) : 0 + avg_net = trends_count > 0 ? (total_net / trends_count) : 0 + overall_savings_rate = total_income > 0 ? ((total_net / total_income) * 100).round(0) : 0 + %> + + + + + + + + +
<%= t("reports.print.trends.month") %><%= t("reports.print.trends.income") %><%= t("reports.print.trends.expenses") %><%= t("reports.print.trends.net") %><%= t("reports.print.trends.savings_rate") %>
<%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %><%= Money.new(trend[:income], Current.family.currency).format %><%= Money.new(trend[:expenses], Current.family.currency).format %>"><%= Money.new(trend[:net], Current.family.currency).format %> + <% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %> + <%= month_savings_rate %>% +
<%= t("reports.print.trends.average") %><%= Money.new(avg_income, Current.family.currency).format %><%= Money.new(avg_expenses, Current.family.currency).format %>"><%= Money.new(avg_net, Current.family.currency).format %><%= overall_savings_rate %>%
+ <% if @trends_data.any? { |t| t[:is_current_month] } %> +

<%= t("reports.print.trends.current_month_note") %>

+ <% end %> +
+ <% end %> + + <%# Investments %> + <% if @investment_metrics[:has_investments] %> +
+

<%= t("reports.print.investments.title") %>

+
+
+ <%= t("reports.print.investments.portfolio_value") %> + <%= format_money(@investment_metrics[:portfolio_value]) %> + <% if has_sparkline_data?(@trends_data) %> + + + + <% end %> +
+ <% if @investment_metrics[:unrealized_trend] %> +
+ <%= t("reports.print.investments.total_return") %> + + <%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %> + + + <%= @investment_metrics[:unrealized_trend].percent_formatted %> + +
+ <% end %> +
+ <%= t("reports.print.investments.contributions") %> + <%= format_money(@investment_metrics[:period_contributions]) %> + <%= t("reports.print.investments.this_period") %> +
+
+ <%= t("reports.print.investments.withdrawals") %> + <%= format_money(@investment_metrics[:period_withdrawals]) %> + <%= t("reports.print.investments.this_period") %> +
+
+ + <% if @investment_metrics[:top_holdings].any? %> +

<%= t("reports.print.investments.top_holdings") %>

+ + + + + + + + + + + <% @investment_metrics[:top_holdings].each do |holding| %> + + + + + + + <% end %> + +
<%= t("reports.print.investments.holding") %><%= t("reports.print.investments.weight") %><%= t("reports.print.investments.value") %><%= t("reports.print.investments.return") %>
<%= holding.ticker %> <%= truncate(holding.name, length: 25) %><%= number_to_percentage(holding.weight || 0, precision: 1) %><%= format_money(holding.amount_money) %> + <% if holding.trend %> + <%= holding.trend.percent_formatted %> + <% else %> + + <% end %> +
+ <% end %> +
+ <% end %> + + <%# Spending by Category %> + <% if @transactions.any? %> +
+

<%= t("reports.print.spending.title") %>

+ <% + income_groups = @transactions.select { |g| g[:type] == "income" } + expense_groups = @transactions.select { |g| g[:type] == "expense" } + income_total = income_groups.sum { |g| g[:total] } + expense_total = expense_groups.sum { |g| g[:total] } + %> + +
+ <% if income_groups.any? %> +
+

<%= t("reports.print.spending.income") %> <%= Money.new(income_total, Current.family.currency).format %>

+ + + + + + + + + + <% income_groups.first(8).each do |group| %> + <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %> + + + + + + <% end %> + <% if income_groups.length > 8 %> + + + + + + <% end %> + +
<%= t("reports.print.spending.category") %><%= t("reports.print.spending.amount") %><%= t("reports.print.spending.percent") %>
+ + <%= group[:category_name] %> + <%= Money.new(group[:total], Current.family.currency).format %><%= percentage %>%
<%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %>
+
+ <% end %> + + <% if expense_groups.any? %> +
+

<%= t("reports.print.spending.expenses") %> <%= Money.new(expense_total, Current.family.currency).format %>

+ + + + + + + + + + <% expense_groups.first(8).each do |group| %> + <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %> + + + + + + <% end %> + <% if expense_groups.length > 8 %> + + + + + + <% end %> + +
<%= t("reports.print.spending.category") %><%= t("reports.print.spending.amount") %><%= t("reports.print.spending.percent") %>
+ + <%= group[:category_name] %> + <%= Money.new(group[:total], Current.family.currency).format %><%= percentage %>%
<%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %>
+
+ <% end %> +
+
+ <% end %> + + +
diff --git a/config/locales/views/reports/en.yml b/config/locales/views/reports/en.yml index 2d34539f7..ceebbe74c 100644 --- a/config/locales/views/reports/en.yml +++ b/config/locales/views/reports/en.yml @@ -5,6 +5,7 @@ en: title: Reports subtitle: Comprehensive insights into your financial health export: Export CSV + print_report: Print Report drag_to_reorder: "Drag to reorder section" toggle_section: "Toggle section visibility" periods: @@ -149,3 +150,57 @@ en: open_sheets: Open Google Sheets go_to_api_keys: Go to API Keys close: Got it + print: + document_title: Financial Report + title: Financial Report + generated_on: "Generated %{date}" + # Summary section + summary: + title: Summary + income: Income + expenses: Expenses + net_savings: Net Savings + budget: Budget + vs_prior: "%{percent}% vs prior" + of_income: "%{percent}% of income" + used: used + # Net Worth section + net_worth: + title: Net Worth + current_balance: Current Balance + this_period: this period + assets: Assets + liabilities: Liabilities + no_liabilities: No liabilities + # Monthly Trends section + trends: + title: Monthly Trends + month: Month + income: Income + expenses: Expenses + net: Net + savings_rate: Savings Rate + average: Average + current_month_note: "* Current month (partial data)" + # Investments section + investments: + title: Investments + portfolio_value: Portfolio Value + total_return: Total Return + contributions: Contributions + withdrawals: Withdrawals + this_period: this period + top_holdings: Top Holdings + holding: Holding + weight: Weight + value: Value + return: Return + # Spending by Category section + spending: + title: Spending by Category + income: Income + expenses: Expenses + category: Category + amount: Amount + percent: "%" + more_categories: "+ %{count} more categories" diff --git a/config/routes.rb b/config/routes.rb index ced0f676a..b2c7b84d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,6 +135,7 @@ Rails.application.routes.draw do patch :update_preferences, on: :collection get :export_transactions, on: :collection get :google_sheets_instructions, on: :collection + get :print, on: :collection end resources :budgets, only: %i[index show edit update], param: :month_year do diff --git a/test/models/import_encoding_test.rb b/test/models/import_encoding_test.rb index 0052e8dfc..7770da992 100644 --- a/test/models/import_encoding_test.rb +++ b/test/models/import_encoding_test.rb @@ -50,9 +50,10 @@ class ImportEncodingTest < ActiveSupport::TestCase assert_equal 3, import.rows_count, "Expected 3 data rows" # Verify Polish characters were preserved correctly - first_row = import.rows.first - assert_not_nil first_row, "Expected first row to exist" - assert_includes first_row.name, "spożywczy", "Polish characters should be preserved" + # Check that any row contains the Polish characters (test is about encoding, not ordering) + assert import.rows.any? { |row| row.name&.include?("spożywczy") }, "Polish characters should be preserved" + # Also verify other Polish characters from different rows + assert import.rows.any? { |row| row.name&.include?("Café") }, "Extended Latin characters should be preserved" end test "handles UTF-8 files without modification" do