diff --git a/moneymgr_backend/src/controllers/stats_controller.rs b/moneymgr_backend/src/controllers/stats_controller.rs index e5dac09..851bc66 100644 --- a/moneymgr_backend/src/controllers/stats_controller.rs +++ b/moneymgr_backend/src/controllers/stats_controller.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end: Option, +} + +/// 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, +} + +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, +) -> 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)) +} diff --git a/moneymgr_backend/src/main.rs b/moneymgr_backend/src/main.rs index 38acc22..a4a3cd8 100644 --- a/moneymgr_backend/src/main.rs +++ b/moneymgr_backend/src/main.rs @@ -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( diff --git a/moneymgr_backend/src/models/movements.rs b/moneymgr_backend/src/models/movements.rs index 2f0f01f..824d26f 100644 --- a/moneymgr_backend/src/models/movements.rs +++ b/moneymgr_backend/src/models/movements.rs @@ -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, + /// 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 { self.file_id.map(FileID) } diff --git a/moneymgr_web/package-lock.json b/moneymgr_web/package-lock.json index 6d1174a..cca96de 100644 --- a/moneymgr_web/package-lock.json +++ b/moneymgr_web/package-lock.json @@ -16,6 +16,7 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^7.0.1", "@mui/material": "^7.0.1", + "@mui/x-charts": "^8.2.0", "@mui/x-data-grid": "^7.28.3", "@mui/x-date-pickers": "^8.0.0-beta.3", "date-and-time": "^3.6.0", @@ -1541,12 +1542,12 @@ } }, "node_modules/@mui/types": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.0.tgz", - "integrity": "sha512-TxJ4ezEeedWHBjOmLtxI203a9DII9l4k83RXmz1PYSAmnyEcK2PglTNmJGxswC/wM5cdl9ap2h8lnXvt2swAGQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz", + "integrity": "sha512-gUL8IIAI52CRXP/MixT1tJKt3SI6tVv4U/9soFsTtAsHzaJQptZ42ffdHZV3niX1ei0aUgMvOxBBN0KYqdG39g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.26.10" + "@babel/runtime": "^7.27.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1558,17 +1559,17 @@ } }, "node_modules/@mui/utils": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.0.1.tgz", - "integrity": "sha512-SJKrrebNpmK9rJCnVL29nGPhPXQYtBZmb7Dsp0f58uIUhQfAKcBXHE4Kjs06SX4CwqeCuwEVgcHY+MgAO6XQ/g==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.0.2.tgz", + "integrity": "sha512-72gcuQjPzhj/MLmPHLCgZjy2VjOH4KniR/4qRtXTTXIEwbkgcN+Y5W/rC90rWtMmZbjt9svZev/z+QHUI4j74w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.26.10", - "@mui/types": "^7.4.0", + "@babel/runtime": "^7.27.0", + "@mui/types": "^7.4.1", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.0.0" + "react-is": "^19.1.0" }, "engines": { "node": ">=14.0.0" @@ -1587,6 +1588,87 @@ } } }, + "node_modules/@mui/x-charts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.2.0.tgz", + "integrity": "sha512-Onf9ZrZmoTz3awrOKXtMDHqTXroGSdDJismIVQP71MHEcoOB+qvNDaekUJqkx8jGQwldTQnwDpkzXCQaiX9RRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.2", + "@mui/x-charts-vendor": "8.0.0", + "@mui/x-internals": "8.2.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.0.0.tgz", + "integrity": "sha512-aXv0QlCTkVxSNX+sHdG92jaQMEWJFw2NuxBx599JyZ5Ij038JwdU9x0dArfPdtpdCX0A19lHKHYgZ8S0I4LpnQ==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@types/d3-color": "^3.1.3", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1", + "delaunator": "^5.0.1", + "robust-predicates": "^3.0.2" + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.2.0.tgz", + "integrity": "sha512-qV4Qr+m4sAPBSuqu8/Ofi5m+nMMvIybGno6cp757bHSmwxkqrn5SKaGyFnH5kB58fOhYA9hG1UivFp7mO1dE4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/x-data-grid": { "version": "7.28.3", "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.28.3.tgz", @@ -2096,6 +2178,63 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2474,6 +2613,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/birecord": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.1.tgz", @@ -2690,6 +2835,130 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/date-and-time": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz", @@ -2726,6 +2995,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3355,6 +3633,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3928,9 +4215,9 @@ } }, "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", + "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "license": "MIT" }, "node_modules/react-refresh": { @@ -4051,6 +4338,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.36.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.36.0.tgz", @@ -4390,9 +4683,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/moneymgr_web/package.json b/moneymgr_web/package.json index 1c9147f..dfdd22b 100644 --- a/moneymgr_web/package.json +++ b/moneymgr_web/package.json @@ -18,6 +18,7 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^7.0.1", "@mui/material": "^7.0.1", + "@mui/x-charts": "^8.2.0", "@mui/x-data-grid": "^7.28.3", "@mui/x-date-pickers": "^8.0.0-beta.3", "date-and-time": "^3.6.0", diff --git a/moneymgr_web/src/api/MovementsApi.ts b/moneymgr_web/src/api/MovementsApi.ts index ab5acf9..3674044 100644 --- a/moneymgr_web/src/api/MovementsApi.ts +++ b/moneymgr_web/src/api/MovementsApi.ts @@ -1,6 +1,6 @@ import { APIClient } from "./ApiClient"; -type Balances = Record; +export type Balances = Record; export interface MovementUpdate { account_id: number; diff --git a/moneymgr_web/src/api/StatsApi.ts b/moneymgr_web/src/api/StatsApi.ts index 8dbff0a..0c86418 100644 --- a/moneymgr_web/src/api/StatsApi.ts +++ b/moneymgr_web/src/api/StatsApi.ts @@ -7,6 +7,8 @@ export interface GlobalStats { total_files_size: number; } +export type StatBalanceVariation = Record & { time: number }; + export class StatsApi { /** * Get global statistics @@ -19,4 +21,20 @@ export class StatsApi { }) ).data; } + + /** + * Get balance variation statistics + */ + static async BalanceVariationStats( + start: number, + end: number, + interval: number + ): Promise { + return ( + await APIClient.exec({ + uri: `/stats/balance_variation?start=${start}&end=${end}&interval=${interval}`, + method: "GET", + }) + ).data; + } } diff --git a/moneymgr_web/src/routes/HomeRoute.tsx b/moneymgr_web/src/routes/HomeRoute.tsx index 814dff1..731fc20 100644 --- a/moneymgr_web/src/routes/HomeRoute.tsx +++ b/moneymgr_web/src/routes/HomeRoute.tsx @@ -1,22 +1,49 @@ +import FolderCopyIcon from "@mui/icons-material/FolderCopy"; import FunctionsIcon from "@mui/icons-material/Functions"; +import ImportExportIcon from "@mui/icons-material/ImportExport"; import RefreshIcon from "@mui/icons-material/Refresh"; +import ScaleIcon from "@mui/icons-material/Scale"; import { Grid, IconButton, Paper, Tooltip, Typography } from "@mui/material"; +import { LineChart } from "@mui/x-charts/LineChart"; +import { filesize } from "filesize"; import React from "react"; -import { GlobalStats, StatsApi } from "../api/StatsApi"; +import { GlobalStats, StatBalanceVariation, StatsApi } from "../api/StatsApi"; +import { useAccounts } from "../hooks/AccountsListProvider"; +import { fmtDateFromTime, time } from "../utils/DateUtils"; +import { AmountWidget } from "../widgets/AmountWidget"; import { AsyncWidget } from "../widgets/AsyncWidget"; import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; -import ImportExportIcon from "@mui/icons-material/ImportExport"; -import FolderCopyIcon from "@mui/icons-material/FolderCopy"; -import ScaleIcon from "@mui/icons-material/Scale"; -import { filesize } from "filesize"; -import { AmountWidget } from "../widgets/AmountWidget"; export function HomeRoute(): React.ReactElement { + const account = useAccounts(); + const loadKey = React.useRef(1); + const [global, setGlobal] = React.useState(); + const [lastYear, setLastYear] = React.useState< + StatBalanceVariation[] | undefined + >(); const load = async () => { setGlobal(await StatsApi.GetGlobal()); + const lastyear = await StatsApi.BalanceVariationStats( + time() - 3600 * 24 * 365, + time(), + 3600 * 24 + ); + + // Manually compute sum + for (const entry of lastyear) { + let sum = 0; + + for (const a of account.list.list) { + sum += entry[a.id.toString()]; + } + + entry["sum"] = sum; + } + + setLastYear(lastyear); }; const reload = async () => { @@ -40,17 +67,19 @@ export function HomeRoute(): React.ReactElement { loadKey={loadKey.current} load={load} errMsg="Failed to load statistics!" - build={() => } + build={() => } /> ); } -export function StatsDashboard(p: { global: GlobalStats }): React.ReactElement { +export function StatsDashboard(p: { + global: GlobalStats; + lastYear: StatBalanceVariation[]; +}): React.ReactElement { return ( <> - {" "} } @@ -72,6 +101,22 @@ export function StatsDashboard(p: { global: GlobalStats }): React.ReactElement { icon={} /> + + + + + + + + + + + + ); } @@ -82,22 +127,81 @@ export function StatTile(p: { icon: React.ReactElement; }): React.ReactElement { return ( - - -
- {p.icon} - - {p.title} - - {p.value} -
+ + + {p.icon} + + {p.title} + + {p.value} ); } + +export function StatChart(p: { + label: string; + start?: number; + lastYear: StatBalanceVariation[]; +}): React.ReactElement { + const accounts = useAccounts(); + + return ( +
+ + {p.label} + + { + return { + id: a.id, + label: a.name, + dataKey: a.id.toString(), + stack: "total", + area: false, + showMark: false, + }; + }), + ]} + height={400} + hideLegend + /> +
+ ); +} diff --git a/moneymgr_web/src/utils/DateUtils.tsx b/moneymgr_web/src/utils/DateUtils.tsx index c087ab9..4b784b6 100644 --- a/moneymgr_web/src/utils/DateUtils.tsx +++ b/moneymgr_web/src/utils/DateUtils.tsx @@ -18,3 +18,13 @@ export function dateToTime(date: Dayjs | undefined): number | undefined { export function time(): number { return Math.floor(new Date().getTime() / 1000); } + +/** + * Format date from unix time (secondes since Epoch) + */ +export function fmtDateFromTime(time: number): string { + const d = new Date(time * 1000); + return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1) + .toString() + .padStart(2, "0")}/${d.getFullYear()}`; +}