Initial working state

This commit is contained in:
structix committed 2026-09-21 21:30:45 +02:00
1 parent 4d3877f161
commit b84b0c4138
9 files changed
+1600

No files matched your search

+108
View File
@@ -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
View File
@@ -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();
}