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 @@
+
+
+
+
- <%= 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") %>
+
<%= t("reports.index.print_report") %>
+ <% 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 %>
+
+
+ <%# 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) %>
+
+ <% 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) %>
+
+ <% 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| %>
+
+ | <%= group[:name] %> |
+ <%= group[:total].format %> |
+
+ <% end %>
+
+
+ <% 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| %>
+
+ | <%= group[:name] %> |
+ <%= group[:total].format %> |
+
+ <% end %>
+
+
+ <% else %>
+
<%= t("reports.print.net_worth.no_liabilities") %>
+ <% end %>
+
+
+
+ <% end %>
+
+ <%# Monthly Trends %>
+ <% if has_sparkline_data?(@trends_data) %>
+
+ <%= t("reports.print.trends.title") %>
+
+
+
+ | <%= 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") %> |
+
+
+
+ <% @trends_data.each do |trend| %>
+ ">
+ | <%= 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 %>%
+ |
+
+ <% 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.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] } %>
+
+ <% 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") %>
+
+
+
+ | <%= t("reports.print.investments.holding") %> |
+ <%= t("reports.print.investments.weight") %> |
+ <%= t("reports.print.investments.value") %> |
+ <%= t("reports.print.investments.return") %> |
+
+
+
+ <% @investment_metrics[:top_holdings].each do |holding| %>
+
+ | <%= 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 %>
+
+ <% 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 %>
+
+
+
+ | <%= t("reports.print.spending.category") %> |
+ <%= t("reports.print.spending.amount") %> |
+ <%= t("reports.print.spending.percent") %> |
+
+
+
+ <% income_groups.first(8).each do |group| %>
+ <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
+
+ |
+
+ <%= group[:category_name] %>
+ |
+ <%= Money.new(group[:total], Current.family.currency).format %> |
+ <%= percentage %>% |
+
+ <% end %>
+ <% if income_groups.length > 8 %>
+
+ | <%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %> |
+ |
+ |
+
+ <% end %>
+
+
+
+ <% end %>
+
+ <% if expense_groups.any? %>
+
+
<%= t("reports.print.spending.expenses") %> <%= Money.new(expense_total, Current.family.currency).format %>
+
+
+
+ | <%= t("reports.print.spending.category") %> |
+ <%= t("reports.print.spending.amount") %> |
+ <%= t("reports.print.spending.percent") %> |
+
+
+
+ <% expense_groups.first(8).each do |group| %>
+ <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
+
+ |
+
+ <%= group[:category_name] %>
+ |
+ <%= Money.new(group[:total], Current.family.currency).format %> |
+ <%= percentage %>% |
+
+ <% end %>
+ <% if expense_groups.length > 8 %>
+
+ | <%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %> |
+ |
+ |
+
+ <% end %>
+
+
+
+ <% 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