Added net worth trend chart
This commit is contained in:
parent
759f91a9a2
commit
b922895f42
4 changed files with 127 additions and 3 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,10 @@ video {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue