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
100fn 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
121fn 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, ¶ms).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, ¶ms).await?;
509 Ok(Json(spec))
510}