Lines
100 %
Functions
40.43 %
Branches
//! View projections shared across report consumers.
//!
//! Flattens `ReportData` / `ActivityData` / `BreakdownData` server
//! responses into small row-oriented structs. Report table handlers, the
//! chart adapters in the `plotting` crate, and the CLI all consume these
//! instead of re-walking the tree.
use num_rational::Rational64;
use sqlx::types::Uuid;
use super::super::{ActivityData, BreakdownData, ReportData, ReportNode, UNCATEGORIZED_KEY};
#[derive(Debug, Clone)]
pub struct AmountView {
pub commodity_symbol: String,
pub amount: Rational64,
}
pub struct ReportRowView {
pub account_id: Uuid,
pub parent_id: Option<Uuid>,
pub account_name: String,
pub depth: usize,
pub has_children: bool,
pub amounts: Vec<AmountView>,
pub struct GroupView {
pub label: String,
pub flip_sign: bool,
pub rows: Vec<ReportRowView>,
pub total: Vec<AmountView>,
pub struct PeriodActivityView {
pub groups: Vec<GroupView>,
pub net: Vec<AmountView>,
pub struct BreakdownRowView {
pub tag_value: String,
pub is_uncategorized: bool,
pub struct BreakdownPeriodView {
pub rows: Vec<BreakdownRowView>,
fn amount_views(amounts: &[super::super::CommodityAmount], flip: bool) -> Vec<AmountView> {
amounts
.iter()
.map(|a| AmountView {
commodity_symbol: a.commodity_symbol.clone(),
amount: if flip { -a.amount } else { a.amount },
})
.collect()
fn flatten_nodes(
nodes: &[ReportNode],
parent: Option<Uuid>,
flip: bool,
out: &mut Vec<ReportRowView>,
) {
for node in nodes {
out.push(ReportRowView {
account_id: node.account_id,
parent_id: parent,
account_name: node.account_name.clone(),
depth: node.depth,
has_children: !node.children.is_empty(),
amounts: amount_views(&node.amounts, flip),
});
flatten_nodes(&node.children, Some(node.account_id), flip, out);
/// Flatten a point-in-time or period-activity `ReportData` into a single row
/// list. The outer `PeriodData` wrapper collapses because Balance always
/// returns exactly one period.
#[must_use]
pub fn flatten_report_data(data: &ReportData) -> Vec<ReportRowView> {
let mut rows = Vec::new();
for period in &data.periods {
flatten_nodes(&period.roots, None, false, &mut rows);
rows
fn sum_into(dest: &mut Vec<AmountView>, src: &[AmountView], negate: bool) {
for a in src {
let contribution = if negate { -a.amount } else { a.amount };
match dest
.iter_mut()
.find(|d| d.commodity_symbol == a.commodity_symbol)
{
Some(existing) => existing.amount += contribution,
None => dest.push(AmountView {
amount: contribution,
}),
fn top_level_totals(rows: &[ReportRowView]) -> Vec<AmountView> {
let mut out = Vec::new();
for row in rows.iter().filter(|r| r.depth == 0) {
sum_into(&mut out, &row.amounts, false);
out
/// Flatten an `ActivityData` into per-period / per-group row lists with
/// totals. `flip_sign` is applied to amounts and to the per-period net,
/// following the convention documented in `doc/reporting.org`:
/// `Net = sum(group_total * (flip_sign ? +1 : -1))`.
pub fn flatten_activity_data(data: &ActivityData) -> Vec<PeriodActivityView> {
data.periods
.map(|period| {
let groups: Vec<GroupView> = period
.groups
.map(|g| {
flatten_nodes(&g.roots, None, g.flip_sign, &mut rows);
let total = top_level_totals(&rows);
GroupView {
label: g.label.clone(),
flip_sign: g.flip_sign,
rows,
total,
.collect();
let mut net: Vec<AmountView> = Vec::new();
for g in &groups {
sum_into(&mut net, &g.total, !g.flip_sign);
PeriodActivityView {
label: period.label.clone().unwrap_or_default(),
groups,
net,
/// Flatten a `BreakdownData` into per-period row lists. The uncategorized
/// sentinel bucket is translated into `is_uncategorized = true` so consumers
/// can render it differently without knowing the sentinel value.
pub fn flatten_breakdown_data(data: &BreakdownData) -> Vec<BreakdownPeriodView> {
.map(|p| BreakdownPeriodView {
label: p.label.clone().unwrap_or_default(),
rows: p
.rows
.map(|r| BreakdownRowView {
tag_value: r.tag_value.clone(),
is_uncategorized: r.is_uncategorized || r.tag_value == UNCATEGORIZED_KEY,
amounts: amount_views(&r.amounts, false),
.collect(),
#[cfg(test)]
mod tests {
use super::super::super::{
ActivityGroupResult, ActivityPeriod, BreakdownPeriod, BreakdownRow, CommodityAmount,
PeriodData, ReportMeta,
};
use super::*;
fn commodity(symbol: &str, num: i64) -> CommodityAmount {
CommodityAmount {
commodity_id: Uuid::new_v4(),
commodity_symbol: symbol.to_string(),
amount: Rational64::new(num, 1),
fn node(
name: &str,
depth: usize,
amounts: Vec<CommodityAmount>,
children: Vec<ReportNode>,
) -> ReportNode {
ReportNode {
account_id: Uuid::new_v4(),
account_name: name.to_string(),
account_path: name.to_string(),
depth,
account_type: None,
amounts,
children,
#[test]
fn flatten_report_data_preserves_parent_links() {
let child = node("Checking", 1, vec![commodity("USD", 100)], vec![]);
let child_id = child.account_id;
let parent = node("Assets", 0, vec![commodity("USD", 100)], vec![child]);
let parent_id = parent.account_id;
let data = ReportData {
meta: ReportMeta {
date_from: None,
date_to: None,
target_commodity_id: None,
},
periods: vec![PeriodData {
label: None,
roots: vec![parent],
}],
let rows = flatten_report_data(&data);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].account_id, parent_id);
assert!(rows[0].parent_id.is_none());
assert!(rows[0].has_children);
assert_eq!(rows[1].account_id, child_id);
assert_eq!(rows[1].parent_id, Some(parent_id));
assert!(!rows[1].has_children);
fn flatten_activity_data_flips_income_sign_and_nets() {
let income = ActivityGroupResult {
label: "Income".to_string(),
flip_sign: true,
roots: vec![node("Salary", 0, vec![commodity("USD", -500)], vec![])],
let expense = ActivityGroupResult {
label: "Expense".to_string(),
flip_sign: false,
roots: vec![node("Rent", 0, vec![commodity("USD", 200)], vec![])],
let data = ActivityData {
periods: vec![ActivityPeriod {
label: Some("2026-04".to_string()),
groups: vec![income, expense],
let periods = flatten_activity_data(&data);
assert_eq!(periods.len(), 1);
let p = &periods[0];
assert_eq!(p.label, "2026-04");
assert_eq!(
p.groups[0].rows[0].amounts[0].amount,
Rational64::new(500, 1)
);
assert_eq!(p.groups[0].total[0].amount, Rational64::new(500, 1));
assert_eq!(p.groups[1].total[0].amount, Rational64::new(200, 1));
// Net: income flipped (+500) minus expense (200) = 300.
assert_eq!(p.net.len(), 1);
assert_eq!(p.net[0].commodity_symbol, "USD");
assert_eq!(p.net[0].amount, Rational64::new(300, 1));
fn flatten_breakdown_data_marks_sentinel_uncategorized() {
let data = BreakdownData {
tag_name: "category".to_string(),
periods: vec![BreakdownPeriod {
rows: vec![
BreakdownRow {
tag_value: "food".to_string(),
is_uncategorized: false,
amounts: vec![commodity("USD", 50)],
tag_value: UNCATEGORIZED_KEY.to_string(),
is_uncategorized: true,
amounts: vec![commodity("USD", 10)],
],
let periods = flatten_breakdown_data(&data);
assert_eq!(periods[0].rows.len(), 2);
assert!(!periods[0].rows[0].is_uncategorized);
assert!(periods[0].rows[1].is_uncategorized);