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, report_status,
19 today_string, 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 {
271 return Err(StatusCode::BAD_REQUEST);
272 };
273
274 let mut cmd = CategoryBreakdown::new()
275 .user_id(jwt_auth.user.id)
276 .date_from(df)
277 .date_to(dt)
278 .include_uncategorized(include_uncategorized);
279
280 if let Some(ref name) = params.tag_name {
281 cmd = cmd.tag_name(name.clone());
282 }
283
284 if let Some(tid) = params
288 .target_commodity_id
289 .as_deref()
290 .filter(|s| !s.is_empty())
291 .map(|s| s.parse::<Uuid>())
292 .transpose()
293 .map_err(|_| StatusCode::BAD_REQUEST)?
294 {
295 cmd = cmd.target_commodity_id(tid);
296 }
297
298 if let Some(pg) = params
301 .period_grouping
302 .as_deref()
303 .filter(|s| !s.is_empty())
304 .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
305 .transpose()?
306 {
307 cmd = cmd.period_grouping(pg);
308 }
309
310 if let Some(order) = parse_sort_order(&sort_order_raw) {
311 cmd = cmd.sort_order(order);
312 }
313
314 if let Some(filter) = build_report_filter(
315 params.tag_filters.as_deref(),
316 Some(tag_filter_mode.as_str()),
317 )? {
318 cmd = cmd.report_filter(filter);
319 }
320
321 let result = cmd.run().await.map_err(report_status)?;
322
323 let breakdown_data = match result {
324 Some(CmdResult::Breakdown(data)) => data,
325 _ => BreakdownData {
326 meta: ReportMeta {
327 date_from: None,
328 date_to: None,
329 target_commodity_id: None,
330 },
331 tag_name: tag_name_display.clone(),
332 periods: vec![],
333 },
334 };
335
336 if wants_json(&headers) {
337 return Ok(Json(breakdown_data).into_response());
338 }
339
340 let periods = flatten_breakdown_data(&breakdown_data);
341 let summary = breakdown_summary(&periods, 5);
342 let commodity_symbols = collect_commodity_symbols(&periods);
343
344 Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
345 commodities,
346 summary,
347 periods,
348 commodity_symbols,
349 commodity_columns,
350 tag_name: breakdown_data.tag_name,
351 date_from: Some(date_from_str),
352 date_to: Some(date_to_str),
353 target_commodity_id: params.target_commodity_id,
354 period_grouping: params.period_grouping,
355 sort_order: sort_order_raw,
356 include_uncategorized,
357 tag_filters: params.tag_filters.unwrap_or_default(),
358 tag_filter_mode,
359 scripting_enabled: true,
360 chart_kind,
361 chart_query,
362 renderer,
363 })
364 .into_response())
365}
366
367#[derive(Deserialize)]
368pub struct CategoryBreakdownChartQuery {
369 #[serde(default, deserialize_with = "empty_string_as_none")]
370 date_from: Option<String>,
371 #[serde(default, deserialize_with = "empty_string_as_none")]
372 date_to: Option<String>,
373 #[serde(default, deserialize_with = "empty_string_as_none")]
374 target_commodity_id: Option<String>,
375 #[serde(default, deserialize_with = "empty_string_as_none")]
376 period_grouping: Option<String>,
377 #[serde(default, deserialize_with = "empty_string_as_none")]
378 tag_name: Option<String>,
379 #[serde(default, deserialize_with = "empty_string_as_none")]
380 sort_order: Option<String>,
381 #[serde(default)]
382 exclude_uncategorized: Option<String>,
383 #[serde(default, deserialize_with = "empty_string_as_none")]
384 tag_filters: Option<String>,
385 #[serde(default, deserialize_with = "empty_string_as_none")]
386 tag_filter_mode: Option<String>,
387 #[serde(flatten)]
388 chart: ChartParams,
389}
390
391async fn breakdown_periods_for_chart(
392 user_id: Uuid,
393 params: &CategoryBreakdownChartQuery,
394) -> Result<Vec<BreakdownPeriodView>, StatusCode> {
395 let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
396 let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
397 let (Some(df), Some(dt)) = (
398 parse_date_bound(&date_from_str, false),
399 parse_date_bound(&date_to_str, true),
400 ) else {
401 return Err(StatusCode::BAD_REQUEST);
402 };
403
404 let include_uncategorized =
405 params_include_uncategorized(params.exclude_uncategorized.as_deref());
406
407 let mut cmd = CategoryBreakdown::new()
408 .user_id(user_id)
409 .date_from(df)
410 .date_to(dt)
411 .include_uncategorized(include_uncategorized);
412
413 if let Some(ref name) = params.tag_name {
414 cmd = cmd.tag_name(name.clone());
415 }
416
417 if let Some(tid) = params
421 .target_commodity_id
422 .as_deref()
423 .filter(|s| !s.is_empty())
424 .map(|s| s.parse::<Uuid>())
425 .transpose()
426 .map_err(|_| StatusCode::BAD_REQUEST)?
427 {
428 cmd = cmd.target_commodity_id(tid);
429 }
430
431 if let Some(pg) = params
434 .period_grouping
435 .as_deref()
436 .filter(|s| !s.is_empty())
437 .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
438 .transpose()?
439 {
440 cmd = cmd.period_grouping(pg);
441 }
442
443 if let Some(order) = params.sort_order.as_deref().and_then(parse_sort_order) {
444 cmd = cmd.sort_order(order);
445 }
446
447 if let Some(filter) = build_report_filter(
448 params.tag_filters.as_deref(),
449 params.tag_filter_mode.as_deref(),
450 )? {
451 cmd = cmd.report_filter(filter);
452 }
453
454 let result = cmd.run().await.map_err(report_status)?;
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}