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, 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
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    // An unparsable date is a client error: an empty table under a 200 reads
219    // as "no transactions in that range", which is a different answer.
220    let (Some(df), Some(dt)) = (date_from, date_to) else {
221        return Err(StatusCode::BAD_REQUEST);
222    };
223
224    // Groups that were asked for but will not parse are an error, not a silent
225    // fall back to the defaults: that answers a different question with the
226    // same 200.
227    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    // A conversion target that will not parse must not be dropped: the report
241    // would come back in original currencies under a 200, which is a different
242    // answer to the one asked for.
243    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    // Likewise an unknown grouping: silently returning an UNGROUPED report is
255    // not the report that was requested.
256    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
344/// Run the activity command with the chart params and flatten.
345/// Returns an empty `ActivityData` when the date range is invalid —
346/// consistent with how the table handler fails soft.
347async 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    // A conversion target that will not parse must not be dropped: the report
366    // would come back in original currencies under a 200, which is a different
367    // answer to the one asked for.
368    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    // Likewise an unknown grouping: silently returning an UNGROUPED report is
380    // not the report that was requested.
381    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    // `chart_series=groups` suppresses the Net line; anything else
428    // (including the default) includes it.
429    !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, &params).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, &params).await?;
466    Ok(Json(spec))
467}