1use std::collections::BTreeMap;
7
8use num_rational::Rational64;
9use server::command::report::view::{
10 AmountView, BreakdownPeriodView, PeriodActivityView, ReportRowView,
11};
12
13use crate::spec::{ChartKind, ChartSpec, Series, SeriesPoint};
14
15fn abs(r: Rational64) -> Rational64 {
18 if r < Rational64::new(0, 1) { -r } else { r }
19}
20
21fn point(x: impl Into<String>, amount: Rational64) -> SeriesPoint {
22 SeriesPoint {
23 x: x.into(),
24 y_num: *amount.numer(),
25 y_denom: *amount.denom(),
26 }
27}
28
29fn amount_for(symbol: &str, amounts: &[AmountView]) -> Rational64 {
30 amounts
31 .iter()
32 .find(|a| a.commodity_symbol == symbol)
33 .map_or_else(|| Rational64::new(0, 1), |a| a.amount)
34}
35
36fn pick_primary_commodity(symbols: &[String]) -> (String, Vec<String>) {
41 match symbols.split_first() {
42 Some((primary, rest)) => (primary.clone(), rest.to_vec()),
43 None => (String::new(), Vec::new()),
44 }
45}
46
47fn multi_currency_note(elided: &[String]) -> Option<String> {
48 match elided.len() {
49 0 => None,
50 1 => Some(format!(
51 "Showing primary commodity only; {} hidden. Set a target currency to see all.",
52 elided[0],
53 )),
54 n => Some(format!(
55 "Showing primary commodity only; {n} others hidden. Set a target currency to see all.",
56 )),
57 }
58}
59
60fn sort_ranked<R: AccountLike>(ranked: &mut [(R, Rational64)], order: SortOrder) {
65 match order {
66 SortOrder::MagnitudeDesc => {
67 ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1));
68 }
69 SortOrder::MagnitudeAsc => {
70 ranked.sort_by_key(|entry| entry.1);
71 }
72 SortOrder::NameAsc => {
73 ranked.sort_by(|a, b| a.0.name().cmp(b.0.name()));
74 }
75 SortOrder::NameDesc => {
76 ranked.sort_by(|a, b| b.0.name().cmp(a.0.name()));
77 }
78 }
79}
80
81trait AccountLike {
82 fn name(&self) -> &str;
83}
84
85impl AccountLike for &ReportRowView {
86 fn name(&self) -> &str {
87 &self.account_name
88 }
89}
90
91#[derive(Debug, Clone, Copy, Default)]
98pub enum SortOrder {
99 #[default]
100 MagnitudeDesc,
101 MagnitudeAsc,
102 NameAsc,
103 NameDesc,
104}
105
106pub struct BalanceChartOpts {
107 pub kind: ChartKind,
108 pub top_n: usize,
109 pub sort_order: SortOrder,
110}
111
112#[must_use]
118pub fn balance_chart(rows: &[ReportRowView], opts: BalanceChartOpts) -> ChartSpec {
119 let top_level: Vec<&ReportRowView> = rows.iter().filter(|r| r.depth == 0).collect();
120
121 if top_level.is_empty() {
122 return ChartSpec {
123 title: "Balance".to_string(),
124 kind: opts.kind,
125 x_label: "Account".to_string(),
126 y_label: "Amount".to_string(),
127 series: vec![],
128 notes: vec!["No top-level accounts to chart.".to_string()],
129 };
130 }
131
132 let mut symbols: Vec<String> = Vec::new();
136 for row in &top_level {
137 for a in &row.amounts {
138 if !symbols.iter().any(|s| s == &a.commodity_symbol) {
139 symbols.push(a.commodity_symbol.clone());
140 }
141 }
142 }
143 let (primary, elided) = pick_primary_commodity(&symbols);
144
145 let mut ranked: Vec<(&ReportRowView, Rational64)> = top_level
150 .into_iter()
151 .map(|row| (row, abs(amount_for(&primary, &row.amounts))))
152 .collect();
153 sort_ranked(&mut ranked, opts.sort_order);
154
155 let clipped = ranked.len() > opts.top_n && opts.top_n > 0;
156 if opts.top_n > 0 {
157 ranked.truncate(opts.top_n);
158 }
159
160 let series: Vec<Series> = ranked
164 .iter()
165 .map(|(row, _)| Series {
166 label: row.account_name.clone(),
167 commodity_symbol: primary.clone(),
168 points: vec![point(&row.account_name, amount_for(&primary, &row.amounts))],
169 })
170 .collect();
171
172 let mut notes = Vec::new();
173 if let Some(n) = multi_currency_note(&elided) {
174 notes.push(n);
175 }
176 if clipped {
177 notes.push(format!("Showing top {} accounts by magnitude.", opts.top_n));
178 }
179
180 ChartSpec {
181 title: "Balance".to_string(),
182 kind: opts.kind,
183 x_label: "Account".to_string(),
184 y_label: format!("Amount ({primary})"),
185 series,
186 notes,
187 }
188}
189
190pub struct ActivityChartOpts {
193 pub kind: ChartKind,
194 pub include_net: bool,
195}
196
197#[must_use]
201pub fn activity_chart(periods: &[PeriodActivityView], opts: ActivityChartOpts) -> ChartSpec {
202 let mut symbols: Vec<String> = Vec::new();
204 for p in periods {
205 for g in &p.groups {
206 for a in &g.total {
207 if !symbols.iter().any(|s| s == &a.commodity_symbol) {
208 symbols.push(a.commodity_symbol.clone());
209 }
210 }
211 for a in &p.net {
212 if !symbols.iter().any(|s| s == &a.commodity_symbol) {
213 symbols.push(a.commodity_symbol.clone());
214 }
215 }
216 }
217 }
218 let (primary, elided) = pick_primary_commodity(&symbols);
219
220 let mut group_labels: Vec<String> = Vec::new();
222 for p in periods {
223 for g in &p.groups {
224 if !group_labels.iter().any(|s| s == &g.label) {
225 group_labels.push(g.label.clone());
226 }
227 }
228 }
229
230 let mut series: Vec<Series> = group_labels
231 .iter()
232 .map(|label| Series {
233 label: label.clone(),
234 commodity_symbol: primary.clone(),
235 points: periods
236 .iter()
237 .map(|p| {
238 let amount = p
239 .groups
240 .iter()
241 .find(|g| &g.label == label)
242 .map_or_else(|| Rational64::new(0, 1), |g| amount_for(&primary, &g.total));
243 point(&p.label, amount)
244 })
245 .collect(),
246 })
247 .collect();
248
249 if opts.include_net {
250 series.push(Series {
251 label: "Net".to_string(),
252 commodity_symbol: primary.clone(),
253 points: periods
254 .iter()
255 .map(|p| point(&p.label, amount_for(&primary, &p.net)))
256 .collect(),
257 });
258 }
259
260 let mut notes = Vec::new();
261 if let Some(n) = multi_currency_note(&elided) {
262 notes.push(n);
263 }
264 if periods.is_empty() {
265 notes.push("No periods in range.".to_string());
266 }
267
268 ChartSpec {
269 title: "Activity".to_string(),
270 kind: opts.kind,
271 x_label: "Period".to_string(),
272 y_label: format!("Amount ({primary})"),
273 series,
274 notes,
275 }
276}
277
278pub struct BreakdownChartOpts {
281 pub kind: ChartKind,
282 pub top_n: usize,
283}
284
285#[must_use]
293pub fn breakdown_chart(periods: &[BreakdownPeriodView], opts: BreakdownChartOpts) -> ChartSpec {
294 if periods.is_empty() {
295 return ChartSpec {
296 title: "Category Breakdown".to_string(),
297 kind: opts.kind,
298 x_label: "Category".to_string(),
299 y_label: "Amount".to_string(),
300 series: vec![],
301 notes: vec!["No rows in range.".to_string()],
302 };
303 }
304
305 let mut symbols: Vec<String> = Vec::new();
306 for p in periods {
307 for row in &p.rows {
308 for a in &row.amounts {
309 if !symbols.iter().any(|s| s == &a.commodity_symbol) {
310 symbols.push(a.commodity_symbol.clone());
311 }
312 }
313 }
314 }
315 let (primary, elided) = pick_primary_commodity(&symbols);
316
317 let flat = periods.len() == 1 && periods[0].label.is_empty();
318
319 let mut uncategorized_tags: std::collections::HashSet<String> =
322 std::collections::HashSet::new();
323 for p in periods {
324 for row in &p.rows {
325 if row.is_uncategorized {
326 uncategorized_tags.insert(row.tag_value.clone());
327 }
328 }
329 }
330
331 let mut totals: BTreeMap<String, Rational64> = BTreeMap::new();
333 for p in periods {
334 for row in &p.rows {
335 let entry = totals
336 .entry(row.tag_value.clone())
337 .or_insert_with(|| Rational64::new(0, 1));
338 *entry += amount_for(&primary, &row.amounts);
339 }
340 }
341
342 let mut ranked: Vec<(String, Rational64)> = totals.into_iter().collect();
343 ranked.sort_by_key(|entry| std::cmp::Reverse(abs(entry.1)));
344 let clipped = ranked.len() > opts.top_n && opts.top_n > 0;
345 if opts.top_n > 0 {
346 ranked.truncate(opts.top_n);
347 }
348
349 let pretty_tag = |tag: &str| -> String {
350 if uncategorized_tags.contains(tag) {
351 "(uncategorized)".to_string()
352 } else {
353 tag.to_string()
354 }
355 };
356
357 let mut notes = Vec::new();
358 if let Some(n) = multi_currency_note(&elided) {
359 notes.push(n);
360 }
361 if clipped {
362 notes.push(format!("Showing top {} categories.", opts.top_n));
363 }
364
365 let series = if flat {
370 ranked
371 .iter()
372 .map(|(name, amount)| Series {
373 label: pretty_tag(name),
374 commodity_symbol: primary.clone(),
375 points: vec![point(pretty_tag(name), *amount)],
376 })
377 .collect()
378 } else {
379 ranked
380 .iter()
381 .map(|(tag, _)| Series {
382 label: pretty_tag(tag),
383 commodity_symbol: primary.clone(),
384 points: periods
385 .iter()
386 .map(|p| {
387 let amount = p.rows.iter().find(|r| &r.tag_value == tag).map_or_else(
388 || Rational64::new(0, 1),
389 |r| amount_for(&primary, &r.amounts),
390 );
391 point(&p.label, amount)
392 })
393 .collect(),
394 })
395 .collect()
396 };
397
398 ChartSpec {
399 title: "Category Breakdown".to_string(),
400 kind: opts.kind,
401 x_label: if flat {
402 "Category".to_string()
403 } else {
404 "Period".to_string()
405 },
406 y_label: format!("Amount ({primary})"),
407 series,
408 notes,
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use server::command::report::view::{BreakdownRowView, GroupView};
415 use uuid::Uuid;
416
417 use super::*;
418
419 fn amt(sym: &str, n: i64) -> AmountView {
420 AmountView {
421 commodity_symbol: sym.to_string(),
422 amount: Rational64::new(n, 1),
423 }
424 }
425
426 fn row(name: &str, depth: usize, amounts: Vec<AmountView>) -> ReportRowView {
427 ReportRowView {
428 account_id: Uuid::new_v4(),
429 parent_id: None,
430 account_name: name.to_string(),
431 depth,
432 has_children: false,
433 amounts,
434 }
435 }
436
437 #[test]
440 fn balance_chart_ranks_and_clips_to_top_n() {
441 let rows = vec![
442 row("Cash", 0, vec![amt("USD", 100)]),
443 row("Bank", 0, vec![amt("USD", 5000)]),
444 row("Credit", 0, vec![amt("USD", -2000)]),
445 row("Checking", 1, vec![amt("USD", 3000)]), ];
447
448 let spec = balance_chart(
449 &rows,
450 BalanceChartOpts {
451 kind: ChartKind::Bar,
452 top_n: 2,
453 sort_order: SortOrder::MagnitudeDesc,
454 },
455 );
456
457 assert_eq!(spec.series.len(), 2, "one series per top-N account");
460 let labels: Vec<&str> = spec.series.iter().map(|s| s.label.as_str()).collect();
461 assert_eq!(labels, ["Bank", "Credit"]);
462 assert_eq!(spec.series[0].points[0].x, "Bank");
463 assert_eq!(spec.series[0].points[0].y_num, 5000);
464 assert_eq!(spec.series[1].points[0].y_num, -2000);
465 assert!(spec.notes.iter().any(|n| n.contains("top 2")));
466 }
467
468 #[test]
469 fn balance_chart_sort_order_variants() {
470 let rows = vec![
471 row("Gamma", 0, vec![amt("USD", 300)]),
472 row("Alpha", 0, vec![amt("USD", 500)]),
473 row("Beta", 0, vec![amt("USD", -100)]),
474 ];
475
476 let order_labels = |order: SortOrder| -> Vec<String> {
477 balance_chart(
478 &rows,
479 BalanceChartOpts {
480 kind: ChartKind::Bar,
481 top_n: 10,
482 sort_order: order,
483 },
484 )
485 .series
486 .iter()
487 .map(|s| s.label.clone())
488 .collect()
489 };
490
491 assert_eq!(
492 order_labels(SortOrder::MagnitudeDesc),
493 vec!["Alpha", "Gamma", "Beta"],
494 "default: largest absolute amount first"
495 );
496 assert_eq!(
497 order_labels(SortOrder::MagnitudeAsc),
498 vec!["Beta", "Gamma", "Alpha"],
499 "ascending walks smallest first"
500 );
501 assert_eq!(
502 order_labels(SortOrder::NameAsc),
503 vec!["Alpha", "Beta", "Gamma"],
504 "alphabetical"
505 );
506 assert_eq!(
507 order_labels(SortOrder::NameDesc),
508 vec!["Gamma", "Beta", "Alpha"],
509 "reverse alphabetical"
510 );
511 }
512
513 #[test]
514 fn balance_chart_handles_multi_currency() {
515 let rows = vec![
516 row("Cash", 0, vec![amt("USD", 100)]),
517 row("Bank", 0, vec![amt("EUR", 200), amt("USD", 50)]),
518 ];
519
520 let spec = balance_chart(
521 &rows,
522 BalanceChartOpts {
523 kind: ChartKind::Bar,
524 top_n: 10,
525 sort_order: SortOrder::MagnitudeDesc,
526 },
527 );
528
529 assert_eq!(spec.series.len(), 2, "one series per account");
533 assert_eq!(spec.y_label, "Amount (USD)");
534 let cash = spec.series.iter().find(|s| s.label == "Cash").unwrap();
535 let bank = spec.series.iter().find(|s| s.label == "Bank").unwrap();
536 assert_eq!(cash.points[0].y_num, 100);
537 assert_eq!(bank.points[0].y_num, 50);
538 assert!(
539 spec.notes
540 .iter()
541 .any(|n| n.contains("EUR") && n.contains("hidden")),
542 "notes mention the elided commodity"
543 );
544 }
545
546 #[test]
547 fn balance_chart_empty_rows_is_graceful() {
548 let spec = balance_chart(
549 &[],
550 BalanceChartOpts {
551 kind: ChartKind::Bar,
552 top_n: 5,
553 sort_order: SortOrder::MagnitudeDesc,
554 },
555 );
556 assert!(spec.series.is_empty());
557 assert!(spec.notes.iter().any(|n| n.contains("No top-level")));
558 }
559
560 #[test]
563 fn activity_chart_emits_series_per_group_and_optional_net() {
564 let periods = vec![
565 PeriodActivityView {
566 label: "2026-01".to_string(),
567 groups: vec![
568 GroupView {
569 label: "Income".to_string(),
570 flip_sign: true,
571 rows: vec![],
572 total: vec![amt("USD", 3200)],
573 },
574 GroupView {
575 label: "Expense".to_string(),
576 flip_sign: false,
577 rows: vec![],
578 total: vec![amt("USD", 2100)],
579 },
580 ],
581 net: vec![amt("USD", 1100)],
582 },
583 PeriodActivityView {
584 label: "2026-02".to_string(),
585 groups: vec![
586 GroupView {
587 label: "Income".to_string(),
588 flip_sign: true,
589 rows: vec![],
590 total: vec![amt("USD", 2800)],
591 },
592 GroupView {
593 label: "Expense".to_string(),
594 flip_sign: false,
595 rows: vec![],
596 total: vec![amt("USD", 1900)],
597 },
598 ],
599 net: vec![amt("USD", 900)],
600 },
601 ];
602
603 let spec = activity_chart(
604 &periods,
605 ActivityChartOpts {
606 kind: ChartKind::StackedBar,
607 include_net: true,
608 },
609 );
610
611 assert_eq!(spec.series.len(), 3, "Income, Expense, Net — three series");
612 let income = spec.series.iter().find(|s| s.label == "Income").unwrap();
613 let xs: Vec<&str> = income.points.iter().map(|p| p.x.as_str()).collect();
614 assert_eq!(xs, vec!["2026-01", "2026-02"]);
615 let ys: Vec<i64> = income.points.iter().map(|p| p.y_num).collect();
616 assert_eq!(ys, vec![3200, 2800]);
617
618 let net = spec.series.iter().find(|s| s.label == "Net").unwrap();
619 assert_eq!(net.points[0].y_num, 1100);
620 assert_eq!(net.points[1].y_num, 900);
621 }
622
623 #[test]
624 fn activity_chart_without_net_omits_the_net_series() {
625 let periods = vec![PeriodActivityView {
626 label: "2026-04".to_string(),
627 groups: vec![GroupView {
628 label: "Income".to_string(),
629 flip_sign: true,
630 rows: vec![],
631 total: vec![amt("USD", 1000)],
632 }],
633 net: vec![amt("USD", 1000)],
634 }];
635
636 let spec = activity_chart(
637 &periods,
638 ActivityChartOpts {
639 kind: ChartKind::Bar,
640 include_net: false,
641 },
642 );
643
644 assert!(spec.series.iter().all(|s| s.label != "Net"));
645 }
646
647 #[test]
648 fn activity_chart_multi_currency_notes_omission() {
649 let periods = vec![PeriodActivityView {
650 label: "2026-04".to_string(),
651 groups: vec![GroupView {
652 label: "Income".to_string(),
653 flip_sign: true,
654 rows: vec![],
655 total: vec![amt("USD", 100), amt("EUR", 80)],
656 }],
657 net: vec![amt("USD", 100), amt("EUR", 80)],
658 }];
659
660 let spec = activity_chart(
661 &periods,
662 ActivityChartOpts {
663 kind: ChartKind::Bar,
664 include_net: false,
665 },
666 );
667
668 assert_eq!(spec.series[0].commodity_symbol, "USD");
669 assert!(
670 spec.notes
671 .iter()
672 .any(|n| n.contains("EUR") && n.contains("hidden")),
673 "notes mention the elided commodity"
674 );
675 }
676
677 fn brow(tag: &str, amounts: Vec<AmountView>) -> BreakdownRowView {
680 BreakdownRowView {
681 tag_value: tag.to_string(),
682 is_uncategorized: false,
683 amounts,
684 }
685 }
686
687 #[test]
688 fn breakdown_chart_flat_shape_single_series() {
689 let periods = vec![BreakdownPeriodView {
690 label: String::new(),
691 rows: vec![
692 brow("food", vec![amt("USD", 400)]),
693 brow("transport", vec![amt("USD", 150)]),
694 brow("rent", vec![amt("USD", 1200)]),
695 ],
696 }];
697
698 let spec = breakdown_chart(
699 &periods,
700 BreakdownChartOpts {
701 kind: ChartKind::Bar,
702 top_n: 10,
703 },
704 );
705
706 assert_eq!(spec.series.len(), 3, "one series per category");
709 let labels: Vec<&str> = spec.series.iter().map(|s| s.label.as_str()).collect();
710 assert_eq!(labels, ["rent", "food", "transport"]);
711 let rent = &spec.series[0];
712 assert_eq!(rent.points.len(), 1, "single point per flat series");
713 assert_eq!(rent.points[0].y_num, 1200);
714 assert_eq!(spec.x_label, "Category");
715 }
716
717 #[test]
718 fn breakdown_chart_period_shape_one_series_per_category() {
719 let periods = vec![
720 BreakdownPeriodView {
721 label: "2026-01".to_string(),
722 rows: vec![
723 brow("food", vec![amt("USD", 300)]),
724 brow("transport", vec![amt("USD", 100)]),
725 ],
726 },
727 BreakdownPeriodView {
728 label: "2026-02".to_string(),
729 rows: vec![brow("food", vec![amt("USD", 350)])],
730 },
731 ];
732
733 let spec = breakdown_chart(
734 &periods,
735 BreakdownChartOpts {
736 kind: ChartKind::Line,
737 top_n: 5,
738 },
739 );
740
741 assert_eq!(spec.series.len(), 2, "food + transport");
742 let food = spec.series.iter().find(|s| s.label == "food").unwrap();
743 let transport = spec.series.iter().find(|s| s.label == "transport").unwrap();
744 assert_eq!(food.points[0].y_num, 300);
745 assert_eq!(food.points[1].y_num, 350);
746 assert_eq!(transport.points[0].y_num, 100);
747 assert_eq!(
748 transport.points[1].y_num, 0,
749 "missing periods fill with zero"
750 );
751 assert_eq!(spec.x_label, "Period");
752 }
753
754 #[test]
755 fn breakdown_chart_clips_to_top_n_and_notes() {
756 let periods = vec![BreakdownPeriodView {
757 label: String::new(),
758 rows: vec![
759 brow("a", vec![amt("USD", 100)]),
760 brow("b", vec![amt("USD", 200)]),
761 brow("c", vec![amt("USD", 300)]),
762 brow("d", vec![amt("USD", 400)]),
763 ],
764 }];
765
766 let spec = breakdown_chart(
767 &periods,
768 BreakdownChartOpts {
769 kind: ChartKind::Bar,
770 top_n: 2,
771 },
772 );
773
774 assert_eq!(spec.series.len(), 2);
776 assert!(spec.notes.iter().any(|n| n.contains("top 2")));
777 }
778
779 #[test]
780 fn breakdown_chart_flat_relabels_uncategorized_sentinel() {
781 let periods = vec![BreakdownPeriodView {
782 label: String::new(),
783 rows: vec![
784 brow("food", vec![amt("USD", 400)]),
785 BreakdownRowView {
786 tag_value: "__uncategorized__".to_string(),
787 is_uncategorized: true,
788 amounts: vec![amt("USD", 50)],
789 },
790 ],
791 }];
792
793 let spec = breakdown_chart(
794 &periods,
795 BreakdownChartOpts {
796 kind: ChartKind::Bar,
797 top_n: 10,
798 },
799 );
800
801 let uncategorized = spec
802 .series
803 .iter()
804 .find(|s| s.label == "(uncategorized)")
805 .expect("pretty label replaces sentinel");
806 assert_eq!(uncategorized.points[0].x, "(uncategorized)");
807 assert!(
808 !spec.series.iter().any(|s| s.label == "__uncategorized__"),
809 "sentinel must not leak into the chart"
810 );
811 }
812}