Compare commits

..

9 Commits

7 changed files with 3013 additions and 50 deletions

View File

@@ -11,7 +11,18 @@ create table account (
id serial primary key, id serial primary key,
name varchar not null, name varchar not null,
login varchar not null, login varchar not null,
birthdate date not null birthdate date not null,
bodymass integer,
constraint fk_bodymass foreign key (bodymass) references bodymass(id)
);
create table bodymass (
id serial primary key,
account integer not null,
time timestamptz not null,
kilos numeric(4,1) not null,
constraint fk_account foreign key (account) references account(id),
constraint unique_account_time unique (account, time),
constraint time_not_infty check (time <> 'infinity'::timestamptz)
); );
create table training ( create table training (
id serial primary key, id serial primary key,
@@ -24,8 +35,22 @@ create table training (
constraint fk_exercise foreign key (exercise) references exercise(id), constraint fk_exercise foreign key (exercise) references exercise(id),
constraint fk_account foreign key (account) references account(id) constraint fk_account foreign key (account) references account(id)
); );
create table message (
id serial primary key,
account integer not null,
time timestamptz not null default timezone('utc', now()),
message varchar not null,
constraint fk_account foreign key (account) references account(id)
);
create view dailylift as select create view dailylift as select
date_trunc('day', time) as time, account, exercise, sum(runs * reps * kilos) as lift date(time) as time, account, exercise, sum(runs * reps * kilos) as lift
from training group by 1, 2, 3; from training group by 1, 2, 3;
create role training_user;
grant select on account to training_user;
grant select on exercise to training_user;
grant select on shorthand to training_user;
grant insert on training to training_user;
grant select on training to training_user;
grant insert on message to training_user;
grant usage on training_id_seq to training_user;

26
db/readme.md Normal file
View File

@@ -0,0 +1,26 @@
## Joining to bodymass table by time
Data in the bodymass table can be transformed as (for account 1):
```sql
select kilos, time as start, (case when lead(time) over (order by time asc) is null then 'infinity'::timestamptz else lead(time) over (order by time asc) end) as end from bodymass where account=1;
```
Thereby giving a table with clear timespans. Joining can then be performed by a nested table:
```
with bm_report as (
select kilos, time as start,
(case when lead(time) over (order by time asc) is null
then 'infinity'::timestamptz
else lead(time) over (order by time asc) end) as end from bodymass
where account=1)
select
t.time, t.exercise, t.kilos, b.kilos as bodymass, round(100*t.kilos/b.kilos, 1) as ratio
from training t
inner join bm_report b on t.time >= b.start and t.time < b.end
where t.account=1 and exercise=1
order by 1;
```

View File

@@ -0,0 +1,286 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": 22,
"links": [],
"panels": [
{
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "aelloyt3t3q4gd"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineStyle": {
"fill": "solid"
},
"lineWidth": 4,
"pointSize": 10,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "masskg"
},
"overrides": []
},
"gridPos": {
"h": 12,
"w": 18,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "aelloyt3t3q4gd"
},
"editorMode": "code",
"format": "time_series",
"rawQuery": true,
"rawSql": "select t.time, e.name as metric, t.kilos \nfrom training t \ninner join exercise e on e.id = t.exercise \nwhere t.account=$account\n and $__timeFilter(time) \norder by 1,2;\n",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Kilos per Exercise",
"type": "timeseries"
},
{
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "aelloyt3t3q4gd"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "masst"
},
"overrides": []
},
"gridPos": {
"h": 12,
"w": 18,
"x": 0,
"y": 12
},
"id": 2,
"options": {
"barRadius": 0,
"barWidth": 0.97,
"fullHighlight": false,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
},
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "aelloyt3t3q4gd"
},
"editorMode": "code",
"format": "time_series",
"rawQuery": true,
"rawSql": "select d.time, e.name as metric, d.lift/1000\nfrom dailylift d \ninner join exercise e on e.id = d.exercise \nwhere d.account=$account\n and $__timeFilter(time) \norder by 1,2;",
"refId": "A",
"sql": {
"columns": [
{
"parameters": [],
"type": "function"
}
],
"groupBy": [
{
"property": {
"type": "string"
},
"type": "groupBy"
}
],
"limit": 50
}
}
],
"title": "Daily Lift",
"type": "barchart"
}
],
"preload": false,
"schemaVersion": 41,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": "Jakob Dalsgaard",
"value": "1"
},
"datasource": {
"type": "grafana-postgresql-datasource",
"uid": "aelloyt3t3q4gd"
},
"definition": "select id as __value, name as __text from account;",
"name": "account",
"options": [],
"query": "select id as __value, name as __text from account;",
"refresh": 1,
"regex": "",
"type": "query"
}
]
},
"time": {
"from": "now-7d",
"to": "now"
},
"timepicker": {},
"timezone": "utc",
"title": "Exercise",
"uid": "eeln0f0zcu2gwe",
"version": 17
}

