Compare commits

..

7 Commits

6 changed files with 2711 additions and 48 deletions

View File

@@ -11,7 +11,18 @@ create table account (
id serial primary key,
name 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 (
id serial primary key,
@@ -24,8 +35,22 @@ create table training (
constraint fk_exercise foreign key (exercise) references exercise(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
date(time) as time, account, exercise, sum(runs * reps * kilos) as lift
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

@@ -41,16 +41,16 @@ of unix domain socket in `/var/run/postgresql`.
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
time, date time or rfc3339 timestamp can be specified:
```
train squat 3 10 60 "12:05:00"
train 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 "12:05:00"
train report squat 3 10 60 "2025-12-24 18:00:00"
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.
@@ -59,9 +59,19 @@ 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:
```
TZ=Australia/Sydney train squat 3 10 60
TZ=Australia/Sydney train report squat 3 10 60
```
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
=====================
@@ -73,6 +83,7 @@ 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]
name = "train-cli"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
postgres = { version = "0.19.10", features = ["with-chrono-0_4"] }
sqlx = { version = "0.8.5", features = ["rust_decimal"] }
clap = { version = "4.5.37", features = ["derive"] }
whoami = "1.6.0"
rust_decimal = { version = "1.37.1", features = ["db-postgres"] }
chrono = "0.4.41"
postgres = { version = "0.19.12", features = ["with-chrono-0_4"] }
sqlx = { version = "0.8.6", features = ["rust_decimal"] }
clap = { version = "4.5.50", features = ["derive"] }
whoami = "1.6.1"
rust_decimal = { version = "1.39.0", features = ["db-postgres"] }
chrono = "0.4.42"

View File

@@ -1,51 +1,76 @@
use clap::Parser;
use clap::{Parser, Subcommand};
use postgres::{Client, NoTls};
use whoami;
use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use chrono::{DateTime, Local, FixedOffset, TimeZone, NaiveTime, LocalResult, NaiveDateTime};
#[derive(Parser, Debug)]
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct TrainingArgs {
// name of exercise
exercise: String,
struct Cli {
#[command(subcommand)]
command: Commands,
}
// number of runs
runs: i16,
#[derive(Subcommand)]
enum Commands {
Report {
/// name of exercise: abs, squat, bp, biceps, triceps
exercise: String,
// number of reps per run
reps: i16,
/// number of runs
runs: i16,
// kilos
kilos: f32,
/// number of reps per run
reps: i16,
// time
time: Option<String>,
/// kilos
kilos: f32,
/// time, either "2025-05-20T14:15:20+0200", "2025-12-24 18:00:00" or "17:15:00"
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() {
let args = TrainingArgs::parse();
insert_training(args);
let args = Cli::parse();
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
// have milliseconds
let datetimetz = DateTime::parse_from_rfc3339(&time);
let datetimetz = DateTime::parse_from_rfc3339(time);
if let Ok(dt) = datetimetz {
return Some(dt);
}
// check if time is a simple "date time" without timezone, then apply user session
// 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 {
let dt: DateTime<Local> = Local.from_local_datetime(&ndt).unwrap();
return Some(dt.into());
}
// check if time is merely a simple hour:minute:second -- then apply
// 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 {
return match Local::now().with_time(nt) {
LocalResult::Single(dt) => Some(dt.into()),
@@ -56,27 +81,62 @@ fn parse_time (time: String) -> Option<DateTime<FixedOffset>> {
return None;
}
fn insert_training (args: TrainingArgs) {
let mut client = Client::connect("dbname=training host=/var/run/postgresql", NoTls).unwrap();
let dec_kilos = Decimal::from_f32(args.kilos).unwrap();
let dt = match args.time {
fn with_valid_time<F>(time: &Option<String>, closure: F) ->()
where F: Fn(Client, DateTime<FixedOffset>) -> () {
let client = get_client();
let dt = match time {
Some(time) => parse_time(time),
None => Some(Local::now().into()),
};
match dt {
None => println!("Invalid time/date specified"),
Some(time) => {
let res = client.execute("insert into training values
(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)",
&[&whoami::username(), &time, &args.exercise, &args.runs, &args.reps, &dec_kilos]);
match res {
Ok(_inserted) => println!("Training inserted"),
Err(e) => println!("Training not inserted, since: {}", e)
}
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,
(select e.id from exercise e inner join shorthand s on s.exercise=e.id where s.name=$3), $4, $5, $6)",
&[&whoami::username(), &t, &exercise, &runs, &reps, &dec_kilos]);
match res {
Ok(_inserted) => println!("Training inserted"),
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)
}
}