Skip to main content

web/pages/report/
category_breakdown.rs

1use std::sync::Arc;
2
3use askama::Template;
4use axum::Json;
5use axum::extract::Query;
6use axum::http::HeaderMap;
7use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
8use serde::Deserialize;
9use server::command::{
10    BreakdownData, BreakdownSort, CmdResult, PeriodGrouping, ReportMeta, report::CategoryBreakdown,
11};
12use sqlx::types::Uuid;
13
14use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
15
16use super::{
17    ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
18    empty_string_as_none, load_commodities, month_start_string, parse_date_bound, today_string,
19    wants_json,
20};
21use plotting::adapters::{BreakdownChartOpts, breakdown_chart};
22use server::command::report::view::{AmountView, BreakdownPeriodView, flatten_breakdown_data};
23
24#[derive(Template)]
25#[template(path = "pages/report/category_breakdown.html")]
26struct CategoryBreakdownReportPage;
27
28pub async fn category_breakdown_report_page() -> impl IntoResponse {
29    HtmlTemplate(CategoryBreakdownReportPage)
30}
31
32fn parse_period_grouping(s: &str) -> Option<PeriodGrouping> {
33    match s {
34        "month" => Some(PeriodGrouping::Month),
35        "quarter" => Some(PeriodGrouping::Quarter),
36        "year" => Some(PeriodGrouping::Year),
37        _ => None,
38    }
39}
40
41fn parse_sort_order(s: &str) -> Option<BreakdownSort> {
42    match s {
43        "amount_desc" => Some(BreakdownSort::AmountDesc),
44        "amount_asc" => Some(BreakdownSort::AmountAsc),
45        "name_asc" => Some(BreakdownSort::NameAsc),
46        "name_desc" => Some(BreakdownSort::NameDesc),
47        _ => None,
48    }
49}
50
51#[derive(Template)]
52#[template(path = "components/report/category_breakdown_table.html")]
53struct CategoryBreakdownTableTemplate {
54    commodities: Vec<CommodityOption>,
55    summary: Vec<SummaryCard>,
56    periods: Vec<BreakdownPeriodView>,
57    commodity_symbols: Vec<String>,
58    commodity_columns: bool,
59    tag_name: String,
60    date_from: Option<String>,
61    date_to: Option<String>,
62    target_commodity_id: Option<String>,
63    period_grouping: Option<String>,
64    sort_order: String,
65    include_uncategorized: bool,
66    tag_filters: String,
67    tag_filter_mode: String,
68    scripting_enabled: bool,
69    chart_kind: String,
70    chart_query: String,
71    renderer: String,
72}
73
74#[derive(Deserialize)]
75pub struct CategoryBreakdownParams {
76    #[serde(default, deserialize_with = "empty_string_as_none")]
77    date_from: Option<String>,
78    #[serde(default, deserialize_with = "empty_string_as_none")]
79    date_to: Option<String>,
80    #[serde(default, deserialize_with = "empty_string_as_none")]
81    target_commodity_id: Option<String>,
82    #[serde(default, deserialize_with = "empty_string_as_none")]
83    period_grouping: Option<String>,
84    #[serde(default, deserialize_with = "empty_string_as_none")]
85    tag_name: Option<String>,
86    #[serde(default, deserialize_with = "empty_string_as_none")]
87    sort_order: Option<String>,
88    #[serde(default)]
89    exclude_uncategorized: Option<String>,
90    #[serde(default, deserialize_with = "empty_string_as_none")]
91    tag_filters: Option<String>,
92    #[serde(default, deserialize_with = "empty_string_as_none")]
93    tag_filter_mode: Option<String>,
94    #[serde(flatten)]
95    table: TableControlParams,
96    #[serde(flatten)]
97    chart: ChartParams,
98}
99
100/// HTML checkboxes only submit a value when checked. `exclude_uncategorized`
101/// defaults to false so an absent parameter naturally reads as "include the
102/// bucket" and a checked box as "exclude it".
103fn params_include_uncategorized(exclude_raw: Option<&str>) -> bool {
104    let exclude = exclude_raw.is_some_and(|v| {
105        let v = v.trim();
106        v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
107    });
108    !exclude
109}
110
111fn push_amount(dest: &mut Vec<AmountView>, a: &AmountView) {
112    match dest
113        .iter_mut()
114        .find(|d| d.commodity_symbol == a.commodity_symbol)
115    {
116        Some(existing) => existing.amount += a.amount,
117        None => dest.push(a.clone()),
118    }
119}
120
121/// Build summary cards for Category Breakdown: one grand-total card per
122/// commodity plus up to `top_n` cards for the largest categories by
123/// absolute amount.
124fn breakdown_summary(periods: &[BreakdownPeriodView], top_n: usize) -> Vec<SummaryCard> {
125    let mut totals: Vec<AmountView> = Vec::new();
126    let mut per_category: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
127
128    for period in periods {
129        for row in &period.rows {
130            for a in &row.amounts {
131                push_amount(&mut totals, a);
132            }
133            match per_category
134                .iter_mut()
135                .find(|(name, _, _)| name == &row.tag_value)
136            {
137                Some((_, _, amts)) => {
138                    for a in &row.amounts {
139                        push_amount(amts, a);
140                    }
141                }
142                None => per_category.push((
143                    row.tag_value.clone(),
144                    row.is_uncategorized,
145                    row.amounts.clone(),
146                )),
147            }
148        }
149    }
150
151    let mut cards: Vec<SummaryCard> = totals
152        .into_iter()
153        .map(|a| SummaryCard {
154            label: format!("Total ({})", a.commodity_symbol),
155            amounts: vec![a],
156            is_net: false,
157            highlight: true,
158        })
159        .collect();
160
161    per_category.sort_by(|a, b| {
162        let key = |row: &(String, bool, Vec<AmountView>)| -> num_rational::Rational64 {
163            row.2
164                .iter()
165                .map(|x| {
166                    if x.amount < num_rational::Rational64::new(0, 1) {
167                        -x.amount
168                    } else {
169                        x.amount
170                    }
171                })
172                .sum()
173        };
174        key(b).cmp(&key(a))
175    });
176
177    for (name, is_uncat, amounts) in per_category.into_iter().take(top_n) {
178        let label = if is_uncat {
179            "(uncategorized)".to_string()
180        } else {
181            name
182        };
183        cards.push(SummaryCard {
184            label,
185            amounts,
186            is_net: false,
187            highlight: false,
188        });
189    }
190    cards
191}
192
193fn collect_commodity_symbols(periods: &[BreakdownPeriodView]) -> Vec<String> {
194    let mut seen: Vec<String> = Vec::new();
195    for period in periods {
196        for row in &period.rows {
197            for a in &row.amounts {
198                if !seen.iter().any(|s| s == &a.commodity_symbol) {
199                    seen.push(a.commodity_symbol.clone());
200                }
201            }
202        }
203    }
204    seen
205}
206
207pub async fn category_breakdown_report_table(
208    Query(params): Query<CategoryBreakdownParams>,
209    State(_data): State<Arc<AppState>>,
210    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
211    headers: HeaderMap,
212) -> Result<impl IntoResponse, StatusCode> {
213    let commodities = load_commodities(jwt_auth.user.id).await;
214
215    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
216    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
217
218    let date_from = parse_date_bound(&date_from_str, false);
219    let date_to = parse_date_bound(&date_to_str, true);
220
221    let tag_filter_mode = params
222        .tag_filter_mode
223        .clone()
224        .unwrap_or_else(|| "visual".to_string());
225
226    let tag_name_display = params
227        .tag_name
228        .clone()
229        .unwrap_or_else(|| "category".to_string());
230
231    let sort_order_raw = params
232        .sort_order
233        .clone()
234        .unwrap_or_else(|| "amount_desc".to_string());
235
236    let include_uncategorized =
237        params_include_uncategorized(params.exclude_uncategorized.as_deref());
238
239    let commodity_columns = params.table.commodity_columns_enabled();
240    let chart_kind = params.chart.chart_kind_str();
241    let renderer = params.chart.renderer_str();
242    let chart_query = super::encode_query(&[
243        ("date_from", date_from_str.as_str()),
244        ("date_to", date_to_str.as_str()),
245        (
246            "target_commodity_id",
247            params.target_commodity_id.as_deref().unwrap_or_default(),
248        ),
249        (
250            "period_grouping",
251            params.period_grouping.as_deref().unwrap_or_default(),
252        ),
253        ("tag_name", params.tag_name.as_deref().unwrap_or_default()),
254        ("sort_order", sort_order_raw.as_str()),
255        (
256            "exclude_uncategorized",
257            if include_uncategorized { "" } else { "on" },
258        ),
259        (
260            "tag_filters",
261            params.tag_filters.as_deref().unwrap_or_default(),
262        ),
263        ("tag_filter_mode", tag_filter_mode.as_str()),
264        ("chart_kind", chart_kind.as_str()),
265        ("renderer", renderer.as_str()),
266    ]);
267
268    let (Some(df), Some(dt)) = (date_from, date_to) else {
269        return Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
270            commodities,
271            summary: vec![],
272            periods: vec![],
273            commodity_symbols: vec![],
274            commodity_columns,
275            tag_name: tag_name_display,
276            date_from: Some(date_from_str),
277            date_to: Some(date_to_str),
278            target_commodity_id: params.target_commodity_id,
279            period_grouping: params.period_grouping,
280            sort_order: sort_order_raw,
281            include_uncategorized,
282            tag_filters: params.tag_filters.unwrap_or_default(),
283            tag_filter_mode,
284            scripting_enabled: cfg!(feature = "scripting"),
285            chart_kind: chart_kind.clone(),
286            chart_query: chart_query.clone(),
287            renderer: renderer.clone(),
288        })
289        .into_response());
290    };
291
292    let mut cmd = CategoryBreakdown::new()
293        .user_id(jwt_auth.user.id)
294        .date_from(df)
295        .date_to(dt)
296        .include_uncategorized(include_uncategorized);
297
298    if let Some(ref name) = params.tag_name {
299        cmd = cmd.tag_name(name.clone());
300    }
301
302    if let Some(ref tid_str) = params.target_commodity_id
303        && let Ok(tid) = tid_str.parse::<Uuid>()
304    {
305        cmd = cmd.target_commodity_id(tid);
306    }
307
308    if let Some(pg) = params
309        .period_grouping
310        .as_deref()
311        .and_then(parse_period_grouping)
312    {
313        cmd = cmd.period_grouping(pg);
314    }
315
316    if let Some(order) = parse_sort_order(&sort_order_raw) {
317        cmd = cmd.sort_order(order);
318    }
319
320    if let Some(filter) = build_report_filter(
321        params.tag_filters.as_deref(),
322        Some(tag_filter_mode.as_str()),
323    ) {
324        cmd = cmd.report_filter(filter);
325    }
326
327    let result = cmd
328        .run()
329        .await
330        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
331
332    let breakdown_data = match result {
333        Some(CmdResult::Breakdown(data)) => data,
334        _ => BreakdownData {
335            meta: ReportMeta {
336                date_from: None,
337                date_to: None,
338                target_commodity_id: None,
339            },
340            tag_name: tag_name_display.clone(),
341            periods: vec![],
342        },
343    };
344
345    if wants_json(&headers) {
346        return Ok(Json(breakdown_data).into_response());
347    }
348
349    let periods = flatten_breakdown_data(&breakdown_data);
350    let summary = breakdown_summary(&periods, 5);
351    let commodity_symbols = collect_commodity_symbols(&periods);
352
353    Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
354        commodities,
355        summary,
356        periods,
357        commodity_symbols,
358        commodity_columns,
359        tag_name: breakdown_data.tag_name,
360        date_from: Some(date_from_str),
361        date_to: Some(date_to_str),
362        target_commodity_id: params.target_commodity_id,
363        period_grouping: params.period_grouping,
364        sort_order: sort_order_raw,
365        include_uncategorized,
366        tag_filters: params.tag_filters.unwrap_or_default(),
367        tag_filter_mode,
368        scripting_enabled: cfg!(feature = "scripting"),
369        chart_kind,
370        chart_query,
371        renderer,
372    })
373    .into_response())
374}
375
376#[derive(Deserialize)]
377pub struct CategoryBreakdownChartQuery {
378    #[serde(default, deserialize_with = "empty_string_as_none")]
379    date_from: Option<String>,
380    #[serde(default, deserialize_with = "empty_string_as_none")]
381    date_to: Option<String>,
382    #[serde(default, deserialize_with = "empty_string_as_none")]
383    target_commodity_id: Option<String>,
384    #[serde(default, deserialize_with = "empty_string_as_none")]
385    period_grouping: Option<String>,
386    #[serde(default, deserialize_with = "empty_string_as_none")]
387    tag_name: Option<String>,
388    #[serde(default, deserialize_with = "empty_string_as_none")]
389    sort_order: Option<String>,
390    #[serde(default)]
391    exclude_uncategorized: Option<String>,
392    #[serde(default, deserialize_with = "empty_string_as_none")]
393    tag_filters: Option<String>,
394    #[serde(default, deserialize_with = "empty_string_as_none")]
395    tag_filter_mode: Option<String>,
396    #[serde(flatten)]
397    chart: ChartParams,
398}
399
400async fn breakdown_periods_for_chart(
401    user_id: Uuid,
402    params: &CategoryBreakdownChartQuery,
403) -> Result<Vec<BreakdownPeriodView>, StatusCode> {
404    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
405    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
406    let (Some(df), Some(dt)) = (
407        parse_date_bound(&date_from_str, false),
408        parse_date_bound(&date_to_str, true),
409    ) else {
410        return Ok(Vec::new());
411    };
412
413    let include_uncategorized =
414        params_include_uncategorized(params.exclude_uncategorized.as_deref());
415
416    let mut cmd = CategoryBreakdown::new()
417        .user_id(user_id)
418        .date_from(df)
419        .date_to(dt)
420        .include_uncategorized(include_uncategorized);
421
422    if let Some(ref name) = params.tag_name {
423        cmd = cmd.tag_name(name.clone());
424    }
425
426    if let Some(ref tid_str) = params.target_commodity_id
427        && let Ok(tid) = tid_str.parse::<Uuid>()
428    {
429        cmd = cmd.target_commodity_id(tid);
430    }
431
432    if let Some(pg) = params
433        .period_grouping
434        .as_deref()
435        .and_then(parse_period_grouping)
436    {
437        cmd = cmd.period_grouping(pg);
438    }
439
440    if let Some(order) = params.sort_order.as_deref().and_then(parse_sort_order) {
441        cmd = cmd.sort_order(order);
442    }
443
444    if let Some(filter) = build_report_filter(
445        params.tag_filters.as_deref(),
446        params.tag_filter_mode.as_deref(),
447    ) {
448        cmd = cmd.report_filter(filter);
449    }
450
451    let result = cmd
452        .run()
453        .await
454        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
455
456    let breakdown_data = match result {
457        Some(CmdResult::Breakdown(data)) => data,
458        _ => BreakdownData {
459            meta: ReportMeta {
460                date_from: None,
461                date_to: None,
462                target_commodity_id: None,
463            },
464            tag_name: params
465                .tag_name
466                .clone()
467                .unwrap_or_else(|| "category".to_string()),
468            periods: vec![],
469        },
470    };
471
472    Ok(flatten_breakdown_data(&breakdown_data))
473}
474
475async fn breakdown_chart_spec(
476    user_id: Uuid,
477    params: &CategoryBreakdownChartQuery,
478) -> Result<plotting::ChartSpec, StatusCode> {
479    let periods = breakdown_periods_for_chart(user_id, params).await?;
480    Ok(breakdown_chart(
481        &periods,
482        BreakdownChartOpts {
483            kind: params.chart.chart_kind_or_default(),
484            top_n: 10,
485        },
486    ))
487}
488
489pub async fn category_breakdown_report_chart_svg(
490    Query(params): Query<CategoryBreakdownChartQuery>,
491    State(_data): State<Arc<AppState>>,
492    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
493) -> Result<impl IntoResponse, StatusCode> {
494    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
495    let svg = plotting::svg::render_svg(&spec, 720, 360);
496    Ok((
497        StatusCode::OK,
498        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
499        svg,
500    ))
501}
502
503pub async fn category_breakdown_report_chart_json(
504    Query(params): Query<CategoryBreakdownChartQuery>,
505    State(_data): State<Arc<AppState>>,
506    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
507) -> Result<impl IntoResponse, StatusCode> {
508    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
509    Ok(Json(spec))
510}