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::{
BreakdownData, BreakdownSort, CmdResult, PeriodGrouping, ReportMeta, report::CategoryBreakdown,
};
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::{BreakdownChartOpts, breakdown_chart};
use server::command::report::view::{AmountView, BreakdownPeriodView, flatten_breakdown_data};
#[derive(Template)]
#[template(path = "pages/report/category_breakdown.html")]
struct CategoryBreakdownReportPage;
pub async fn category_breakdown_report_page() -> impl IntoResponse {
HtmlTemplate(CategoryBreakdownReportPage)
}
fn parse_period_grouping(s: &str) -> Option<PeriodGrouping> {
match s {
"month" => Some(PeriodGrouping::Month),
"quarter" => Some(PeriodGrouping::Quarter),
"year" => Some(PeriodGrouping::Year),
_ => None,
fn parse_sort_order(s: &str) -> Option<BreakdownSort> {
"amount_desc" => Some(BreakdownSort::AmountDesc),
"amount_asc" => Some(BreakdownSort::AmountAsc),
"name_asc" => Some(BreakdownSort::NameAsc),
"name_desc" => Some(BreakdownSort::NameDesc),
#[template(path = "components/report/category_breakdown_table.html")]
struct CategoryBreakdownTableTemplate {
commodities: Vec<CommodityOption>,
summary: Vec<SummaryCard>,
periods: Vec<BreakdownPeriodView>,
commodity_symbols: Vec<String>,
commodity_columns: bool,
tag_name: String,
date_from: Option<String>,
date_to: Option<String>,
target_commodity_id: Option<String>,
period_grouping: Option<String>,
sort_order: String,
include_uncategorized: bool,
tag_filters: String,
tag_filter_mode: String,
scripting_enabled: bool,
chart_kind: String,
chart_query: String,
renderer: String,
#[derive(Deserialize)]
pub struct CategoryBreakdownParams {
#[serde(default, deserialize_with = "empty_string_as_none")]
tag_name: Option<String>,
sort_order: Option<String>,
#[serde(default)]
exclude_uncategorized: Option<String>,
tag_filters: Option<String>,
tag_filter_mode: Option<String>,
#[serde(flatten)]
table: TableControlParams,
chart: ChartParams,
/// HTML checkboxes only submit a value when checked. `exclude_uncategorized`
/// defaults to false so an absent parameter naturally reads as "include the
/// bucket" and a checked box as "exclude it".
fn params_include_uncategorized(exclude_raw: Option<&str>) -> bool {
let exclude = exclude_raw.is_some_and(|v| {
let v = v.trim();
v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
});
!exclude
fn push_amount(dest: &mut Vec<AmountView>, a: &AmountView) {
match dest
.iter_mut()
.find(|d| d.commodity_symbol == a.commodity_symbol)
{
Some(existing) => existing.amount += a.amount,
None => dest.push(a.clone()),
/// Build summary cards for Category Breakdown: one grand-total card per
/// commodity plus up to `top_n` cards for the largest categories by
/// absolute amount.
fn breakdown_summary(periods: &[BreakdownPeriodView], top_n: usize) -> Vec<SummaryCard> {
let mut totals: Vec<AmountView> = Vec::new();
let mut per_category: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
for period in periods {
for row in &period.rows {
for a in &row.amounts {
push_amount(&mut totals, a);
match per_category
.find(|(name, _, _)| name == &row.tag_value)
Some((_, _, amts)) => {
push_amount(amts, a);
None => per_category.push((
row.tag_value.clone(),
row.is_uncategorized,
row.amounts.clone(),
)),
let mut cards: Vec<SummaryCard> = totals
.into_iter()
.map(|a| SummaryCard {
label: format!("Total ({})", a.commodity_symbol),
amounts: vec![a],
is_net: false,
highlight: true,
})
.collect();
per_category.sort_by(|a, b| {
let key = |row: &(String, bool, Vec<AmountView>)| -> num_rational::Rational64 {
row.2
.iter()
.map(|x| {
if x.amount < num_rational::Rational64::new(0, 1) {
-x.amount
} else {
x.amount
.sum()
key(b).cmp(&key(a))
for (name, is_uncat, amounts) in per_category.into_iter().take(top_n) {
let label = if is_uncat {
"(uncategorized)".to_string()
name
cards.push(SummaryCard {
label,
amounts,
highlight: false,
cards
fn collect_commodity_symbols(periods: &[BreakdownPeriodView]) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
if !seen.iter().any(|s| s == &a.commodity_symbol) {
seen.push(a.commodity_symbol.clone());
seen
pub async fn category_breakdown_report_table(
Query(params): Query<CategoryBreakdownParams>,
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 tag_name_display = params
.tag_name
.unwrap_or_else(|| "category".to_string());
let sort_order_raw = params
.sort_order
.unwrap_or_else(|| "amount_desc".to_string());
let include_uncategorized =
params_include_uncategorized(params.exclude_uncategorized.as_deref());
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(),
("tag_name", params.tag_name.as_deref().unwrap_or_default()),
("sort_order", sort_order_raw.as_str()),
"exclude_uncategorized",
if include_uncategorized { "" } else { "on" },
"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(CategoryBreakdownTableTemplate {
commodities,
summary: vec![],
periods: vec![],
commodity_symbols: vec![],
commodity_columns,
tag_name: tag_name_display,
date_from: Some(date_from_str),
date_to: Some(date_to_str),
target_commodity_id: params.target_commodity_id,
period_grouping: params.period_grouping,
sort_order: sort_order_raw,
include_uncategorized,
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 mut cmd = CategoryBreakdown::new()
.user_id(jwt_auth.user.id)
.date_from(df)
.date_to(dt)
.include_uncategorized(include_uncategorized);
if let Some(ref name) = params.tag_name {
cmd = cmd.tag_name(name.clone());
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
.as_deref()
.and_then(parse_period_grouping)
cmd = cmd.period_grouping(pg);
if let Some(order) = parse_sort_order(&sort_order_raw) {
cmd = cmd.sort_order(order);
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 breakdown_data = match result {
Some(CmdResult::Breakdown(data)) => data,
_ => BreakdownData {
meta: ReportMeta {
date_from: None,
date_to: None,
target_commodity_id: None,
},
tag_name: tag_name_display.clone(),
if wants_json(&headers) {
return Ok(Json(breakdown_data).into_response());
let periods = flatten_breakdown_data(&breakdown_data);
let summary = breakdown_summary(&periods, 5);
let commodity_symbols = collect_commodity_symbols(&periods);
Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
summary,
periods,
commodity_symbols,
tag_name: breakdown_data.tag_name,
chart_kind,
chart_query,
renderer,
.into_response())
pub struct CategoryBreakdownChartQuery {
async fn breakdown_periods_for_chart(
user_id: Uuid,
params: &CategoryBreakdownChartQuery,
) -> Result<Vec<BreakdownPeriodView>, 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(order) = params.sort_order.as_deref().and_then(parse_sort_order) {
params.tag_filter_mode.as_deref(),
tag_name: params
.unwrap_or_else(|| "category".to_string()),
Ok(flatten_breakdown_data(&breakdown_data))
async fn breakdown_chart_spec(
) -> Result<plotting::ChartSpec, StatusCode> {
let periods = breakdown_periods_for_chart(user_id, params).await?;
Ok(breakdown_chart(
&periods,
BreakdownChartOpts {
kind: params.chart.chart_kind_or_default(),
top_n: 10,
))
pub async fn category_breakdown_report_chart_svg(
Query(params): Query<CategoryBreakdownChartQuery>,
let spec = breakdown_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 category_breakdown_report_chart_json(
Ok(Json(spec))