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

+8
View File
@@ -16,3 +16,11 @@ target/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Added by cargo
/target
# Runtime SQLite database
/data.db
Generated
+1132
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "continuous-training"
version = "0.1.0"
edition = "2024"
[dependencies]
askama = "0.16.1"
axum = "0.8.9"
axum-extra = { version = "0.12.6", features = ["cookie", "cookie-private"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
time = { version = "0.3.55", features = ["macros"] }
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
+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();
}
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Exercise Tracker{% endblock %}</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem;
color: #1a1a1a; background: #fafafa;
}
h1 { font-size: 1.5rem; }
nav { margin-bottom: 1.5rem; display: flex; align-items: center; gap: 1rem; }
nav a { color: #4a5dff; text-decoration: none; font-weight: 600; }
nav a:hover { text-decoration: underline; }
nav form { margin: 0; }
nav button { background: none; border: none; color: #666; cursor: pointer; font-size: 0.9rem; padding: 0; }
.card {
background: #fff; border: 1px solid #e3e3e3; border-radius: 10px;
padding: 1rem 1.25rem; margin-bottom: 1.25rem;
}
.card h2 { margin: 0 0 0.75rem; font-size: 1.1rem; }
.stats { display: flex; gap: 1.75rem; margin-bottom: 1rem; }
.stats .num { display: block; font-size: 1.4rem; font-weight: 700; line-height: 1.2; }
.stats .label { display: block; font-size: 0.75rem; color: #777; }
.chart {
display: flex; align-items: flex-end; gap: 2px;
height: 70px; background: #f0f1f5; border-radius: 6px; padding: 6px;
}
.bar { flex: 1; min-height: 2px; background: #4a5dff; border-radius: 2px 2px 0 0; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; }
th, td { text-align: left; padding: 0.4rem 0.8rem 0.4rem 0; border-bottom: 1px solid #ddd; }
form { margin-bottom: 1.5rem; }
label { display: block; margin-bottom: 0.3rem; }
input, select {
font-size: 1rem; padding: 0.4rem; margin-bottom: 0.6rem;
border: 1px solid #ccc; border-radius: 6px;
}
button {
font-size: 1rem; padding: 0.45rem 0.9rem; margin-bottom: 0.6rem;
background: #4a5dff; color: #fff; border: none; border-radius: 6px; cursor: pointer;
}
button:hover { background: #3a4bdb; }
.error { color: #b00020; }
</style>
</head>
<body>
<nav>
<a href="/">Stats</a>
{% block nav_extra %}{% endblock %}
</nav>
{% block content %}{% endblock %}
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block nav_extra %}<a href="/manage">Manage</a>{% endblock %}
{% block content %}
<h1>Exercise Stats</h1>
{% if exercises.is_empty() %}
<p>No exercises yet.</p>
{% else %}
{% for e in exercises %}
<section class="card">
<h2>{{ e.name }}</h2>
<div class="stats">
<div><span class="num">{{ e.month }}</span><span class="label">this month</span></div>
<div><span class="num">{{ e.year }}</span><span class="label">this year</span></div>
<div><span class="num">{{ e.all_time }}</span><span class="label">all time</span></div>
</div>
<div class="chart">
{% for bar in e.daily %}
<div class="bar" style="height: {{ bar.pct }}%" title="Day {{ bar.day }}: {{ bar.reps }} reps"></div>
{% endfor %}
</div>
</section>
{% endfor %}
{% endif %}
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<h1>Login</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="post" action="/login">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button>
</form>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block nav_extra %}<form method="post" action="/logout" style="display:inline; margin:0;"><button type="submit">Log out</button></form>{% endblock %}
{% block content %}
<h1>Manage</h1>
<h2>Add exercise</h2>
<form method="post" action="/exercises">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<button type="submit">Add exercise</button>
</form>
<h2>Log reps</h2>
{% if exercises.is_empty() %}
<p>Add an exercise first.</p>
{% else %}
<form method="post" action="/entries">
<label for="exercise">Exercise</label>
<select id="exercise" name="exercise" required>
{% for name in exercises %}
<option value="{{ name }}">{{ name }}</option>
{% endfor %}
</select>
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{ today }}" required>
<label for="reps">Reps</label>
<input type="number" id="reps" name="reps" min="0" required>
<button type="submit">Save</button>
</form>
{% endif %}
{% endblock %}