Skip to main content

web/pages/report/
activity.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    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
90/// Collect the set of commodity symbols referenced anywhere in the
91/// activity result, in insertion order. Used to drive the
92/// "Commodities as columns" pivot.
93fn 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
127/// Build summary cards for Activity: one card per group label summing its
128/// group-total across every period, plus a final net card combining per-
129/// period nets. Server values are already `flip_sign`-applied by the view
130/// flattener, so this is a straightforward cross-period sum.
131fn 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
349/// Run the activity command with the chart params and flatten.
350/// Returns an empty `ActivityData` when the date range is invalid —
351/// consistent with how the table handler fails soft.
352async 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    // `chart_series=groups` suppresses the Net line; anything else
421    // (including the default) includes it.
422    !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, &params).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, &params).await?;
459    Ok(Json(spec))
460}