View File

@@ -41,16 +41,16 @@ of unix domain socket in `/var/run/postgresql`.
Now training can be invoked with: Now training can be invoked with:
``` ```
train squat 3 10 60 train report squat 3 10 60
``` ```
Which would register squauts, 3 runs of 10 reps of 60kg -- at local time and date. Optionally a Which would register squauts, 3 runs of 10 reps of 60kg -- at local time and date. Optionally a
time, date time or rfc3339 timestamp can be specified: time, date time or rfc3339 timestamp can be specified:
``` ```
train squat 3 10 60 "12:05:00" train report squat 3 10 60 "12:05:00"
train squat 3 10 60 "2025-12-24 18:00:00" train report squat 3 10 60 "2025-12-24 18:00:00"
train squat 3 10 60 "2025-12-24T18:00:00+0200" train report squat 3 10 60 "2025-12-24T18:00:00+0200"
``` ```
The two former will source missing date and timezone information from the user session, i.e. The two former will source missing date and timezone information from the user session, i.e.
@@ -59,7 +59,32 @@ type `date` on your commandline to see what you have.
When travelling you might opt for specifying a specific location on the command line, like: When travelling you might opt for specifying a specific location on the command line, like:
``` ```
TZ=Australia/Sydney train squat 3 10 60 TZ=Australia/Sydney train report squat 3 10 60
``` ```
Data in the database can be visualized with, for example, Grafana, more info to follow. To insert messages into the graph, use the `messsage` command:
```
train message "Still F55.3"
```
The message command takes time as an optional 2nd arguments, just as the report command.
Grafana Visaulisation
=====================
In `grafana/training-dashboard.json` a small dashboard is presented, a Postgres data source
must be defined, and the data base user must have access to select from tables in the training database.
```
grant select on account to grafana_query;
grant select on training to grafan_query;
grant select on exercise to grafan_query;
grant select on dailylist to grafan_query;
grant select on message to grafana_query;
```

2541
train-cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,16 @@
[package] [package]
name = "train-cli" name = "train-cli"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
postgres = { version = "0.19.10", features = ["with-chrono-0_4"] } postgres = { version = "0.19.12", features = ["with-chrono-0_4"] }
sqlx = { version = "0.8.5", features = ["rust_decimal"] } sqlx = { version = "0.8.6", features = ["rust_decimal"] }
clap = { version = "4.5.37", features = ["derive"] } clap = { version = "4.5.50", features = ["derive"] }
whoami = "1.6.0" whoami = "1.6.1"
rust_decimal = { version = "1.37.1", features = ["db-postgres"] } rust_decimal = { version = "1.39.0", features = ["db-postgres"] }
chrono = "0.4.41" chrono = "0.4.42"

View File

