1
//! Chart intermediate representation.
2
//!
3
//! This is the one contract that adapters, renderers (SVG, canvas,
4
//! ratatui), and the JSON wire format all share. Serialises cleanly so
5
//! the WASM canvas renderer can consume the same bytes the server
6
//! produces.
7

            
8
use serde::{Deserialize, Serialize};
9

            
10
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11
#[serde(rename_all = "lowercase")]
12
pub enum ChartKind {
13
    Bar,
14
    StackedBar,
15
    Line,
16
}
17

            
18
/// One x/y datum. `y` is carried as a `num`/`denom` pair so the exact
19
/// Rational64 crosses the wire without float drift; each renderer
20
/// converts to `f64` for display.
21
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22
pub struct SeriesPoint {
23
    pub x: String,
24
    pub y_num: i64,
25
    pub y_denom: i64,
26
}
27

            
28
impl SeriesPoint {
29
    /// Best-effort conversion to an `f64` for the renderer. A zero
30
    /// denominator falls back to 0.0 rather than panicking — an
31
    /// invariant violation in the IR shouldn't take down the renderer.
32
    #[must_use]
33
72
    pub fn y_f64(&self) -> f64 {
34
72
        if self.y_denom == 0 {
35
1
            return 0.0;
36
71
        }
37
71
        self.y_num as f64 / self.y_denom as f64
38
72
    }
39
}
40

            
41
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42
pub struct Series {
43
    pub label: String,
44
    /// Commodity symbol (e.g. "USD"). Empty when the series is unitless.
45
    pub commodity_symbol: String,
46
    pub points: Vec<SeriesPoint>,
47
}
48

            
49
/// A chart the server has already prepared. Renderers read this
50
/// directly; none of them compute totals, pick top-N, or negate
51
/// signs. All of that lives in the adapters.
52
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53
pub struct ChartSpec {
54
    pub title: String,
55
    pub kind: ChartKind,
56
    pub x_label: String,
57
    pub y_label: String,
58
    pub series: Vec<Series>,
59
    /// Display-time notes (e.g. "Showing USD; 2 other commodities
60
    /// hidden"). Rendered as an info line under the chart.
61
    pub notes: Vec<String>,
62
}
63

            
64
#[cfg(test)]
65
mod tests {
66
    use super::*;
67

            
68
1
    fn sample() -> ChartSpec {
69
1
        ChartSpec {
70
1
            title: "Income vs Expense".to_string(),
71
1
            kind: ChartKind::StackedBar,
72
1
            x_label: "Month".to_string(),
73
1
            y_label: "Amount".to_string(),
74
1
            series: vec![
75
1
                Series {
76
1
                    label: "Income".to_string(),
77
1
                    commodity_symbol: "USD".to_string(),
78
1
                    points: vec![
79
1
                        SeriesPoint {
80
1
                            x: "2026-01".to_string(),
81
1
                            y_num: 3200,
82
1
                            y_denom: 1,
83
1
                        },
84
1
                        SeriesPoint {
85
1
                            x: "2026-02".to_string(),
86
1
                            y_num: 2800,
87
1
                            y_denom: 1,
88
1
                        },
89
1
                    ],
90
1
                },
91
1
                Series {
92
1
                    label: "Expense".to_string(),
93
1
                    commodity_symbol: "USD".to_string(),
94
1
                    points: vec![
95
1
                        SeriesPoint {
96
1
                            x: "2026-01".to_string(),
97
1
                            y_num: 2100,
98
1
                            y_denom: 1,
99
1
                        },
100
1
                        SeriesPoint {
101
1
                            x: "2026-02".to_string(),
102
1
                            y_num: 1900,
103
1
                            y_denom: 1,
104
1
                        },
105
1
                    ],
106
1
                },
107
1
            ],
108
1
            notes: vec!["Showing USD only; 1 other commodity hidden".to_string()],
109
1
        }
110
1
    }
111

            
112
    #[test]
113
1
    fn spec_round_trips_through_json() {
114
1
        let original = sample();
115
1
        let encoded = serde_json::to_string(&original).expect("serialise");
116
1
        let decoded: ChartSpec = serde_json::from_str(&encoded).expect("deserialise");
117
1
        assert_eq!(decoded, original, "wire format must be lossless");
118
1
    }
119

            
120
    #[test]
121
1
    fn chart_kind_serialises_as_lowercase() {
122
1
        let s = serde_json::to_string(&ChartKind::StackedBar).expect("serialise");
123
1
        assert_eq!(s, "\"stackedbar\"");
124
1
    }
125

            
126
    #[test]
127
1
    fn series_point_converts_to_f64() {
128
1
        let p = SeriesPoint {
129
1
            x: "x".to_string(),
130
1
            y_num: 3,
131
1
            y_denom: 2,
132
1
        };
133
1
        assert!((p.y_f64() - 1.5).abs() < f64::EPSILON);
134
1
    }
135

            
136
    #[test]
137
1
    fn series_point_zero_denominator_is_safe() {
138
1
        let p = SeriesPoint {
139
1
            x: "x".to_string(),
140
1
            y_num: 42,
141
1
            y_denom: 0,
142
1
        };
143
1
        assert_eq!(p.y_f64(), 0.0);
144
1
    }
145
}