Initial working state
This commit is contained in:
9 files changed
+1600
No files matched your search
@@ -0,0 +1,108 @@
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn init(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS exercises (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
exercise_id INTEGER NOT NULL REFERENCES exercises(id),
|
||||
date TEXT NOT NULL,
|
||||
reps INTEGER NOT NULL,
|
||||
UNIQUE(exercise_id, date)
|
||||
);",
|
||||
)
|
||||
.expect("failed to initialize schema");
|
||||
}
|
||||
|
||||
pub fn list_exercises(conn: &Connection) -> Vec<String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT name FROM exercises ORDER BY name")
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns Err(()) if an exercise with this name already exists.
|
||||
pub fn add_exercise(conn: &Connection, name: &str) -> Result<(), ()> {
|
||||
conn.execute("INSERT INTO exercises (name) VALUES (?1)", params![name])
|
||||
.map(|_| ())
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Returns Err(()) if the exercise name is unknown.
|
||||
pub fn upsert_entry(conn: &Connection, exercise_name: &str, date: &str, reps: i64) -> Result<(), ()> {
|
||||
let exercise_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM exercises WHERE name = ?1",
|
||||
params![exercise_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.ok_or(())?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO entries (exercise_id, date, reps) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(exercise_id, date) DO UPDATE SET reps = excluded.reps",
|
||||
params![exercise_id, date, reps],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Sum of reps per exercise name, for entries with date >= `since` (inclusive).
|
||||
/// Pass "" to include all entries.
|
||||
pub fn totals_since(conn: &Connection, since: &str) -> HashMap<String, i64> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.name, SUM(en.reps)
|
||||
FROM entries en JOIN exercises e ON e.id = en.exercise_id
|
||||
WHERE en.date >= ?1
|
||||
GROUP BY e.name",
|
||||
)
|
||||
.unwrap();
|
||||
stmt.query_map(params![since], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reps per day-of-month for entries whose date starts with `year_month`
|
||||
/// (e.g. "2026-09"), keyed by exercise name then day-of-month (1-31).
|
||||
pub fn daily_totals_for_month(
|
||||
conn: &Connection,
|
||||
year_month: &str,
|
||||
) -> HashMap<String, HashMap<u8, i64>> {
|
||||
let like = format!("{year_month}-%");
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT e.name, CAST(substr(en.date, 9, 2) AS INTEGER), SUM(en.reps)
|
||||
FROM entries en JOIN exercises e ON e.id = en.exercise_id
|
||||
WHERE en.date LIKE ?1
|
||||
GROUP BY e.name, substr(en.date, 9, 2)",
|
||||
)
|
||||
.unwrap();
|
||||
let mut out: HashMap<String, HashMap<u8, i64>> = HashMap::new();
|
||||
for row in stmt
|
||||
.query_map(params![like], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)? as u8,
|
||||
row.get::<_, i64>(2)?,
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
{
|
||||
let (name, day, reps) = row.unwrap();
|
||||
out.entry(name).or_default().insert(day, reps);
|
||||
}
|
||||
out
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
mod db;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, FromRef, State},
|
||||
response::{Html, Redirect},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar};
|
||||
use rusqlite::Connection;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use time::{Date, Month, OffsetDateTime};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
admin_password: String,
|
||||
cookie_key: Key,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Key {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.cookie_key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_date(d: Date) -> String {
|
||||
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
|
||||
}
|
||||
|
||||
fn require_auth(jar: &PrivateCookieJar) -> Result<(), Redirect> {
|
||||
if jar.get("auth").is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Redirect::to("/login"))
|
||||
}
|
||||
}
|
||||
|
||||
struct DayBar {
|
||||
day: u8,
|
||||
reps: i64,
|
||||
pct: u32,
|
||||
}
|
||||
|
||||
struct ExerciseStats {
|
||||
name: String,
|
||||
month: i64,
|
||||
year: i64,
|
||||
all_time: i64,
|
||||
daily: Vec<DayBar>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
exercises: Vec<ExerciseStats>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "manage.html")]
|
||||
struct ManageTemplate {
|
||||
exercises: Vec<String>,
|
||||
today: String,
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Html<String> {
|
||||
let conn = state.conn.lock().unwrap();
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let month_start = fmt_date(today.replace_day(1).unwrap());
|
||||
let year_start = fmt_date(Date::from_calendar_date(today.year(), Month::January, 1).unwrap());
|
||||
let month_prefix = format!("{:04}-{:02}", today.year(), u8::from(today.month()));
|
||||
let days_in_month = today.month().length(today.year());
|
||||
|
||||
let month_totals = db::totals_since(&conn, &month_start);
|
||||
let year_totals = db::totals_since(&conn, &year_start);
|
||||
let all_time_totals = db::totals_since(&conn, "");
|
||||
let daily_totals = db::daily_totals_for_month(&conn, &month_prefix);
|
||||
|
||||
let exercises = db::list_exercises(&conn)
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let month = *month_totals.get(&name).unwrap_or(&0);
|
||||
let year = *year_totals.get(&name).unwrap_or(&0);
|
||||
let all_time = *all_time_totals.get(&name).unwrap_or(&0);
|
||||
|
||||
let empty = HashMap::new();
|
||||
let by_day = daily_totals.get(&name).unwrap_or(&empty);
|
||||
let max = by_day.values().copied().max().unwrap_or(0).max(1);
|
||||
let daily = (1..=days_in_month)
|
||||
.map(|day| {
|
||||
let reps = *by_day.get(&day).unwrap_or(&0);
|
||||
let pct = (reps as f64 / max as f64 * 100.0).round() as u32;
|
||||
DayBar { day, reps, pct }
|
||||
})
|
||||
.collect();
|
||||
|
||||
ExerciseStats { name, month, year, all_time, daily }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Html(IndexTemplate { exercises }.render().unwrap())
|
||||
}
|
||||
|
||||
async fn login_get() -> Html<String> {
|
||||
Html(LoginTemplate { error: None }.render().unwrap())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: PrivateCookieJar,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Result<(PrivateCookieJar, Redirect), Html<String>> {
|
||||
if form.password == state.admin_password {
|
||||
let jar = jar.add(Cookie::new("auth", "1"));
|
||||
Ok((jar, Redirect::to("/manage")))
|
||||
} else {
|
||||
Err(Html(
|
||||
LoginTemplate { error: Some("Wrong password.".to_string()) }
|
||||
.render()
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn logout(jar: PrivateCookieJar) -> (PrivateCookieJar, Redirect) {
|
||||
let jar = jar.remove(Cookie::from("auth"));
|
||||
(jar, Redirect::to("/"))
|
||||
}
|
||||
|
||||
async fn manage(
|
||||
State(state): State<AppState>,
|
||||
jar: PrivateCookieJar,
|
||||
) -> Result<Html<String>, Redirect> {
|
||||
require_auth(&jar)?;
|
||||
let conn = state.conn.lock().unwrap();
|
||||
let exercises = db::list_exercises(&conn);
|
||||
let today = fmt_date(OffsetDateTime::now_utc().date());
|
||||
Ok(Html(ManageTemplate { exercises, today }.render().unwrap()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExerciseForm {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn add_exercise(
|
||||
State(state): State<AppState>,
|
||||
jar: PrivateCookieJar,
|
||||
Form(form): Form<ExerciseForm>,
|
||||
) -> Result<Redirect, Redirect> {
|
||||
require_auth(&jar)?;
|
||||
let conn = state.conn.lock().unwrap();
|
||||
let _ = db::add_exercise(&conn, form.name.trim());
|
||||
Ok(Redirect::to("/manage"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EntryForm {
|
||||
exercise: String,
|
||||
date: String,
|
||||
reps: i64,
|
||||
}
|
||||
|
||||
async fn add_entry(
|
||||
State(state): State<AppState>,
|
||||
jar: PrivateCookieJar,
|
||||
Form(form): Form<EntryForm>,
|
||||
) -> Result<Redirect, Redirect> {
|
||||
require_auth(&jar)?;
|
||||
let conn = state.conn.lock().unwrap();
|
||||
let _ = db::upsert_entry(&conn, &form.exercise, &form.date, form.reps);
|
||||
Ok(Redirect::to("/manage"))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let admin_password =
|
||||
std::env::var("ADMIN_PASSWORD").expect("ADMIN_PASSWORD env var must be set");
|
||||
|
||||
let conn = Connection::open("data.db").expect("failed to open database");
|
||||
db::init(&conn);
|
||||
|
||||
let state = AppState {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
admin_password,
|
||||
cookie_key: Key::generate(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/login", get(login_get).post(login_post))
|
||||
.route("/logout", post(logout))
|
||||
.route("/manage", get(manage))
|
||||
.route("/exercises", post(add_exercise))
|
||||
.route("/entries", post(add_entry))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
println!("listening on http://0.0.0.0:3000");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user