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, today_string,
19 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 {
219 return Ok(HtmlTemplate(ActivityTableTemplate {
220 commodities,
221 summary: vec![],
222 periods: vec![],
223 commodity_symbols: vec![],
224 commodity_columns,
225 sortable: true,
226 collapsible: true,
227 date_from: Some(date_from_str),
228 date_to: Some(date_to_str),
229 target_commodity_id: params.target_commodity_id,
230 period_grouping: params.period_grouping,
231 collapsed_depth: params.table.collapsed_depth,
232 groups_json: groups_json_raw,
233 groups_mode,
234 tag_filters: params.tag_filters.unwrap_or_default(),
235 tag_filter_mode,
236 scripting_enabled: cfg!(feature = "scripting"),
237 chart_kind: chart_kind.clone(),
238 chart_query: chart_query.clone(),
239 renderer: renderer.clone(),
240 })
241 .into_response());
242 };
243
244 let parsed_groups: Option<Vec<ActivityGroup>> = params
245 .groups
246 .as_deref()
247 .and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok());
248
249 let mut cmd = ActivityReport::new()
250 .user_id(jwt_auth.user.id)
251 .date_from(df)
252 .date_to(dt);
253
254 if let Some(ref tid_str) = params.target_commodity_id
255 && let Ok(tid) = tid_str.parse::<Uuid>()
256 {
257 cmd = cmd.target_commodity_id(tid);
258 }
259
260 if let Some(pg) = params
261 .period_grouping
262 .as_deref()
263 .and_then(parse_period_grouping)
264 {
265 cmd = cmd.period_grouping(pg);
266 }
267
268 if let Some(g) = parsed_groups {
269 cmd = cmd.groups(g);
270 }
271
272 if let Some(filter) = build_report_filter(
273 params.tag_filters.as_deref(),
274 Some(tag_filter_mode.as_str()),
275 ) {
276 cmd = cmd.report_filter(filter);
277 }
278
279 let result = cmd
280 .run()
281 .await
282 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
283
284 let activity_data = match result {
285 Some(CmdResult::Activity(data)) => data,
286 _ => ActivityData {
287 meta: ReportMeta {
288 date_from: None,
289 date_to: None,
290 target_commodity_id: None,
291 },
292 periods: vec![],
293 },
294 };
295
296 if wants_json(&headers) {
297 return Ok(Json(activity_data).into_response());
298 }
299
300 let periods = flatten_activity_data(&activity_data);
301 let summary = activity_summary(&periods);
302 let commodity_symbols = collect_commodity_symbols(&periods);
303
304 Ok(HtmlTemplate(ActivityTableTemplate {
305 commodities,
306 summary,
307 periods,
308 commodity_symbols,
309 commodity_columns,
310 sortable: true,
311 collapsible: true,
312 date_from: Some(date_from_str),
313 date_to: Some(date_to_str),
314 target_commodity_id: params.target_commodity_id,
315 period_grouping: params.period_grouping,
316 collapsed_depth: params.table.collapsed_depth,
317 groups_json: groups_json_raw,
318 groups_mode,
319 tag_filters: params.tag_filters.unwrap_or_default(),
320 tag_filter_mode,
321 scripting_enabled: cfg!(feature = "scripting"),
322 chart_kind,
323 chart_query,
324 renderer,
325 })
326 .into_response())
327}
328
329#[derive(Deserialize)]
330pub struct ActivityChartQuery {
331 #[serde(default, deserialize_with = "empty_string_as_none")]
332 date_from: Option<String>,
333 #[serde(default, deserialize_with = "empty_string_as_none")]
334 date_to: Option<String>,
335 #[serde(default, deserialize_with = "empty_string_as_none")]
336 target_commodity_id: Option<String>,
337 #[serde(default, deserialize_with = "empty_string_as_none")]
338 period_grouping: Option<String>,
339 #[serde(default, deserialize_with = "empty_string_as_none")]
340 groups: Option<String>,
341 #[serde(default, deserialize_with = "empty_string_as_none")]
342 tag_filters: Option<String>,
343 #[serde(default, deserialize_with = "empty_string_as_none")]
344 tag_filter_mode: Option<String>,
345 #[serde(flatten)]
346 chart: ChartParams,
347}
348
349async fn activity_periods_for_chart(
353 user_id: Uuid,
354 params: &ActivityChartQuery,
355) -> Result<Vec<PeriodActivityView>, StatusCode> {
356 let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
357 let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
358 let (Some(df), Some(dt)) = (
359 parse_date_bound(&date_from_str, false),
360 parse_date_bound(&date_to_str, true),
361 ) else {
362 return Ok(Vec::new());
363 };
364
365 let mut cmd = ActivityReport::new()
366 .user_id(user_id)
367 .date_from(df)
368 .date_to(dt);
369
370 if let Some(ref tid_str) = params.target_commodity_id
371 && let Ok(tid) = tid_str.parse::<Uuid>()
372 {
373 cmd = cmd.target_commodity_id(tid);
374 }
375
376 if let Some(pg) = params
377 .period_grouping
378 .as_deref()
379 .and_then(parse_period_grouping)
380 {
381 cmd = cmd.period_grouping(pg);
382 }
383
384 if let Some(g) = params
385 .groups
386 .as_deref()
387 .and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok())
388 {
389 cmd = cmd.groups(g);
390 }
391
392 if let Some(filter) = build_report_filter(
393 params.tag_filters.as_deref(),
394 params.tag_filter_mode.as_deref(),
395 ) {
396 cmd = cmd.report_filter(filter);
397 }
398
399 let result = cmd
400 .run()
401 .await
402 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
403
404 let activity_data = match result {
405 Some(CmdResult::Activity(data)) => data,
406 _ => ActivityData {
407 meta: ReportMeta {
408 date_from: None,
409 date_to: None,
410 target_commodity_id: None,
411 },
412 periods: vec![],
413 },
414 };
415
416 Ok(flatten_activity_data(&activity_data))
417}
418
419fn chart_series_includes_net(raw: Option<&str>) -> bool {
420 !matches!(raw.map(str::to_ascii_lowercase), Some(ref s) if s == "groups")
423}
424
425async fn activity_chart_spec(
426 user_id: Uuid,
427 params: &ActivityChartQuery,
428) -> Result<plotting::ChartSpec, StatusCode> {
429 let periods = activity_periods_for_chart(user_id, params).await?;
430 Ok(activity_chart(
431 &periods,
432 ActivityChartOpts {
433 kind: params.chart.chart_kind_or_default(),
434 include_net: chart_series_includes_net(params.chart.chart_series.as_deref()),
435 },
436 ))
437}
438
439pub async fn activity_report_chart_svg(
440 Query(params): Query<ActivityChartQuery>,
441 State(_data): State<Arc<AppState>>,
442 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
443) -> Result<impl IntoResponse, StatusCode> {
444 let spec = activity_chart_spec(jwt_auth.user.id, ¶ms).await?;
445 let svg = plotting::svg::render_svg(&spec, 720, 360);
446 Ok((
447 StatusCode::OK,
448 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
449 svg,
450 ))
451}
452
453pub async fn activity_report_chart_json(
454 Query(params): Query<ActivityChartQuery>,
455 State(_data): State<Arc<AppState>>,
456 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
457) -> Result<impl IntoResponse, StatusCode> {
458 let spec = activity_chart_spec(jwt_auth.user.id, ¶ms).await?;
459 Ok(Json(spec))
460}