Initial working state
This commit is contained in:
9 files changed
+1600
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
+13
@@ -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"] }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user