@@ -1,51 +1,76 @@
use clap::Parser; use clap::{Parser, Subcommand};
use postgres::{Client, NoTls}; use postgres::{Client, NoTls};
use whoami; use whoami;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::FromPrimitive;
use chrono::{DateTime, Local, FixedOffset, TimeZone, NaiveTime, LocalResult, NaiveDateTime}; use chrono::{DateTime, Local, FixedOffset, TimeZone, NaiveTime, LocalResult, NaiveDateTime};
#[derive(Parser, Debug)] #[derive(Parser)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
struct TrainingArgs { struct Cli {
// name of exercise #[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Report {
/// name of exercise: abs, squat, bp, biceps, triceps
exercise: String, exercise: String,
// number of runs /// number of runs
runs: i16, runs: i16,
// number of reps per run /// number of reps per run
reps: i16, reps: i16,
// kilos /// kilos
kilos: f32, kilos: f32,
// time /// time, either "2025-05-20T14:15:20+0200", "2025-12-24 18:00:00" or "17:15:00"
time: Option<String>, time: Option<String>,
},
Message {
/// message to insert
text: String,
/// time, either "2025-05-20T14:15:20+0200", "2025-12-24 18:00:00" or "17:15:00"
time: Option<String>,
},
Latest {
},
} }
fn main() { fn main() {
let args = TrainingArgs::parse(); let args = Cli::parse();
insert_training(args); match &args.command {
Commands::Report { exercise, runs, reps, kilos, time } => insert_training(exercise, runs, reps, kilos, time),
Commands::Message { text, time } => insert_message(text, time),
Commands::Latest {} => get_latest(),
}
} }
fn parse_time (time: String) -> Option<DateTime<FixedOffset>> { fn get_client() -> Client {
Client::connect("dbname=training host=/var/run/postgresql", NoTls).unwrap()
}
fn parse_time (time: &String) -> Option<DateTime<FixedOffset>> {
// check if time is an rfc3339 formatted timestamp, i.e. "2025-05-20T14:30:10+0200" (can also // check if time is an rfc3339 formatted timestamp, i.e. "2025-05-20T14:30:10+0200" (can also
// have milliseconds // have milliseconds
let datetimetz = DateTime::parse_from_rfc3339(&time); let datetimetz = DateTime::parse_from_rfc3339(time);
if let Ok(dt) = datetimetz { if let Ok(dt) = datetimetz {
return Some(dt); return Some(dt);
} }
// check if time is a simple "date time" without timezone, then apply user session // check if time is a simple "date time" without timezone, then apply user session
// time zone // time zone
let naive_datetime = NaiveDateTime::parse_from_str(&time, "%Y-%m-%d %H:%M:%S"); let naive_datetime = NaiveDateTime::parse_from_str(time, "%Y-%m-%d %H:%M:%S");
if let Ok(ndt) = naive_datetime { if let Ok(ndt) = naive_datetime {
let dt: DateTime<Local> = Local.from_local_datetime(&ndt).unwrap(); let dt: DateTime<Local> = Local.from_local_datetime(&ndt).unwrap();
return Some(dt.into()); return Some(dt.into());
} }
// check if time is merely a simple hour:minute:second -- then apply // check if time is merely a simple hour:minute:second -- then apply
// current date from user session // current date from user session
let naive_time = NaiveTime::parse_from_str(&time, "%H:%M:%S"); let naive_time = NaiveTime::parse_from_str(time, "%H:%M:%S");
if let Ok(nt) = naive_time { if let Ok(nt) = naive_time {
return match Local::now().with_time(nt) { return match Local::now().with_time(nt) {
LocalResult::Single(dt) => Some(dt.into()), LocalResult::Single(dt) => Some(dt.into()),
@@ -56,27 +81,62 @@ fn parse_time (time: String) -> Option<DateTime<FixedOffset>> {
return None; return None;
} }
fn with_valid_time<F>(time: &Option<String>, closure: F) ->()
fn insert_training (args: TrainingArgs) { where F: Fn(Client, DateTime<FixedOffset>) -> () {
let mut client = Client::connect("dbname=training host=/var/run/postgresql", NoTls).unwrap(); let client = get_client();
let dec_kilos = Decimal::from_f32(args.kilos).unwrap(); let dt = match time {
let dt = match args.time {
Some(time) => parse_time(time), Some(time) => parse_time(time),
None => Some(Local::now().into()), None => Some(Local::now().into()),
}; };
match dt { match dt {
None => println!("Invalid time/date specified"), None => println!("Invalid time/date specified"),
Some(time) => { Some(time) => {
let res = client.execute("insert into training values closure(client, time);
}
}
}
fn insert_message (message: &String, time: &Option<String>) {
with_valid_time(time, move |mut c, t| {
let res = c.execute("insert into message (account, time, message) values ((select id from account where login=$1), $2, $3)",
&[&whoami::username(), &t, &message]);
match res {
Ok(_inserted) => println!("Message inserted"),
Err(e) => println!("Message not inserted, since {}", e),
}
});
}
fn insert_training (exercise: &String, runs: &i16, reps: &i16, kilos: &f32, time: &Option<String>) {
with_valid_time(time, move |mut c, t| {
let dec_kilos = Decimal::from_f32(*kilos).unwrap();
let res = c.execute("insert into training values
(default, (select id from account where login=$1), $2, (default, (select id from account where login=$1), $2,
(select e.id from exercise e inner join shorthand s on s.exercise=e.id where s.name=$3), $4, $5, $6)", (select e.id from exercise e inner join shorthand s on s.exercise=e.id where s.name=$3), $4, $5, $6)",
&[&whoami::username(), &time, &args.exercise, &args.runs, &args.reps, &dec_kilos]); &[&whoami::username(), &t, &exercise, &runs, &reps, &dec_kilos]);
match res { match res {
Ok(_inserted) => println!("Training inserted"), Ok(_inserted) => println!("Training inserted"),
Err(e) => println!("Training not inserted, since: {}", e) Err(e) => println!("Training not inserted, since: {}", e)
} }
} });
} }
fn get_latest () {
let mut client = get_client();
let res = client.query("with report as (select distinct on (t.exercise) t.time, t.exercise, t.kilos
from training t inner join account a on a.id=t.account where a.login=$1 order by 2, 1 desc)
select date_trunc('second', r.time), e.name, r.kilos from report r inner join exercise e on e.id=r.exercise order by 2",
&[&whoami::username()]);
match res {
Ok(resultset) => {
for r in resultset {
let time : DateTime<Local> = r.get(0);
let exercise : String= r.get(1);
let kilos: Decimal = r.get(2);
println!("{time} {exercise:>15} {kilos:>5}", time=time, exercise=exercise, kilos=kilos);
}
},
Err(e) => println!("Report not fetched, since: {}", e)
}
} }