Lines
0 %
Functions
Branches
100 %
use std::sync::Arc;
use askama::Template;
use axum::Json;
use axum::extract::Query;
use axum::http::HeaderMap;
use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use server::command::{
ActivityData, ActivityGroup, CmdResult, PeriodGrouping, ReportMeta, report::ActivityReport,
};
use sqlx::types::Uuid;
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
use super::{
ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
empty_string_as_none, load_commodities, month_start_string, parse_date_bound, today_string,
wants_json,
use plotting::adapters::{ActivityChartOpts, activity_chart};
use server::command::report::view::{AmountView, PeriodActivityView, flatten_activity_data};
#[derive(Template)]
#[template(path = "pages/report/activity.html")]
struct ActivityReportPage;
pub async fn activity_report_page() -> impl IntoResponse {
HtmlTemplate(ActivityReportPage)
}
fn parse_period_grouping(s: &str) -> Option<PeriodGrouping> {
match s {
"month" => Some(PeriodGrouping::Month),
"quarter" => Some(PeriodGrouping::Quarter),
"year" => Some(PeriodGrouping::Year),
_ => None,
#[template(path = "components/report/activity_table.html")]
struct ActivityTableTemplate {
commodities: Vec<CommodityOption>,
summary: Vec<SummaryCard>,
periods: Vec<PeriodActivityView>,
commodity_symbols: Vec<String>,
commodity_columns: bool,
sortable: bool,
collapsible: bool,
date_from: Option<String>,
date_to: Option<String>,
target_commodity_id: Option<String>,
period_grouping: Option<String>,
collapsed_depth: Option<String>,
groups_json: String,
groups_mode: String,
tag_filters: String,
tag_filter_mode: String,
scripting_enabled: bool,
chart_kind: String,
chart_query: String,
renderer: String,
#[derive(Deserialize)]
pub struct ActivityParams {
#[serde(default, deserialize_with = "empty_string_as_none")]
groups: Option<String>,
groups_mode: Option<String>,
tag_filters: Option<String>,
tag_filter_mode: Option<String>,
#[serde(flatten)]
table: TableControlParams,
chart: ChartParams,
/// Collect the set of commodity symbols referenced anywhere in the
/// activity result, in insertion order. Used to drive the
/// "Commodities as columns" pivot.
fn collect_commodity_symbols(periods: &[PeriodActivityView]) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
let mut push = |sym: &str| {
if !seen.iter().any(|s| s == sym) {
seen.push(sym.to_string());
for period in periods {
for group in &period.groups {
for row in &group.rows {
for a in &row.amounts {
push(&a.commodity_symbol);
seen
fn add_amounts(dest: &mut Vec<AmountView>, src: &[AmountView]) {
for a in src {
match dest
.iter_mut()
.find(|d| d.commodity_symbol == a.commodity_symbol)
{
Some(existing) => existing.amount += a.amount,
None => dest.push(AmountView {
commodity_symbol: a.commodity_symbol.clone(),
amount: a.amount,
}),
/// Build summary cards for Activity: one card per group label summing its
/// group-total across every period, plus a final net card combining per-
/// period nets. Server values are already `flip_sign`-applied by the view
/// flattener, so this is a straightforward cross-period sum.
fn activity_summary(periods: &[PeriodActivityView]) -> Vec<SummaryCard> {
let mut labeled: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
let mut net: Vec<AmountView> = Vec::new();
match labeled
.find(|(label, _, _)| label == &group.label)
Some((_, _, totals)) => add_amounts(totals, &group.total),
None => labeled.push((group.label.clone(), group.flip_sign, group.total.clone())),
add_amounts(&mut net, &period.net);
let mut cards: Vec<SummaryCard> = labeled
.into_iter()
.map(|(label, highlight, amounts)| SummaryCard {
label,
amounts,
is_net: false,
highlight,
})
.collect();
if !net.is_empty() {
cards.push(SummaryCard {
label: "Net".to_string(),
amounts: net,
is_net: true,
highlight: false,
});
cards
pub async fn activity_report_table(
Query(params): Query<ActivityParams>,
State(_data): State<Arc<AppState>>,
Extension(jwt_auth): Extension<JWTAuthMiddleware>,
headers: HeaderMap,
) -> Result<impl IntoResponse, StatusCode> {
let commodities = load_commodities(jwt_auth.user.id).await;
let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
let date_from = parse_date_bound(&date_from_str, false);
let date_to = parse_date_bound(&date_to_str, true);
let tag_filter_mode = params
.tag_filter_mode
.clone()
.unwrap_or_else(|| "visual".to_string());
let groups_mode = params
.groups_mode
let groups_json_raw = params.groups.clone().unwrap_or_default();
let commodity_columns = params.table.commodity_columns_enabled();
let chart_kind = params.chart.chart_kind_str();
let renderer = params.chart.renderer_str();
let chart_query = super::encode_query(&[
("date_from", date_from_str.as_str()),
("date_to", date_to_str.as_str()),
(
"target_commodity_id",
params.target_commodity_id.as_deref().unwrap_or_default(),
),
"period_grouping",
params.period_grouping.as_deref().unwrap_or_default(),
("groups", groups_json_raw.as_str()),
"tag_filters",
params.tag_filters.as_deref().unwrap_or_default(),
("tag_filter_mode", tag_filter_mode.as_str()),
("chart_kind", chart_kind.as_str()),
("renderer", renderer.as_str()),
]);
let (Some(df), Some(dt)) = (date_from, date_to) else {
return Ok(HtmlTemplate(ActivityTableTemplate {
commodities,
summary: vec![],
periods: vec![],
commodity_symbols: vec![],
commodity_columns,
sortable: true,
collapsible: true,
date_from: Some(date_from_str),
date_to: Some(date_to_str),
target_commodity_id: params.target_commodity_id,
period_grouping: params.period_grouping,
collapsed_depth: params.table.collapsed_depth,
groups_json: groups_json_raw,
groups_mode,
tag_filters: params.tag_filters.unwrap_or_default(),
tag_filter_mode,
scripting_enabled: cfg!(feature = "scripting"),
chart_kind: chart_kind.clone(),
chart_query: chart_query.clone(),
renderer: renderer.clone(),
.into_response());
let parsed_groups: Option<Vec<ActivityGroup>> = params
.groups
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok());
let mut cmd = ActivityReport::new()
.user_id(jwt_auth.user.id)
.date_from(df)
.date_to(dt);
if let Some(ref tid_str) = params.target_commodity_id
&& let Ok(tid) = tid_str.parse::<Uuid>()
cmd = cmd.target_commodity_id(tid);
if let Some(pg) = params
.period_grouping
.and_then(parse_period_grouping)
cmd = cmd.period_grouping(pg);
if let Some(g) = parsed_groups {
cmd = cmd.groups(g);
if let Some(filter) = build_report_filter(
params.tag_filters.as_deref(),
Some(tag_filter_mode.as_str()),
) {
cmd = cmd.report_filter(filter);
let result = cmd
.run()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let activity_data = match result {
Some(CmdResult::Activity(data)) => data,
_ => ActivityData {
meta: ReportMeta {
date_from: None,
date_to: None,
target_commodity_id: None,
},
if wants_json(&headers) {
return Ok(Json(activity_data).into_response());
let periods = flatten_activity_data(&activity_data);
let summary = activity_summary(&periods);
let commodity_symbols = collect_commodity_symbols(&periods);
Ok(HtmlTemplate(ActivityTableTemplate {
summary,
periods,
commodity_symbols,
chart_kind,
chart_query,
renderer,
.into_response())
pub struct ActivityChartQuery {
/// Run the activity command with the chart params and flatten.
/// Returns an empty `ActivityData` when the date range is invalid —
/// consistent with how the table handler fails soft.
async fn activity_periods_for_chart(
user_id: Uuid,
params: &ActivityChartQuery,
) -> Result<Vec<PeriodActivityView>, StatusCode> {
let (Some(df), Some(dt)) = (
parse_date_bound(&date_from_str, false),
parse_date_bound(&date_to_str, true),
) else {
return Ok(Vec::new());
.user_id(user_id)
if let Some(g) = params
.and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok())
params.tag_filter_mode.as_deref(),
Ok(flatten_activity_data(&activity_data))
fn chart_series_includes_net(raw: Option<&str>) -> bool {
// `chart_series=groups` suppresses the Net line; anything else
// (including the default) includes it.
!matches!(raw.map(str::to_ascii_lowercase), Some(ref s) if s == "groups")
async fn activity_chart_spec(
) -> Result<plotting::ChartSpec, StatusCode> {
let periods = activity_periods_for_chart(user_id, params).await?;
Ok(activity_chart(
&periods,
ActivityChartOpts {
kind: params.chart.chart_kind_or_default(),
include_net: chart_series_includes_net(params.chart.chart_series.as_deref()),
))
pub async fn activity_report_chart_svg(
Query(params): Query<ActivityChartQuery>,
let spec = activity_chart_spec(jwt_auth.user.id, ¶ms).await?;
let svg = plotting::svg::render_svg(&spec, 720, 360);
Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
svg,
pub async fn activity_report_chart_json(
Ok(Json(spec))