Skip to main content

plotting/
adapters.rs

1//! Turn view projections into chart specs. Adapters are pure functions
2//! that handle top-N clipping, multi-currency scaling decisions, and
3//! layout choices (x-axis assignment, series arrangement) so renderers
4//! only have to draw.
5
6use 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
15// ---------- shared helpers ----------
16
17fn 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
36/// Pick the commodity to plot. When the report already targeted a single
37/// commodity there's usually only one symbol; otherwise the adapter
38/// picks the one that first appears and appends a note listing the
39/// elided symbols so the display can warn the user.
40fn 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
60/// Sort ranked `(row, magnitude)` pairs in place according to the
61/// requested order. Uses the balance adapter's data shape — callers
62/// that need this for other adapters can swap the row type via the
63/// tuple's first field; the function is generic in the row ref.
64fn 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// ---------- Balance ----------
92
93/// Sort key applied to a ranked set of rows. Shared by the balance
94/// adapter and (via the web handler) the balance table rendering —
95/// keeping them on one enum means the chart can't drift from the
96/// table ordering.
97#[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/// Balance chart: one series per commodity, x = top-level account names.
113/// When more than one commodity is present and the `top_n` most-active
114/// accounts span several commodities, we still render all bars — each
115/// series places a bar in the x-slot for its account and zero elsewhere,
116/// so a mixed-currency report reads as a grouped / stacked bar chart.
117#[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    // Gather commodity symbols in insertion order, then pick the
133    // primary for plotting. A mixed-currency balance notes the elided
134    // commodities rather than silently mashing them together.
135    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    // Rank accounts by the primary commodity's absolute amount and
146    // then apply the caller's sort choice. Keeping magnitude as the
147    // secondary key means name-sorted charts still break ties
148    // sensibly.
149    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    // One series per account so each bar reads as its own colour and
161    // the legend names the account. Each series holds a single point
162    // anchored at the account name.
163    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
190// ---------- Activity ----------
191
192pub struct ActivityChartOpts {
193    pub kind: ChartKind,
194    pub include_net: bool,
195}
196
197/// Activity chart: x = period label, one series per group label, plus
198/// an optional Net series. Picks a single commodity when multiple are
199/// present and notes the omission.
200#[must_use]
201pub fn activity_chart(periods: &[PeriodActivityView], opts: ActivityChartOpts) -> ChartSpec {
202    // Pass 1: distinct commodities across every group total.
203    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    // Pass 2: group labels in insertion order.
221    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
278// ---------- Category Breakdown ----------
279
280pub struct BreakdownChartOpts {
281    pub kind: ChartKind,
282    pub top_n: usize,
283}
284
285/// Breakdown chart. Two shapes depending on whether the breakdown has
286/// period grouping:
287///
288/// - **With periods** (len > 1, or the single label is non-empty):
289///   x = period label, one series per top-N tag value.
290/// - **Flat** (single period, empty label — meaning no `period_grouping`):
291///   x = tag value, a single series.
292#[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    // Pull the uncategorized flag off each tag-value up front so flat
320    // and period shapes can relabel "__uncategorized__" consistently.
321    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    // Accumulate per-tag totals (for top-N ranking).
332    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    // Flat shape: one series per category so each bar reads as a
366    // distinct colour. Each series holds a single point anchored at
367    // its own tag name. Period shape: one series per category across
368    // period x-slots.
369    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    // ---- Balance ----
438
439    #[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)]), // depth 1 — ignored
446        ];
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        // One series per account so each bar gets its own palette
458        // colour. Account order matches absolute-amount ranking.
459        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        // Mixed currency picks the primary (USD, the first-seen),
530        // notes the elided one, and charts account bars using the
531        // primary's amount.
532        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    // ---- Activity ----
561
562    #[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    // ---- Breakdown ----
678
679    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        // Flat shape emits one series per category so bars read as
707        // distinct colours. Order matches the absolute-amount ranking.
708        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        // Top-2 clip leaves two categories, each in its own series.
775        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}