Lines
97.25 %
Functions
46.67 %
Branches
100 %
//! Turn view projections into chart specs. Adapters are pure functions
//! that handle top-N clipping, multi-currency scaling decisions, and
//! layout choices (x-axis assignment, series arrangement) so renderers
//! only have to draw.
use std::collections::BTreeMap;
use num_rational::Rational64;
use server::command::report::view::{
AmountView, BreakdownPeriodView, PeriodActivityView, ReportRowView,
};
use crate::spec::{ChartKind, ChartSpec, Series, SeriesPoint};
// ---------- shared helpers ----------
fn abs(r: Rational64) -> Rational64 {
if r < Rational64::new(0, 1) { -r } else { r }
}
fn point(x: impl Into<String>, amount: Rational64) -> SeriesPoint {
SeriesPoint {
x: x.into(),
y_num: *amount.numer(),
y_denom: *amount.denom(),
fn amount_for(symbol: &str, amounts: &[AmountView]) -> Rational64 {
amounts
.iter()
.find(|a| a.commodity_symbol == symbol)
.map_or_else(|| Rational64::new(0, 1), |a| a.amount)
/// Pick the commodity to plot. When the report already targeted a single
/// commodity there's usually only one symbol; otherwise the adapter
/// picks the one that first appears and appends a note listing the
/// elided symbols so the display can warn the user.
fn pick_primary_commodity(symbols: &[String]) -> (String, Vec<String>) {
match symbols.split_first() {
Some((primary, rest)) => (primary.clone(), rest.to_vec()),
None => (String::new(), Vec::new()),
fn multi_currency_note(elided: &[String]) -> Option<String> {
match elided.len() {
0 => None,
1 => Some(format!(
"Showing primary commodity only; {} hidden. Set a target currency to see all.",
elided[0],
)),
n => Some(format!(
"Showing primary commodity only; {n} others hidden. Set a target currency to see all.",
/// Sort ranked `(row, magnitude)` pairs in place according to the
/// requested order. Uses the balance adapter's data shape — callers
/// that need this for other adapters can swap the row type via the
/// tuple's first field; the function is generic in the row ref.
fn sort_ranked<R: AccountLike>(ranked: &mut [(R, Rational64)], order: SortOrder) {
match order {
SortOrder::MagnitudeDesc => {
ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1));
SortOrder::MagnitudeAsc => {
ranked.sort_by_key(|entry| entry.1);
SortOrder::NameAsc => {
ranked.sort_by(|a, b| a.0.name().cmp(b.0.name()));
SortOrder::NameDesc => {
ranked.sort_by(|a, b| b.0.name().cmp(a.0.name()));
trait AccountLike {
fn name(&self) -> &str;
impl AccountLike for &ReportRowView {
fn name(&self) -> &str {
&self.account_name
// ---------- Balance ----------
/// Sort key applied to a ranked set of rows. Shared by the balance
/// adapter and (via the web handler) the balance table rendering —
/// keeping them on one enum means the chart can't drift from the
/// table ordering.
#[derive(Debug, Clone, Copy, Default)]
pub enum SortOrder {
#[default]
MagnitudeDesc,
MagnitudeAsc,
NameAsc,
NameDesc,
pub struct BalanceChartOpts {
pub kind: ChartKind,
pub top_n: usize,
pub sort_order: SortOrder,
/// Balance chart: one series per commodity, x = top-level account names.
/// When more than one commodity is present and the `top_n` most-active
/// accounts span several commodities, we still render all bars — each
/// series places a bar in the x-slot for its account and zero elsewhere,
/// so a mixed-currency report reads as a grouped / stacked bar chart.
#[must_use]
pub fn balance_chart(rows: &[ReportRowView], opts: BalanceChartOpts) -> ChartSpec {
let top_level: Vec<&ReportRowView> = rows.iter().filter(|r| r.depth == 0).collect();
if top_level.is_empty() {
return ChartSpec {
title: "Balance".to_string(),
kind: opts.kind,
x_label: "Account".to_string(),
y_label: "Amount".to_string(),
series: vec![],
notes: vec!["No top-level accounts to chart.".to_string()],
// Gather commodity symbols in insertion order, then pick the
// primary for plotting. A mixed-currency balance notes the elided
// commodities rather than silently mashing them together.
let mut symbols: Vec<String> = Vec::new();
for row in &top_level {
for a in &row.amounts {
if !symbols.iter().any(|s| s == &a.commodity_symbol) {
symbols.push(a.commodity_symbol.clone());
let (primary, elided) = pick_primary_commodity(&symbols);
// Rank accounts by the primary commodity's absolute amount and
// then apply the caller's sort choice. Keeping magnitude as the
// secondary key means name-sorted charts still break ties
// sensibly.
let mut ranked: Vec<(&ReportRowView, Rational64)> = top_level
.into_iter()
.map(|row| (row, abs(amount_for(&primary, &row.amounts))))
.collect();
sort_ranked(&mut ranked, opts.sort_order);
let clipped = ranked.len() > opts.top_n && opts.top_n > 0;
if opts.top_n > 0 {
ranked.truncate(opts.top_n);
// One series per account so each bar reads as its own colour and
// the legend names the account. Each series holds a single point
// anchored at the account name.
let series: Vec<Series> = ranked
.map(|(row, _)| Series {
label: row.account_name.clone(),
commodity_symbol: primary.clone(),
points: vec![point(&row.account_name, amount_for(&primary, &row.amounts))],
})
let mut notes = Vec::new();
if let Some(n) = multi_currency_note(&elided) {
notes.push(n);
if clipped {
notes.push(format!("Showing top {} accounts by magnitude.", opts.top_n));
ChartSpec {
y_label: format!("Amount ({primary})"),
series,
notes,
// ---------- Activity ----------
pub struct ActivityChartOpts {
pub include_net: bool,
/// Activity chart: x = period label, one series per group label, plus
/// an optional Net series. Picks a single commodity when multiple are
/// present and notes the omission.
pub fn activity_chart(periods: &[PeriodActivityView], opts: ActivityChartOpts) -> ChartSpec {
// Pass 1: distinct commodities across every group total.
for p in periods {
for g in &p.groups {
for a in &g.total {
for a in &p.net {
// Pass 2: group labels in insertion order.
let mut group_labels: Vec<String> = Vec::new();
if !group_labels.iter().any(|s| s == &g.label) {
group_labels.push(g.label.clone());
let mut series: Vec<Series> = group_labels
.map(|label| Series {
label: label.clone(),
points: periods
.map(|p| {
let amount = p
.groups
.find(|g| &g.label == label)
.map_or_else(|| Rational64::new(0, 1), |g| amount_for(&primary, &g.total));
point(&p.label, amount)
.collect(),
if opts.include_net {
series.push(Series {
label: "Net".to_string(),
.map(|p| point(&p.label, amount_for(&primary, &p.net)))
});
if periods.is_empty() {
notes.push("No periods in range.".to_string());
title: "Activity".to_string(),
x_label: "Period".to_string(),
// ---------- Category Breakdown ----------
pub struct BreakdownChartOpts {
/// Breakdown chart. Two shapes depending on whether the breakdown has
/// period grouping:
///
/// - **With periods** (len > 1, or the single label is non-empty):
/// x = period label, one series per top-N tag value.
/// - **Flat** (single period, empty label — meaning no `period_grouping`):
/// x = tag value, a single series.
pub fn breakdown_chart(periods: &[BreakdownPeriodView], opts: BreakdownChartOpts) -> ChartSpec {
title: "Category Breakdown".to_string(),
x_label: "Category".to_string(),
notes: vec!["No rows in range.".to_string()],
for row in &p.rows {
let flat = periods.len() == 1 && periods[0].label.is_empty();
// Pull the uncategorized flag off each tag-value up front so flat
// and period shapes can relabel "__uncategorized__" consistently.
let mut uncategorized_tags: std::collections::HashSet<String> =
std::collections::HashSet::new();
if row.is_uncategorized {
uncategorized_tags.insert(row.tag_value.clone());
// Accumulate per-tag totals (for top-N ranking).
let mut totals: BTreeMap<String, Rational64> = BTreeMap::new();
let entry = totals
.entry(row.tag_value.clone())
.or_insert_with(|| Rational64::new(0, 1));
*entry += amount_for(&primary, &row.amounts);
let mut ranked: Vec<(String, Rational64)> = totals.into_iter().collect();
ranked.sort_by_key(|entry| std::cmp::Reverse(abs(entry.1)));
let pretty_tag = |tag: &str| -> String {
if uncategorized_tags.contains(tag) {
"(uncategorized)".to_string()
} else {
tag.to_string()
notes.push(format!("Showing top {} categories.", opts.top_n));
// Flat shape: one series per category so each bar reads as a
// distinct colour. Each series holds a single point anchored at
// its own tag name. Period shape: one series per category across
// period x-slots.
let series = if flat {
ranked
.map(|(name, amount)| Series {
label: pretty_tag(name),
points: vec![point(pretty_tag(name), *amount)],
.collect()
.map(|(tag, _)| Series {
label: pretty_tag(tag),
let amount = p.rows.iter().find(|r| &r.tag_value == tag).map_or_else(
|| Rational64::new(0, 1),
|r| amount_for(&primary, &r.amounts),
);
x_label: if flat {
"Category".to_string()
"Period".to_string()
},
#[cfg(test)]
mod tests {
use server::command::report::view::{BreakdownRowView, GroupView};
use uuid::Uuid;
use super::*;
fn amt(sym: &str, n: i64) -> AmountView {
AmountView {
commodity_symbol: sym.to_string(),
amount: Rational64::new(n, 1),
fn row(name: &str, depth: usize, amounts: Vec<AmountView>) -> ReportRowView {
ReportRowView {
account_id: Uuid::new_v4(),
parent_id: None,
account_name: name.to_string(),
depth,
has_children: false,
amounts,
// ---- Balance ----
#[test]
fn balance_chart_ranks_and_clips_to_top_n() {
let rows = vec![
row("Cash", 0, vec![amt("USD", 100)]),
row("Bank", 0, vec![amt("USD", 5000)]),
row("Credit", 0, vec![amt("USD", -2000)]),
row("Checking", 1, vec![amt("USD", 3000)]), // depth 1 — ignored
];
let spec = balance_chart(
&rows,
BalanceChartOpts {
kind: ChartKind::Bar,
top_n: 2,
sort_order: SortOrder::MagnitudeDesc,
// One series per account so each bar gets its own palette
// colour. Account order matches absolute-amount ranking.
assert_eq!(spec.series.len(), 2, "one series per top-N account");
let labels: Vec<&str> = spec.series.iter().map(|s| s.label.as_str()).collect();
assert_eq!(labels, ["Bank", "Credit"]);
assert_eq!(spec.series[0].points[0].x, "Bank");
assert_eq!(spec.series[0].points[0].y_num, 5000);
assert_eq!(spec.series[1].points[0].y_num, -2000);
assert!(spec.notes.iter().any(|n| n.contains("top 2")));
fn balance_chart_sort_order_variants() {
row("Gamma", 0, vec![amt("USD", 300)]),
row("Alpha", 0, vec![amt("USD", 500)]),
row("Beta", 0, vec![amt("USD", -100)]),
let order_labels = |order: SortOrder| -> Vec<String> {
balance_chart(
top_n: 10,
sort_order: order,
)
.series
.map(|s| s.label.clone())
assert_eq!(
order_labels(SortOrder::MagnitudeDesc),
vec!["Alpha", "Gamma", "Beta"],
"default: largest absolute amount first"
order_labels(SortOrder::MagnitudeAsc),
vec!["Beta", "Gamma", "Alpha"],
"ascending walks smallest first"
order_labels(SortOrder::NameAsc),
vec!["Alpha", "Beta", "Gamma"],
"alphabetical"
order_labels(SortOrder::NameDesc),
vec!["Gamma", "Beta", "Alpha"],
"reverse alphabetical"
fn balance_chart_handles_multi_currency() {
row("Bank", 0, vec![amt("EUR", 200), amt("USD", 50)]),
// Mixed currency picks the primary (USD, the first-seen),
// notes the elided one, and charts account bars using the
// primary's amount.
assert_eq!(spec.series.len(), 2, "one series per account");
assert_eq!(spec.y_label, "Amount (USD)");
let cash = spec.series.iter().find(|s| s.label == "Cash").unwrap();
let bank = spec.series.iter().find(|s| s.label == "Bank").unwrap();
assert_eq!(cash.points[0].y_num, 100);
assert_eq!(bank.points[0].y_num, 50);
assert!(
spec.notes
.any(|n| n.contains("EUR") && n.contains("hidden")),
"notes mention the elided commodity"
fn balance_chart_empty_rows_is_graceful() {
&[],
top_n: 5,
assert!(spec.series.is_empty());
assert!(spec.notes.iter().any(|n| n.contains("No top-level")));
// ---- Activity ----
fn activity_chart_emits_series_per_group_and_optional_net() {
let periods = vec![
PeriodActivityView {
label: "2026-01".to_string(),
groups: vec![
GroupView {
label: "Income".to_string(),
flip_sign: true,
rows: vec![],
total: vec![amt("USD", 3200)],
label: "Expense".to_string(),
flip_sign: false,
total: vec![amt("USD", 2100)],
],
net: vec![amt("USD", 1100)],
label: "2026-02".to_string(),
total: vec![amt("USD", 2800)],
total: vec![amt("USD", 1900)],
net: vec![amt("USD", 900)],
let spec = activity_chart(
&periods,
ActivityChartOpts {
kind: ChartKind::StackedBar,
include_net: true,
assert_eq!(spec.series.len(), 3, "Income, Expense, Net — three series");
let income = spec.series.iter().find(|s| s.label == "Income").unwrap();
let xs: Vec<&str> = income.points.iter().map(|p| p.x.as_str()).collect();
assert_eq!(xs, vec!["2026-01", "2026-02"]);
let ys: Vec<i64> = income.points.iter().map(|p| p.y_num).collect();
assert_eq!(ys, vec![3200, 2800]);
let net = spec.series.iter().find(|s| s.label == "Net").unwrap();
assert_eq!(net.points[0].y_num, 1100);
assert_eq!(net.points[1].y_num, 900);
fn activity_chart_without_net_omits_the_net_series() {
let periods = vec![PeriodActivityView {
label: "2026-04".to_string(),
groups: vec![GroupView {
total: vec![amt("USD", 1000)],
}],
net: vec![amt("USD", 1000)],
}];
include_net: false,
assert!(spec.series.iter().all(|s| s.label != "Net"));
fn activity_chart_multi_currency_notes_omission() {
total: vec![amt("USD", 100), amt("EUR", 80)],
net: vec![amt("USD", 100), amt("EUR", 80)],
assert_eq!(spec.series[0].commodity_symbol, "USD");
// ---- Breakdown ----
fn brow(tag: &str, amounts: Vec<AmountView>) -> BreakdownRowView {
BreakdownRowView {
tag_value: tag.to_string(),
is_uncategorized: false,
fn breakdown_chart_flat_shape_single_series() {
let periods = vec![BreakdownPeriodView {
label: String::new(),
rows: vec![
brow("food", vec![amt("USD", 400)]),
brow("transport", vec![amt("USD", 150)]),
brow("rent", vec![amt("USD", 1200)]),
let spec = breakdown_chart(
BreakdownChartOpts {
// Flat shape emits one series per category so bars read as
// distinct colours. Order matches the absolute-amount ranking.
assert_eq!(spec.series.len(), 3, "one series per category");
assert_eq!(labels, ["rent", "food", "transport"]);
let rent = &spec.series[0];
assert_eq!(rent.points.len(), 1, "single point per flat series");
assert_eq!(rent.points[0].y_num, 1200);
assert_eq!(spec.x_label, "Category");
fn breakdown_chart_period_shape_one_series_per_category() {
BreakdownPeriodView {
brow("food", vec![amt("USD", 300)]),
brow("transport", vec![amt("USD", 100)]),
rows: vec![brow("food", vec![amt("USD", 350)])],
kind: ChartKind::Line,
assert_eq!(spec.series.len(), 2, "food + transport");
let food = spec.series.iter().find(|s| s.label == "food").unwrap();
let transport = spec.series.iter().find(|s| s.label == "transport").unwrap();
assert_eq!(food.points[0].y_num, 300);
assert_eq!(food.points[1].y_num, 350);
assert_eq!(transport.points[0].y_num, 100);
transport.points[1].y_num, 0,
"missing periods fill with zero"
assert_eq!(spec.x_label, "Period");
fn breakdown_chart_clips_to_top_n_and_notes() {
brow("a", vec![amt("USD", 100)]),
brow("b", vec![amt("USD", 200)]),
brow("c", vec![amt("USD", 300)]),
brow("d", vec![amt("USD", 400)]),
// Top-2 clip leaves two categories, each in its own series.
assert_eq!(spec.series.len(), 2);
fn breakdown_chart_flat_relabels_uncategorized_sentinel() {
tag_value: "__uncategorized__".to_string(),
is_uncategorized: true,
amounts: vec![amt("USD", 50)],
let uncategorized = spec
.find(|s| s.label == "(uncategorized)")
.expect("pretty label replaces sentinel");
assert_eq!(uncategorized.points[0].x, "(uncategorized)");
!spec.series.iter().any(|s| s.label == "__uncategorized__"),
"sentinel must not leak into the chart"