Added net worth trend chart

This commit is contained in:
Manuel Forcén Muñoz 2024-05-15 13:30:59 +02:00
parent 759f91a9a2
commit b922895f42
4 changed files with 127 additions and 3 deletions

View file

@ -15,6 +15,12 @@ pub struct Transaction {
accumulated: i32, accumulated: i32,
} }
#[derive(FromRow, Serialize, Deserialize, Debug)]
pub struct TransactionAggregated {
tx_date: DateTime<Utc>,
accumulated: i32,
}
impl Transaction { impl Transaction {
pub async fn new( pub async fn new(
pool: &SqlitePool, pool: &SqlitePool,
@ -151,6 +157,39 @@ impl Transaction {
Ok(res) Ok(res)
} }
pub async fn group_by_date(
pool: &SqlitePool,
account: i32,
after: Option<DateTime<Utc>>,
before: Option<DateTime<Utc>>,
asc: bool,
) -> Result<Vec<TransactionAggregated>> {
let mut query =
sqlx::QueryBuilder::new("SELECT accumulated, tx_date FROM transactions WHERE account=");
query.push_bind(account);
if let Some(a) = after {
query.push(" AND tx_date >= ");
query.push_bind(a);
}
if let Some(b) = before {
query.push(" AND tx_date <= ");
query.push_bind(b);
}
query.push(" GROUP BY tx_date HAVING max(tx_order)");
let rows = query.build().fetch_all(pool).await?;
let mut res = Vec::new();
for r in &rows {
res.push(TransactionAggregated::from_row(r)?);
}
Ok(res)
}
pub fn get_id(&self) -> i32 { pub fn get_id(&self) -> i32 {
self.transaction_id self.transaction_id
} }

View file

@ -588,6 +588,10 @@ video {
} }
} }
.relative {
position: relative;
}
.mb-2 { .mb-2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View file

@ -7,7 +7,20 @@
<a href="/accounts/id/{{account.account_id}}/transactions/add">+</a> <a href="/accounts/id/{{account.account_id}}/transactions/add">+</a>
</div> </div>
</div> </div>
<div> <div class="mb-4">
<h2>Net amount</h2>
<div class="ars-input">
<label>
Dates
<input id="amount-date-range" onselect="onDateChange(event)"/>
</label>
</div>
<div style="height: 400px; width: 800px; position: relative;">
<canvas id="amount-trend"></canvas>
</div>
</div>
<div class="mb-2">
<h2>Last transactions</h2>
<table width="100%"> <table width="100%">
<thead> <thead>
<tr> <tr>
@ -58,12 +71,49 @@
border-spacing: 0.2rem; border-spacing: 0.2rem;
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/litepicker/dist/litepicker.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
function onDateChange(e) {
console.log(e);
}
function onSelect(e) { function onSelect(e) {
let params = new URLSearchParams(); let params = new URLSearchParams();
params.set("entries", e.target.value); params.set("entries", e.target.value);
window.location.search = params.toString(); window.location.search = params.toString();
} }
const dateEl = document.getElementById('amount-date-range');
const picker = new Litepicker({
element: dateEl,
singleMode: false,
maxDate: new Date(),
startDate: "{{date_from}}",
endDate: "{{date_to}}"
});
dateEl.addEventListener('input', (e) => console.log(e));
function formatDate(date) {
return date.substr(0, date.indexOf('T'));
}
const data = [
{% for txag in tx_agg -%}
{x: formatDate("{{txag.tx_date}}"), y: {{txag.accumulated/100}} },
{% endfor %}
];
const ctx = document.getElementById('amount-trend');
const chart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{label: 'Account', data: data}],
},
});
</script> </script>
{% endblock body %} {% endblock body %}

View file

@ -5,7 +5,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Json, Json,
}; };
use chrono::{DateTime, Utc}; use chrono::{Date, DateTime, Duration, DurationRound, TimeZone, Utc};
use hyper::{header::CONTENT_TYPE, StatusCode}; use hyper::{header::CONTENT_TYPE, StatusCode};
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@ -16,16 +16,31 @@ use accounters::models::{account::Account, categories::Category, transaction::Tr
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct AccountViewParams { pub struct AccountViewParams {
from: Option<String>,
to: Option<String>,
entries: Option<i32>, entries: Option<i32>,
page: Option<i32>, page: Option<i32>,
} }
fn parse_date(s: &str) -> Option<DateTime<Utc>> {
let mut iter = s.split('-');
let year = iter.next()?.parse::<i32>().ok()?;
let month = iter.next()?.parse::<u32>().ok()?;
let day = iter.next()?.parse::<u32>().ok()?;
Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).single()
}
pub async fn list( pub async fn list(
State(db): State<Arc<SqlitePool>>, State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>, State(tmpls): State<Arc<Tera>>,
uid: UserToken, uid: UserToken,
Path(account_id): Path<i32>, Path(account_id): Path<i32>,
Query(AccountViewParams { entries, page }): Query<AccountViewParams>, Query(AccountViewParams {
from,
to,
entries,
page,
}): Query<AccountViewParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mut ctx = Context::new(); let mut ctx = Context::new();
@ -48,6 +63,22 @@ pub async fn list(
); );
} }
let from = from
.and_then(|x| parse_date(&x))
.unwrap_or(Utc::now().duration_trunc(Duration::days(1)).unwrap() - Duration::days(30));
let to = to
.and_then(|x| parse_date(&x))
.unwrap_or(Utc::now().duration_trunc(Duration::days(1)).unwrap());
ctx.insert("date_from", &from);
ctx.insert("date_to", &to);
let tx_agg = Transaction::group_by_date(db.as_ref(), account_id, Some(from), Some(to), false)
.await
.unwrap();
ctx.insert("tx_agg", &tx_agg);
let categories: HashMap<i32, String> = Category::list(db.as_ref()) let categories: HashMap<i32, String> = Category::list(db.as_ref())
.await .await
.unwrap() .unwrap()