Display balance evolution chart

This commit is contained in:
2025-05-02 11:17:51 +02:00
parent 272c8ab312
commit 56370ec936
9 changed files with 561 additions and 43 deletions

View File

@ -1,7 +1,10 @@
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::AuthExtractor;
use crate::services::{files_service, movements_service};
use actix_web::HttpResponse;
use crate::models::accounts::Account;
use crate::services::{accounts_service, files_service, movements_service};
use crate::utils::time_utils::time;
use actix_web::{HttpResponse, web};
use std::collections::HashMap;
#[derive(serde::Serialize)]
struct GlobalStats {
@ -23,3 +26,75 @@ pub async fn global(auth: AuthExtractor) -> HttpResult {
total_files_size: files.iter().fold(0, |sum, m| sum + m.file_size as u64),
}))
}
#[derive(serde::Deserialize)]
pub struct BalanceVariationQuery {
#[serde(skip_serializing_if = "Option::is_none")]
interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
start: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
end: Option<i64>,
}
/// Statistic dataset entry
#[derive(serde::Serialize, Debug, Clone)]
struct StatEntry {
time: i64,
/// Account balances. Note: due to JSON limitation, we had to turn account id into strings
#[serde(flatten)]
balances: HashMap<String, f32>,
}
impl StatEntry {
fn init_first(time: i64, accounts: &[Account]) -> Self {
Self {
time,
balances: accounts
.iter()
.map(|a| (a.id().0.to_string(), 0.0))
.collect(),
}
}
}
/// Accounts balance variation
pub async fn balance_variation(
auth: AuthExtractor,
query: web::Query<BalanceVariationQuery>,
) -> HttpResult {
let start = query.start.unwrap_or((time() - 3600 * 24 * 30) as i64);
let end = query.end.unwrap_or(time() as i64);
let interval = query.interval.unwrap_or(3600 * 24);
let accounts = accounts_service::get_list_user(auth.user.id()).await?;
let mut movements = movements_service::get_all_movements_user(auth.user.id()).await?;
movements.sort_by(|a, b| a.time.cmp(&b.time));
let mut dataset = vec![];
let mut stat_entry = StatEntry::init_first(start, &accounts);
// Iter movements
for m in movements {
if m.time > end {
break;
}
// Check if it is time to go the the next entry
while m.time > stat_entry.time {
dataset.push(stat_entry.clone());
stat_entry.time += interval as i64;
}
let target_account_id = m.account_id().0.to_string();
let old_amount = *stat_entry.balances.get(&target_account_id).unwrap_or(&0.0);
stat_entry
.balances
.insert(target_account_id, old_amount + m.amount);
}
// Final push
dataset.push(stat_entry);
Ok(HttpResponse::Ok().json(dataset))
}

View File

@ -157,6 +157,10 @@ async fn main() -> std::io::Result<()> {
)
// Statistics controller
.route("/api/stats/global", web::get().to(stats_controller::global))
.route(
"/api/stats/balance_variation",
web::get().to(stats_controller::balance_variation),
)
// Static assets
.route("/", web::get().to(static_controller::root_index))
.route(

View File

@ -6,28 +6,41 @@ use diesel::{Insertable, Queryable, QueryableByName};
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct MovementID(pub i32);
/// Single movement information
#[derive(Queryable, QueryableByName, Debug, Clone, serde::Serialize)]
pub struct Movement {
/// The ID of the movement
id: i32,
/// The ID of the account this movement is attached to
account_id: i32,
/// The time this movement happened
pub time: i64,
/// The label associated to this movement
pub label: String,
/// ID of the file attached to this movement (if any)
file_id: Option<i32>,
/// The amount of the movement
pub amount: f32,
/// Checkbox presented to the user (no impact on code logic)
pub checked: bool,
/// The time this movement was created in the database
pub time_create: i64,
/// The time this movement was last updated in the database
pub time_update: i64,
}
impl Movement {
/// Get the ID of the movement
pub fn id(&self) -> MovementID {
MovementID(self.id)
}
/// The ID of the account attached to the movement
pub fn account_id(&self) -> AccountID {
AccountID(self.account_id)
}
/// The ID of the file attached to the movement, if any
pub fn file_id(&self) -> Option<FileID> {
self.file_id.map(FileID)
}