Skip to main content

tui/tabs/
reports.rs

1//! Reports tab: balance / activity / category-breakdown.
2//!
3//! `ReportsTab` holds the current fetch state and the last successful
4//! `ChartSpec`.  `on_reply` translates a wire frame into a spec via the
5//! shared `cli_core::reports` parse helpers and the `plotting::adapters`
6//! chain.  `draw` renders the spec inline with `plotting::ratatui`.
7
8use cli_core::render::{WireValue, parse_wire};
9use cli_core::reports::{
10    parse_chart_shape, value_to_activity_periods, value_to_balance_rows, value_to_breakdown_periods,
11};
12use plotting::{
13    ChartSpec,
14    adapters::{
15        ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
16        balance_chart, breakdown_chart,
17    },
18    ratatui::render_ratatui,
19};
20use ratatui::Frame;
21use ratatui::layout::Rect;
22use ratatui::widgets::{Block, Borders, Paragraph};
23
24use crate::tabs::fetch::Fetch;
25
26/// Which report is currently loaded or being fetched.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ReportKind {
29    Balance,
30    Activity,
31    Breakdown,
32}
33
34impl ReportKind {
35    #[must_use]
36    pub fn label(self) -> &'static str {
37        match self {
38            ReportKind::Balance => "Balance",
39            ReportKind::Activity => "Activity",
40            ReportKind::Breakdown => "Category Breakdown",
41        }
42    }
43}
44
45pub struct ReportsTab {
46    pub state: Fetch<ChartSpec>,
47    /// Which report produced the current (or pending) fetch.
48    pub kind: ReportKind,
49    /// Chart shape used for the last build.
50    pub chart: String,
51}
52
53impl ReportsTab {
54    #[must_use]
55    pub fn new() -> Self {
56        Self {
57            state: Fetch::Idle,
58            kind: ReportKind::Balance,
59            chart: "bar".to_string(),
60        }
61    }
62
63    /// Set loading state once an eval id is assigned.
64    pub fn set_loading(&mut self, id: i64, kind: ReportKind, chart: impl Into<String>) {
65        self.kind = kind;
66        self.chart = chart.into();
67        self.state = Fetch::Loading { id };
68    }
69
70    /// Process a routed wire reply and update the fetch state.
71    /// Apply a routed reply, parsing it with the `kind`/`chart` that produced
72    /// the request (carried on the route), not the tab's current selection —
73    /// the two can differ if a newer request was issued before this reply.
74    pub fn on_reply(&mut self, wire: &str, kind: ReportKind, chart: &str) {
75        self.kind = kind;
76        self.chart = chart.to_string();
77        self.state = parse_report_reply(wire, kind, chart);
78    }
79
80    /// Reset to idle so the next invocation re-fetches.
81    pub fn reset(&mut self) {
82        self.state = Fetch::Idle;
83    }
84
85    /// Render the tab into `area`.
86    pub fn draw(&self, frame: &mut Frame, area: Rect) {
87        let block = Block::default()
88            .borders(Borders::ALL)
89            .title(self.kind.label());
90        match &self.state {
91            Fetch::Idle => {
92                let msg = "Use :reports balance | :reports activity from=YYYY-MM-DD to=YYYY-MM-DD | :reports breakdown from=... to=...";
93                frame.render_widget(Paragraph::new(msg).block(block), area);
94            }
95            Fetch::Loading { .. } => {
96                frame.render_widget(Paragraph::new("Loading...").block(block), area);
97            }
98            Fetch::Error(e) => {
99                let msg = format!("[error] {e}");
100                frame.render_widget(Paragraph::new(msg).block(block), area);
101            }
102            Fetch::Loaded(spec) => {
103                let inner = block.inner(area);
104                frame.render_widget(block, area);
105                let chart = render_ratatui(spec);
106                chart.draw(frame, inner);
107            }
108        }
109    }
110}
111
112impl Default for ReportsTab {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118/// Parse a wire reply frame into a `Fetch<ChartSpec>` for the given kind.
119fn parse_report_reply(wire: &str, kind: ReportKind, chart: &str) -> Fetch<ChartSpec> {
120    match parse_wire(wire) {
121        Err(e) => Fetch::Error(e.to_string()),
122        Ok(WireValue::Value(value)) => match build_chart_spec(&value, kind, chart) {
123            Ok(spec) => Fetch::Loaded(spec),
124            Err(e) => Fetch::Error(e),
125        },
126    }
127}
128
129fn build_chart_spec(
130    value: &scripting::nomiscript::Value,
131    kind: ReportKind,
132    chart: &str,
133) -> Result<ChartSpec, String> {
134    let chart_kind = parse_chart_shape(chart);
135    match kind {
136        ReportKind::Balance => {
137            let rows = value_to_balance_rows(value).map_err(|e| e.to_string())?;
138            Ok(balance_chart(
139                &rows,
140                BalanceChartOpts {
141                    kind: chart_kind,
142                    top_n: 10,
143                    sort_order: SortOrder::MagnitudeDesc,
144                },
145            ))
146        }
147        ReportKind::Activity => {
148            let periods = value_to_activity_periods(value).map_err(|e| e.to_string())?;
149            Ok(activity_chart(
150                &periods,
151                ActivityChartOpts {
152                    kind: chart_kind,
153                    include_net: true,
154                },
155            ))
156        }
157        ReportKind::Breakdown => {
158            let periods = value_to_breakdown_periods(value).map_err(|e| e.to_string())?;
159            Ok(breakdown_chart(
160                &periods,
161                BreakdownChartOpts {
162                    kind: chart_kind,
163                    top_n: 10,
164                },
165            ))
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests;