Skip to main content

web/pages/report/
mod.rs

1use axum::http::HeaderMap;
2use chrono::{DateTime, Datelike, Utc};
3use num_rational::Rational64;
4use serde::{Deserialize, Deserializer};
5use server::command::{
6    CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
7};
8use sqlx::types::{Uuid, chrono::NaiveDate};
9
10pub mod activity;
11pub mod balance;
12pub mod category_breakdown;
13
14use server::command::report::view;
15use view::AmountView;
16
17pub struct CommodityOption {
18    pub id: Uuid,
19    pub symbol: String,
20    pub name: String,
21}
22
23/// A single card in the top-of-report summary strip. `is_net` flags cards
24/// that show a signed net (e.g. Income − Expense) so templates can
25/// style them distinctly; `highlight` is a generic accent slot.
26pub struct SummaryCard {
27    pub label: String,
28    pub amounts: Vec<AmountView>,
29    pub is_net: bool,
30    pub highlight: bool,
31}
32
33/// Query-string fields shared by every report page to drive client-side
34/// table controls. The server only needs to read `commodity_columns` (which
35/// drives a template pivot) and forward `collapsed_depth` into the template
36/// for initial fold state; `sort_by` is consumed purely by the frontend
37/// WASM reorder logic, so the Rust handler does not parse it.
38#[derive(Default, Deserialize)]
39pub struct TableControlParams {
40    #[serde(default, deserialize_with = "empty_string_as_none")]
41    pub collapsed_depth: Option<String>,
42    #[serde(default, deserialize_with = "empty_string_as_none")]
43    pub commodity_columns: Option<String>,
44}
45
46impl TableControlParams {
47    #[must_use]
48    pub fn commodity_columns_enabled(&self) -> bool {
49        self.commodity_columns
50            .as_deref()
51            .map(str::trim)
52            .is_some_and(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"))
53    }
54}
55
56/// Chart-specific query parameters shared across report chart handlers.
57/// `chart_kind` maps to `plotting::ChartKind`; `chart_series` is a
58/// report-specific toggle (e.g. Activity: show Net or not) parsed in
59/// each handler. `renderer` picks between server-side SVG (default)
60/// and client-side canvas.
61#[derive(Default, Deserialize)]
62pub struct ChartParams {
63    #[serde(default, deserialize_with = "empty_string_as_none")]
64    pub chart_kind: Option<String>,
65    #[serde(default, deserialize_with = "empty_string_as_none")]
66    pub chart_series: Option<String>,
67    #[serde(default, deserialize_with = "empty_string_as_none")]
68    pub renderer: Option<String>,
69}
70
71impl ChartParams {
72    /// Parse `chart_kind` (`bar`, `line`, `stacked`) into a
73    /// `plotting::ChartKind`. Unknown / missing values fall back to
74    /// `Bar` so a stale URL still renders something.
75    #[must_use]
76    pub fn chart_kind_or_default(&self) -> plotting::ChartKind {
77        match self.chart_kind.as_deref().map(str::to_ascii_lowercase) {
78            Some(ref s) if s == "line" => plotting::ChartKind::Line,
79            Some(ref s) if s == "stacked" || s == "stackedbar" => plotting::ChartKind::StackedBar,
80            _ => plotting::ChartKind::Bar,
81        }
82    }
83
84    /// Normalise the chart kind to its canonical lowercase name so
85    /// the template can round-trip it into URLs and `<select>`
86    /// values.
87    #[must_use]
88    pub fn chart_kind_str(&self) -> String {
89        match self.chart_kind_or_default() {
90            plotting::ChartKind::Bar => "bar".to_string(),
91            plotting::ChartKind::StackedBar => "stacked".to_string(),
92            plotting::ChartKind::Line => "line".to_string(),
93        }
94    }
95
96    /// Canonicalise the renderer choice. Accepts `svg` (default) and
97    /// `canvas`; anything else normalises to `svg`.
98    #[must_use]
99    pub fn renderer_str(&self) -> String {
100        match self.renderer.as_deref().map(str::to_ascii_lowercase) {
101            Some(ref s) if s == "canvas" => "canvas".to_string(),
102            _ => "svg".to_string(),
103        }
104    }
105}
106
107/// Minimal URL-query encoder for the (key, value) pairs the chart
108/// endpoints take. Avoids pulling in `serde_urlencoded` just to
109/// stringify a handful of params — keeps the handler simple.
110#[must_use]
111pub fn encode_query(pairs: &[(&str, &str)]) -> String {
112    let mut out = String::new();
113    for (k, v) in pairs {
114        if v.is_empty() {
115            continue;
116        }
117        if !out.is_empty() {
118            out.push('&');
119        }
120        out.push_str(&percent_encode(k));
121        out.push('=');
122        out.push_str(&percent_encode(v));
123    }
124    if out.is_empty() {
125        out
126    } else {
127        format!("?{out}")
128    }
129}
130
131fn percent_encode(s: &str) -> String {
132    let mut out = String::with_capacity(s.len());
133    for b in s.bytes() {
134        let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
135        if keep {
136            out.push(b as char);
137        } else {
138            out.push_str(&format!("%{b:02X}"));
139        }
140    }
141    out
142}
143
144pub async fn load_commodities(user_id: Uuid) -> Vec<CommodityOption> {
145    let Ok(Some(CmdResult::TaggedEntities { entities, .. })) =
146        ListCommodities::new().user_id(user_id).run().await
147    else {
148        return Vec::new();
149    };
150
151    let mut commodities = Vec::new();
152    for (entity, tags) in entities {
153        if let FinanceEntity::Commodity(commodity) = entity
154            && let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) = (&tags["symbol"], &tags["name"])
155        {
156            commodities.push(CommodityOption {
157                id: commodity.id,
158                symbol: s.tag_value.clone(),
159                name: n.tag_value.clone(),
160            });
161        }
162    }
163    commodities
164}
165
166/// Parse a `YYYY-MM-DD` string into UTC. `end_of_day` controls whether the
167/// resulting instant sits at 00:00 (range start) or 23:59:59 (range end /
168/// as-of cutoff).
169#[must_use]
170pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
171    let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
172    let time = if end_of_day {
173        date.and_hms_opt(23, 59, 59)
174    } else {
175        date.and_hms_opt(0, 0, 0)
176    }?;
177    Some(time.and_utc())
178}
179
180#[must_use]
181pub fn wants_json(headers: &HeaderMap) -> bool {
182    headers
183        .get("accept")
184        .and_then(|v| v.to_str().ok())
185        .is_some_and(|v| v.contains("application/json"))
186}
187
188/// Sum top-level rows' amounts per commodity. Used by Balance to build its
189/// grand-total card(s).
190#[must_use]
191pub fn sum_top_level_amounts(rows: &[view::ReportRowView]) -> Vec<AmountView> {
192    let mut out: Vec<AmountView> = Vec::new();
193    for row in rows.iter().filter(|r| r.depth == 0) {
194        for a in &row.amounts {
195            match out
196                .iter_mut()
197                .find(|x| x.commodity_symbol == a.commodity_symbol)
198            {
199                Some(existing) => existing.amount += a.amount,
200                None => out.push(AmountView {
201                    commodity_symbol: a.commodity_symbol.clone(),
202                    amount: a.amount,
203                }),
204            }
205        }
206    }
207    out
208}
209
210/// Collect the set of commodity symbols present across all rows, in the
211/// order the row-data first mentions them. Drives the "Commodities as
212/// columns" pivot: a stable column ordering without depending on the
213/// user's commodity list.
214#[must_use]
215pub fn commodity_symbols_in_rows(rows: &[view::ReportRowView]) -> Vec<String> {
216    let mut seen = Vec::new();
217    for row in rows {
218        for a in &row.amounts {
219            if !seen.iter().any(|s: &String| s == &a.commodity_symbol) {
220                seen.push(a.commodity_symbol.clone());
221            }
222        }
223    }
224    seen
225}
226
227/// Look up a row's amount for the given commodity symbol. Called from the
228/// commodity-columns branch of the tree-table template.
229#[must_use]
230pub fn row_amount_by_symbol(row: &view::ReportRowView, symbol: &str) -> Option<Rational64> {
231    row.amounts
232        .iter()
233        .find(|a| a.commodity_symbol == symbol)
234        .map(|a| a.amount)
235}
236
237/// Parse the shared `sort_order` query param. Balance and Breakdown
238/// accept the same names; this keeps the URLs consistent between
239/// them. Unknown values fall back to `AmountDesc` so a stale URL
240/// still renders.
241#[must_use]
242pub fn parse_sort_order_shared(raw: Option<&str>) -> SharedSort {
243    match raw.map(str::to_ascii_lowercase).as_deref() {
244        Some("amount_asc") => SharedSort::AmountAsc,
245        Some("name_asc") => SharedSort::NameAsc,
246        Some("name_desc") => SharedSort::NameDesc,
247        _ => SharedSort::AmountDesc,
248    }
249}
250
251#[derive(Debug, Clone, Copy, Default)]
252pub enum SharedSort {
253    #[default]
254    AmountDesc,
255    AmountAsc,
256    NameAsc,
257    NameDesc,
258}
259
260impl SharedSort {
261    #[must_use]
262    pub fn to_str(self) -> &'static str {
263        match self {
264            Self::AmountDesc => "amount_desc",
265            Self::AmountAsc => "amount_asc",
266            Self::NameAsc => "name_asc",
267            Self::NameDesc => "name_desc",
268        }
269    }
270
271    /// Map to the plotting crate's balance-chart sort enum.
272    #[must_use]
273    pub fn into_plotting_balance(self) -> plotting::adapters::SortOrder {
274        match self {
275            Self::AmountDesc => plotting::adapters::SortOrder::MagnitudeDesc,
276            Self::AmountAsc => plotting::adapters::SortOrder::MagnitudeAsc,
277            Self::NameAsc => plotting::adapters::SortOrder::NameAsc,
278            Self::NameDesc => plotting::adapters::SortOrder::NameDesc,
279        }
280    }
281}
282
283fn abs_rational(r: Rational64) -> Rational64 {
284    if r < Rational64::new(0, 1) { -r } else { r }
285}
286
287/// Sort top-level rows in place according to `order`. Children ride
288/// with their parent — they're adjacent in the flattened view and we
289/// preserve that adjacency by sorting on a key derived from each
290/// top-level row alone.
291pub fn sort_top_level_rows(rows: &mut Vec<view::ReportRowView>, order: SharedSort) {
292    // Group rows by their top-level ancestor. Flat projection is DFS
293    // order: every depth-0 row is followed by its subtree before the
294    // next depth-0 row, so a single pass builds the groups.
295    let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
296    for row in std::mem::take(rows) {
297        if row.depth == 0 || groups.is_empty() {
298            groups.push(vec![row]);
299        } else if let Some(last) = groups.last_mut() {
300            last.push(row);
301        }
302    }
303
304    groups.sort_by(|a, b| {
305        let (a_root, b_root) = (&a[0], &b[0]);
306        match order {
307            SharedSort::AmountDesc => {
308                let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
309                let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
310                kb.cmp(&ka)
311            }
312            SharedSort::AmountAsc => {
313                let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
314                let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
315                ka.cmp(&kb)
316            }
317            SharedSort::NameAsc => a_root.account_name.cmp(&b_root.account_name),
318            SharedSort::NameDesc => b_root.account_name.cmp(&a_root.account_name),
319        }
320    });
321
322    for group in groups {
323        rows.extend(group);
324    }
325}
326
327#[cfg(test)]
328mod sort_tests {
329    use super::*;
330    use num_rational::Rational64;
331    use view::{AmountView, ReportRowView};
332
333    fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
334        let id = Uuid::new_v4();
335        ReportRowView {
336            account_id: id,
337            parent_id: parent,
338            account_name: name.to_string(),
339            depth,
340            has_children: false,
341            amounts: vec![AmountView {
342                commodity_symbol: "USD".to_string(),
343                amount: Rational64::new(amount, 1),
344            }],
345        }
346    }
347
348    #[test]
349    fn sort_keeps_children_with_parents() {
350        let parent_b = mk_row("Bank", 0, 500, None);
351        let parent_b_id = parent_b.account_id;
352        let parent_a = mk_row("Assets", 0, 1000, None);
353        let parent_c = mk_row("Cash", 0, 200, None);
354        let mut rows = vec![
355            parent_b,
356            mk_row("Checking", 1, 300, Some(parent_b_id)),
357            mk_row("Savings", 1, 200, Some(parent_b_id)),
358            parent_a,
359            parent_c,
360        ];
361
362        sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
363        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
364        assert_eq!(
365            names,
366            vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
367            "Assets first (1000), Bank + children next (500), Cash last (200)"
368        );
369    }
370
371    #[test]
372    fn sort_by_name_ascending() {
373        let mut rows = vec![
374            mk_row("Zulu", 0, 1, None),
375            mk_row("Alpha", 0, 2, None),
376            mk_row("Mike", 0, 3, None),
377        ];
378        sort_top_level_rows(&mut rows, SharedSort::NameAsc);
379        let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
380        assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
381    }
382}
383
384/// Breakdown equivalent of `row_amount_by_symbol`. Category Breakdown uses
385/// a flat row type (`BreakdownRowView`) rather than the tree `ReportRowView`.
386#[must_use]
387pub fn breakdown_row_amount_by_symbol(
388    row: &view::BreakdownRowView,
389    symbol: &str,
390) -> Option<Rational64> {
391    row.amounts
392        .iter()
393        .find(|a| a.commodity_symbol == symbol)
394        .map(|a| a.amount)
395}
396
397pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
398where
399    D: Deserializer<'de>,
400{
401    let opt = Option::<String>::deserialize(deserializer)?;
402    Ok(opt.filter(|s| !s.is_empty()))
403}
404
405#[must_use]
406pub fn today_string() -> String {
407    Utc::now().date_naive().format("%Y-%m-%d").to_string()
408}
409
410#[must_use]
411pub fn month_start_string() -> String {
412    let now = Utc::now().date_naive();
413    NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
414        .unwrap_or(now)
415        .format("%Y-%m-%d")
416        .to_string()
417}
418
419#[derive(Deserialize)]
420#[serde(tag = "type")]
421enum FilterItem {
422    #[serde(rename = "tag")]
423    Tag {
424        entities: Vec<String>,
425        name: String,
426        values: Vec<String>,
427    },
428    #[serde(rename = "account")]
429    Account {
430        account_id: String,
431        #[serde(default, rename = "display_name")]
432        _display_name: String,
433        include_subtree: bool,
434    },
435    #[serde(rename = "group")]
436    Group {
437        logic: String,
438        items: Vec<FilterItem>,
439    },
440}
441
442#[derive(Deserialize)]
443struct FilterGroup {
444    logic: String,
445    items: Vec<FilterItem>,
446}
447
448fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
449    match values.len() {
450        1 => ReportFilter::Tag {
451            entity,
452            name: name.to_string(),
453            value: values[0].clone(),
454        },
455        _ => ReportFilter::TagIn {
456            entity,
457            name: name.to_string(),
458            values: values.to_vec(),
459        },
460    }
461}
462
463fn build_filter_item(item: &FilterItem) -> Option<ReportFilter> {
464    match item {
465        FilterItem::Tag {
466            entities,
467            name,
468            values,
469        } => {
470            if name.trim().is_empty() || values.is_empty() {
471                return None;
472            }
473            let filters: Vec<ReportFilter> = entities
474                .iter()
475                .filter_map(|e| {
476                    let entity = match e.as_str() {
477                        "account" => FilterEntity::Account,
478                        "transaction" => FilterEntity::Transaction,
479                        "split" => FilterEntity::Split,
480                        _ => return None,
481                    };
482                    Some(build_tag_entity_filter(entity, name, values))
483                })
484                .collect();
485            match filters.len() {
486                0 => None,
487                1 => filters.into_iter().next(),
488                _ => Some(ReportFilter::Or(filters)),
489            }
490        }
491        FilterItem::Account {
492            account_id,
493            include_subtree,
494            ..
495        } => {
496            let id = account_id.parse::<Uuid>().ok()?;
497            if *include_subtree {
498                Some(ReportFilter::AccountSubtree(id))
499            } else {
500                Some(ReportFilter::AccountEq(id))
501            }
502        }
503        FilterItem::Group { logic, items } => {
504            let filters: Vec<ReportFilter> = items.iter().filter_map(build_filter_item).collect();
505            combine_group(logic, filters)
506        }
507    }
508}
509
510/// Combine a group's built child filters by the group's declared logic.
511/// `not` requires exactly one child; anything else is ignored (returns
512/// `None`) so the rest of the report still runs. Users who want
513/// `Not(Or(...))` or `Not(And(...))` must nest the subgroup explicitly.
514fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
515    match logic {
516        "not" => match filters.len() {
517            1 => filters
518                .into_iter()
519                .next()
520                .map(|f| ReportFilter::Not(Box::new(f))),
521            _ => None,
522        },
523        "or" => match filters.len() {
524            0 => None,
525            1 => filters.into_iter().next(),
526            _ => Some(ReportFilter::Or(filters)),
527        },
528        _ => match filters.len() {
529            0 => None,
530            1 => filters.into_iter().next(),
531            _ => Some(ReportFilter::And(filters)),
532        },
533    }
534}
535
536fn build_filter_from_group(group: &FilterGroup) -> Option<ReportFilter> {
537    let filters: Vec<ReportFilter> = group.items.iter().filter_map(build_filter_item).collect();
538    combine_group(&group.logic, filters)
539}
540
541#[must_use]
542pub fn build_report_filter(
543    tag_filters: Option<&str>,
544    tag_filter_mode: Option<&str>,
545) -> Option<ReportFilter> {
546    let raw = tag_filters.filter(|s| !s.is_empty())?;
547
548    if tag_filter_mode == Some("script") {
549        return build_filter_from_sexpr(raw);
550    }
551
552    let group: FilterGroup = serde_json::from_str(raw).ok()?;
553    build_filter_from_group(&group)
554}
555
556#[cfg(feature = "scripting")]
557fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
558    ReportFilter::from_sexpr(raw).ok()
559}
560
561#[cfg(not(feature = "scripting"))]
562fn build_filter_from_sexpr(_raw: &str) -> Option<ReportFilter> {
563    None
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn tag_item(name: &str, value: &str) -> FilterItem {
571        FilterItem::Tag {
572            entities: vec!["transaction".to_owned()],
573            name: name.to_owned(),
574            values: vec![value.to_owned()],
575        }
576    }
577
578    #[test]
579    fn not_group_with_single_child_becomes_not() {
580        let group = FilterGroup {
581            logic: "not".to_owned(),
582            items: vec![tag_item("category", "food")],
583        };
584        let result = build_filter_from_group(&group).expect("filter built");
585        match result {
586            ReportFilter::Not(inner) => match *inner {
587                ReportFilter::Tag { name, value, .. } => {
588                    assert_eq!(name, "category");
589                    assert_eq!(value, "food");
590                }
591                other => panic!("expected inner Tag, got {other:?}"),
592            },
593            other => panic!("expected Not(Tag), got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn not_group_with_zero_children_is_dropped() {
599        let group = FilterGroup {
600            logic: "not".to_owned(),
601            items: vec![],
602        };
603        assert!(build_filter_from_group(&group).is_none());
604    }
605
606    #[test]
607    fn not_group_with_multiple_children_is_dropped() {
608        let group = FilterGroup {
609            logic: "not".to_owned(),
610            items: vec![tag_item("category", "food"), tag_item("category", "rent")],
611        };
612        assert!(
613            build_filter_from_group(&group).is_none(),
614            "multi-child NOT must be invalid; users nest explicitly"
615        );
616    }
617
618    #[test]
619    fn explicit_not_of_or_via_nesting() {
620        // Users who want Not(Or(food, rent)) nest an OR subgroup inside a NOT.
621        let group = FilterGroup {
622            logic: "not".to_owned(),
623            items: vec![FilterItem::Group {
624                logic: "or".to_owned(),
625                items: vec![tag_item("category", "food"), tag_item("category", "rent")],
626            }],
627        };
628        let result = build_filter_from_group(&group).expect("filter built");
629        let ReportFilter::Not(inner) = result else {
630            panic!("expected Not");
631        };
632        let ReportFilter::Or(children) = *inner else {
633            panic!("expected Not(Or(..))");
634        };
635        assert_eq!(children.len(), 2);
636    }
637
638    #[test]
639    fn and_group_unchanged() {
640        let group = FilterGroup {
641            logic: "and".to_owned(),
642            items: vec![tag_item("category", "food"), tag_item("project", "roof")],
643        };
644        let result = build_filter_from_group(&group).expect("filter built");
645        assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
646    }
647}