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 ActivityData, ActivityGroup, CmdResult, PeriodGrouping, ReportMeta, report::ActivityReport,
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::{ActivityChartOpts, activity_chart};
22use server::command::report::view::{AmountView, PeriodActivityView, flatten_activity_data};
23
24#[derive(Template)]
25#[template(path = "pages/report/activity.html")]
26struct ActivityReportPage;
27
28pub async fn activity_report_page() -> impl IntoResponse {
29 HtmlTemplate(ActivityReportPage)
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
41#[derive(Template)]
42#[template(path = "components/report/activity_table.html")]
43struct ActivityTableTemplate {
44 commodities: Vec<CommodityOption>,
45 summary: Vec<SummaryCard>,
46 periods: Vec<PeriodActivityView>,
47 commodity_symbols: Vec<String>,
48 commodity_columns: bool,
49 sortable: bool,
50 collapsible: bool,
51 date_from: Option<String>,
52 date_to: Option<String>,
53 target_commodity_id: Option<String>,
54 period_grouping: Option<String>,
55 collapsed_depth: Option<String>,
56 groups_json: String,
57 groups_mode: String,
58 tag_filters: String,
59 tag_filter_mode: String,
60 scripting_enabled: bool,
61 chart_kind: String,
62 chart_query: String,
63 renderer: String,
64}
65
66#[derive(Deserialize)]
67pub struct ActivityParams {
68 #[serde(default, deserialize_with = "empty_string_as_none")]
69 date_from: Option<String>,
70 #[serde(default, deserialize_with = "empty_string_as_none")]
71 date_to: Option<String>,
72 #[serde(default, deserialize_with = "empty_string_as_none")]
73 target_commodity_id: Option<String>,
74 #[serde(default, deserialize_with = "empty_string_as_none")]
75 period_grouping: Option<String>,
76 #[serde(default, deserialize_with = "empty_string_as_none")]
77 groups: Option<String>,
78 #[serde(default, deserialize_with = "empty_string_as_none")]
79 groups_mode: Option<String>,
80 #[serde(default, deserialize_with = "empty_string_as_none")]
81 tag_filters: Option<String>,
82 #[serde(default, deserialize_with = "empty_string_as_none")]
83 tag_filter_mode: Option<String>,
84 #[serde(flatten)]
85 table: TableControlParams,
86 #[serde(flatten)]
87 chart: ChartParams,
88}
89
90fn collect_commodity_symbols(periods: &[PeriodActivityView]) -> Vec<String> {
94 let mut seen: Vec<String> = Vec::new();
95 let mut push = |sym: &str| {
96 if !seen.iter().any(|s| s == sym) {
97 seen.push(sym.to_string());
98 }
99 };
100 for period in periods {
101 for group in &period.groups {
102 for row in &group.rows {
103 for a in &row.amounts {
104 push(&a.commodity_symbol);
105 }
106 }
107 }
108 }
109 seen
110}
111
112fn add_amounts(dest: &mut Vec<AmountView>, src: &[AmountView]) {
113 for a in src {
114 match dest
115 .iter_mut()
116 .find(|d| d.commodity_symbol == a.commodity_symbol)
117 {
118 Some(existing) => existing.amount += a.amount,
119 None => dest.push(AmountView {
120 commodity_symbol: a.commodity_symbol.clone(),
121 amount: a.amount,
122 }),
123 }
124 }
125}
126
127fn activity_summary(periods: &[PeriodActivityView]) -> Vec<SummaryCard> {
132 let mut labeled: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
133 let mut net: Vec<AmountView> = Vec::new();
134
135 for period in periods {
136 for group in &period.groups {
137 match labeled
138 .iter_mut()
139 .find(|(label, _, _)| label == &group.label)
140 {
141 Some((_, _, totals)) => add_amounts(totals, &group.total),
142 None => labeled.push((group.label.clone(), group.flip_sign, group.total.clone())),
143 }
144 }
145 add_amounts(&mut net, &period.net);
146 }
147
148 let mut cards: Vec<SummaryCard> = labeled
149 .into_iter()
150 .map(|(label, highlight, amounts)| SummaryCard {
151 label,
152 amounts,
153 is_net: false,
154 highlight,
155 })
156 .collect();
157
158 if !net.is_empty() {
159 cards.push(SummaryCard {
160 label: "Net".to_string(),
161 amounts: net,
162 is_net: true,
163 highlight: false,
164 });
165 }
166 cards
167}
168
169pub async fn activity_report_table(
170 Query(params): Query<ActivityParams>,
171 State(_data): State<Arc<AppState>>,
172 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
173 headers: HeaderMap,
174) -> Result<impl IntoResponse, StatusCode> {
175 let commodities = load_commodities(jwt_auth.user.id).await;
176
177 let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
178 let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
179
180 let date_from = parse_date_bound(&date_from_str, false);
181 let date_to = parse_date_bound(&date_to_str, true);
182
183 let tag_filter_mode = params
184 .tag_filter_mode
185 .clone()
186 .unwrap_or_else(|| "visual".to_string());
187
188 let groups_mode = params
189 .groups_mode
190 .clone()
191 .unwrap_or_else(|| "visual".to_string());
192
193 let groups_json_raw = params.groups.clone().unwrap_or_default();
194 let commodity_columns = params.table.commodity_columns_enabled();
195 let chart_kind = params.chart.chart_kind_str();
196 let renderer = params.chart.renderer_str();
197 let chart_query = super::encode_query(&[
198 ("date_from", date_from_str.as_str()),
199 ("date_to", date_to_str.as_str()),
200 (
201 "target_commodity_id",
202 params.target_commodity_id.as_deref().unwrap_or_default(),
203 ),
204 (
205 "period_grouping",
206 params.period_grouping.as_deref().unwrap_or_default(),
207 ),
208 ("groups", groups_json_raw.as_str()),
209 (
210 "tag_filters",
211 params.tag_filters.as_deref().unwrap_or_default(),
212 ),
213 ("tag_filter_mode", tag_filter_mode.as_str()),
214 ("chart_kind", chart_kind.as_str()),
215 ("renderer", renderer.as_str()),
216 ]);
217
218 let (Some(df), Some(dt)) = (date_from, date_to) else {
221 return Err(StatusCode::BAD_REQUEST);
222 };
223
224 let parsed_groups: Option<Vec<ActivityGroup>> = params
228 .groups
229 .as_deref()
230 .filter(|s| !s.is_empty())
231 .map(serde_json::from_str::<Vec<ActivityGroup>>)
232 .transpose()
233 .map_err(|_| StatusCode::BAD_REQUEST)?;
234
235 let mut cmd = ActivityReport::new()
236 .user_id(jwt_auth.user.id)
237 .date_from(df)
238 .date_to(dt);
239
240 if let Some(tid) = params
244 .target_commodity_id
245 .as_deref()
246 .filter(|s| !s.is_empty())
247 .map(|s| s.parse::<Uuid>())
248 .transpose()
249 .map_err(|_| StatusCode::BAD_REQUEST)?
250 {
251 cmd = cmd.target_commodity_id(tid);
252 }
253
254 if let Some(pg) = params
257 .period_grouping
258 .as_deref()
259 .filter(|s| !s.is_empty())
260 .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
261 .transpose()?
262 {
263 cmd = cmd.period_grouping(pg);
264 }
265
266 if let Some(g) = parsed_groups {
267 cmd = cmd.groups(g);
268 }
269
270 if let Some(filter) = build_report_filter(
271 params.tag_filters.as_deref(),
272 Some(tag_filter_mode.as_str()),
273 )? {
274 cmd = cmd.report_filter(filter);
275 }
276
277 let result = cmd.run().await.map_err(report_status)?;
278
279 let activity_data = match result {
280 Some(CmdResult::Activity(data)) => data,
281 _ => ActivityData {
282 meta: ReportMeta {
283 date_from: None,
284 date_to: None,
285 target_commodity_id: None,
286 },
287 periods: vec![],
288 },
289 };
290
291 if wants_json(&headers) {
292 return Ok(Json(activity_data).into_response());
293 }
294
295 let periods = flatten_activity_data(&activity_data);
296 let summary = activity_summary(&periods);
297 let commodity_symbols = collect_commodity_symbols(&periods);
298
299 Ok(HtmlTemplate(ActivityTableTemplate {
300 commodities,
301 summary,
302 periods,
303 commodity_symbols,
304 commodity_columns,
305 sortable: true,
306 collapsible: true,
307 date_from: Some(date_from_str),
308 date_to: Some(date_to_str),
309 target_commodity_id: params.target_commodity_id,
310 period_grouping: params.period_grouping,
311 collapsed_depth: params.table.collapsed_depth,
312 groups_json: groups_json_raw,
313 groups_mode,
314 tag_filters: params.tag_filters.unwrap_or_default(),
315 tag_filter_mode,
316 scripting_enabled: true,
317 chart_kind,
318 chart_query,
319 renderer,
320 })
321 .into_response())
322}
323
324#[derive(Deserialize)]
325pub struct ActivityChartQuery {
326 #[serde(default, deserialize_with = "empty_string_as_none")]
327 date_from: Option<String>,
328 #[serde(default, deserialize_with = "empty_string_as_none")]
329 date_to: Option<String>,
330 #[serde(default, deserialize_with = "empty_string_as_none")]
331 target_commodity_id: Option<String>,
332 #[serde(default, deserialize_with = "empty_string_as_none")]
333 period_grouping: Option<String>,
334 #[serde(default, deserialize_with = "empty_string_as_none")]
335 groups: Option<String>,
336 #[serde(default, deserialize_with = "empty_string_as_none")]
337 tag_filters: Option<String>,
338 #[serde(default, deserialize_with = "empty_string_as_none")]
339 tag_filter_mode: Option<String>,
340 #[serde(flatten)]
341 chart: ChartParams,
342}
343
344async fn activity_periods_for_chart(
348 user_id: Uuid,
349 params: &ActivityChartQuery,
350) -> Result<Vec<PeriodActivityView>, StatusCode> {
351 let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
352 let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
353 let (Some(df), Some(dt)) = (
354 parse_date_bound(&date_from_str, false),
355 parse_date_bound(&date_to_str, true),
356 ) else {
357 return Err(StatusCode::BAD_REQUEST);
358 };
359
360 let mut cmd = ActivityReport::new()
361 .user_id(user_id)
362 .date_from(df)
363 .date_to(dt);
364
365 if let Some(tid) = params
369 .target_commodity_id
370 .as_deref()
371 .filter(|s| !s.is_empty())
372 .map(|s| s.parse::<Uuid>())
373 .transpose()
374 .map_err(|_| StatusCode::BAD_REQUEST)?
375 {
376 cmd = cmd.target_commodity_id(tid);
377 }
378
379 if let Some(pg) = params
382 .period_grouping
383 .as_deref()
384 .filter(|s| !s.is_empty())
385 .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
386 .transpose()?
387 {
388 cmd = cmd.period_grouping(pg);
389 }
390
391 if let Some(g) = params
392 .groups
393 .as_deref()
394 .filter(|s| !s.is_empty())
395 .map(serde_json::from_str::<Vec<ActivityGroup>>)
396 .transpose()
397 .map_err(|_| StatusCode::BAD_REQUEST)?
398 {
399 cmd = cmd.groups(g);
400 }
401
402 if let Some(filter) = build_report_filter(
403 params.tag_filters.as_deref(),
404 params.tag_filter_mode.as_deref(),
405 )? {
406 cmd = cmd.report_filter(filter);
407 }
408
409 let result = cmd.run().await.map_err(report_status)?;
410
411 let activity_data = match result {
412 Some(CmdResult::Activity(data)) => data,
413 _ => ActivityData {
414 meta: ReportMeta {
415 date_from: None,
416 date_to: None,
417 target_commodity_id: None,
418 },
419 periods: vec![],
420 },
421 };
422
423 Ok(flatten_activity_data(&activity_data))
424}
425
426fn chart_series_includes_net(raw: Option<&str>) -> bool {
427 !matches!(raw.map(str::to_ascii_lowercase), Some(ref s) if s == "groups")
430}
431
432async fn activity_chart_spec(
433 user_id: Uuid,
434 params: &ActivityChartQuery,
435) -> Result<plotting::ChartSpec, StatusCode> {
436 let periods = activity_periods_for_chart(user_id, params).await?;
437 Ok(activity_chart(
438 &periods,
439 ActivityChartOpts {
440 kind: params.chart.chart_kind_or_default(),
441 include_net: chart_series_includes_net(params.chart.chart_series.as_deref()),
442 },
443 ))
444}
445
446pub async fn activity_report_chart_svg(
447 Query(params): Query<ActivityChartQuery>,
448 State(_data): State<Arc<AppState>>,
449 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
450) -> Result<impl IntoResponse, StatusCode> {
451 let spec = activity_chart_spec(jwt_auth.user.id, ¶ms).await?;
452 let svg = plotting::svg::render_svg(&spec, 720, 360);
453 Ok((
454 StatusCode::OK,
455 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
456 svg,
457 ))
458}
459
460pub async fn activity_report_chart_json(
461 Query(params): Query<ActivityChartQuery>,
462 State(_data): State<Arc<AppState>>,
463 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
464) -> Result<impl IntoResponse, StatusCode> {
465 let spec = activity_chart_spec(jwt_auth.user.id, ¶ms).await?;
466 Ok(Json(spec))
467}