adding food multiplier and timezone fixes

This commit is contained in:
Peter Li 2026-02-07 16:36:10 -08:00
parent bc4cf24f23
commit 4338d1026c
6 changed files with 217 additions and 22 deletions

View File

@ -20,6 +20,7 @@ pub struct UserRecord {
pub id: i64,
pub username: String,
pub password_hash: String,
pub timezone: String,
#[allow(dead_code)]
pub is_admin: bool,
}
@ -47,6 +48,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -89,6 +91,13 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
public_planning INTEGER NOT NULL DEFAULT 0
);",
)?;
if !table_has_column(conn, "users", "timezone")? {
conn.execute(
"ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'",
[],
)?;
}
Ok(())
}
@ -105,11 +114,12 @@ pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
timezone: &str,
is_admin: bool,
) -> Result<i64, rusqlite::Error> {
conn.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?1, ?2, ?3)",
params![username, password_hash, if is_admin { 1 } else { 0 }],
"INSERT INTO users (username, password_hash, timezone, is_admin) VALUES (?1, ?2, ?3, ?4)",
params![username, password_hash, timezone, if is_admin { 1 } else { 0 }],
)?;
Ok(conn.last_insert_rowid())
}
@ -119,7 +129,7 @@ pub fn fetch_user_by_username(
username: &str,
) -> Result<Option<UserRecord>, rusqlite::Error> {
conn.query_row(
"SELECT id, username, password_hash, is_admin
"SELECT id, username, password_hash, timezone, is_admin
FROM users
WHERE username = ?1",
params![username],
@ -128,7 +138,8 @@ pub fn fetch_user_by_username(
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
is_admin: row.get::<_, i64>(3)? == 1,
timezone: row.get(3)?,
is_admin: row.get::<_, i64>(4)? == 1,
})
},
)
@ -140,7 +151,7 @@ pub fn fetch_user_by_id(
user_id: i64,
) -> Result<Option<UserRecord>, rusqlite::Error> {
conn.query_row(
"SELECT id, username, password_hash, is_admin
"SELECT id, username, password_hash, timezone, is_admin
FROM users
WHERE id = ?1",
params![user_id],
@ -149,7 +160,8 @@ pub fn fetch_user_by_id(
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
is_admin: row.get::<_, i64>(3)? == 1,
timezone: row.get(3)?,
is_admin: row.get::<_, i64>(4)? == 1,
})
},
)
@ -168,6 +180,18 @@ pub fn update_user_password(
Ok(())
}
pub fn update_user_timezone(
conn: &Connection,
user_id: i64,
timezone: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE users SET timezone = ?1 WHERE id = ?2",
params![timezone, user_id],
)?;
Ok(())
}
pub fn create_or_replace_session(
conn: &Connection,
token: &str,
@ -494,3 +518,19 @@ pub fn delete_entry(
)?;
Ok(())
}
fn table_has_column(
conn: &Connection,
table_name: &str,
column_name: &str,
) -> Result<bool, rusqlite::Error> {
let mut statement = conn.prepare(&format!("PRAGMA table_info({})", table_name))?;
let mut rows = statement.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get(1)?;
if name == column_name {
return Ok(true);
}
}
Ok(false)
}

View File

@ -6,7 +6,7 @@ use argon2::{Argon2, password_hash::rand_core::OsRng as ArgonOsRng};
use axum::extract::{Form, Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use chrono::{Datelike, Days, Duration, Local, NaiveDate, Utc};
use chrono::{Datelike, Days, Duration, FixedOffset, NaiveDate, Utc};
use rand::RngCore;
use rusqlite::Connection;
use tokio::sync::Mutex;
@ -43,7 +43,8 @@ pub async fn show_calendar(
let Some(auth) = auth else {
return Ok(Redirect::to("/login").into_response());
};
render_calendar_for_month(&state, &auth, Local::now().date_naive()).await
let today = user_local_today(&auth.user.timezone);
render_calendar_for_month(&state, &auth, today, today).await
}
pub async fn show_calendar_for_month(
@ -55,11 +56,12 @@ pub async fn show_calendar_for_month(
let Some(auth) = auth else {
return Ok(Redirect::to("/login").into_response());
};
let today = user_local_today(&auth.user.timezone);
let focus_date = NaiveDate::from_ymd_opt(year, month, 1).ok_or((
StatusCode::BAD_REQUEST,
"Month route must use /calendar/{year}/{month} with month 1-12".to_string(),
))?;
render_calendar_for_month(&state, &auth, focus_date).await
render_calendar_for_month(&state, &auth, focus_date, today).await
}
pub async fn show_login(
@ -87,6 +89,7 @@ pub async fn show_signup(
let page = SignupPageView {
allow_signup: allowed,
error: String::new(),
timezone_value: "UTC".to_string(),
};
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
}
@ -131,16 +134,22 @@ pub async fn signup(
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
if !signup_allowed(&state).await? {
return render_signup_with_error(&state, "Sign-up is disabled").await;
return render_signup_with_error(&state, "Sign-up is disabled", "UTC").await;
}
let username = form.get("username").map(|v| v.trim()).unwrap_or("");
let password = form.get("password").map(|v| v.as_str()).unwrap_or("");
let timezone = match parse_timezone(form.get("timezone")) {
Ok(value) => value,
Err((_, message)) => return render_signup_with_error(&state, &message, "UTC").await,
};
if username.len() < 2 {
return render_signup_with_error(&state, "Username must be at least 2 chars").await;
return render_signup_with_error(&state, "Username must be at least 2 chars", &timezone)
.await;
}
if password.len() < 4 {
return render_signup_with_error(&state, "Password must be at least 4 chars").await;
return render_signup_with_error(&state, "Password must be at least 4 chars", &timezone)
.await;
}
let (user_count, existing) = {
@ -150,13 +159,13 @@ pub async fn signup(
(count, existing)
};
if existing.is_some() {
return render_signup_with_error(&state, "Username already taken").await;
return render_signup_with_error(&state, "Username already taken", &timezone).await;
}
let password_hash = hash_password(password)?;
let user_id = {
let db_conn = state.db.lock().await;
db::create_user(&db_conn, username, &password_hash, user_count == 0)
db::create_user(&db_conn, username, &password_hash, &timezone, user_count == 0)
.map_err(internal_db_error)?
};
@ -201,7 +210,7 @@ pub async fn show_public_profile(
db::fetch_planning(&db_conn, user.id).map_err(internal_db_error)?
};
let today = Local::now().date_naive();
let today = user_local_today(&user.timezone);
let start = today - Days::new(13);
let (calorie_map, weight_map) = {
let db_conn = state.db.lock().await;
@ -319,7 +328,7 @@ pub async fn show_reports(
return Ok(Redirect::to("/login").into_response());
};
let user_id = auth.user.id;
let today = Local::now().date_naive();
let today = user_local_today(&auth.user.timezone);
let planning = {
let db_conn = state.db.lock().await;
db::fetch_planning(&db_conn, user_id).map_err(internal_db_error)?
@ -414,6 +423,7 @@ pub async fn show_planning(
.map(|v| v.to_string())
.unwrap_or_default(),
bmr_value: planning.bmr.map(|v| format!("{v:.0}")).unwrap_or_default(),
timezone_value: auth.user.timezone,
public_entries: planning.public_entries,
public_weights: planning.public_weights,
public_reports: planning.public_reports,
@ -498,6 +508,7 @@ pub async fn update_planning(
let target_weight = parse_optional_positive_f64(form.get("target_weight"))?;
let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
let bmr = parse_optional_positive_f64(form.get("bmr"))?;
let timezone = parse_timezone(form.get("timezone"))?;
let public_entries = form.contains_key("public_entries");
let public_weights = form.contains_key("public_weights");
let public_reports = form.contains_key("public_reports");
@ -516,6 +527,7 @@ pub async fn update_planning(
public_planning,
)
.map_err(internal_db_error)?;
db::update_user_timezone(&db_conn, auth.user.id, &timezone).map_err(internal_db_error)?;
Ok(with_session_cookie(
Redirect::to("/planning").into_response(),
&auth.token,
@ -599,7 +611,7 @@ pub async fn create_entry(
return Ok(Redirect::to("/login").into_response());
};
validate_date(&date_text)?;
let (name, calories) = parse_entry_form_fields(&form)?;
let (name, calories) = parse_new_entry_form_fields(&form)?;
let db_conn = state.db.lock().await;
db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories)
@ -712,9 +724,9 @@ async fn render_calendar_for_month(
state: &AppState,
auth: &AuthUser,
focus_date: NaiveDate,
today: NaiveDate,
) -> Result<Response, AppError> {
let user_id = auth.user.id;
let today = Local::now().date_naive();
let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?;
let totals = {
@ -954,10 +966,15 @@ async fn render_login_with_error(state: &AppState, error: &str) -> Result<Respon
Ok(Html(views::render_login_page(&page).map_err(internal_template_error)?).into_response())
}
async fn render_signup_with_error(state: &AppState, error: &str) -> Result<Response, AppError> {
async fn render_signup_with_error(
state: &AppState,
error: &str,
timezone_value: &str,
) -> Result<Response, AppError> {
let page = SignupPageView {
allow_signup: signup_allowed(state).await?,
error: error.to_string(),
timezone_value: timezone_value.to_string(),
};
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
}
@ -1126,6 +1143,36 @@ fn parse_entry_form_fields(form: &HashMap<String, String>) -> Result<(String, i6
Ok((name.to_string(), calories))
}
fn parse_new_entry_form_fields(form: &HashMap<String, String>) -> Result<(String, i64), AppError> {
let (name, calories) = parse_entry_form_fields(form)?;
let multiplier = match form.get("multiplier") {
None => 1,
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
1
} else {
let parsed = trimmed.parse::<i64>().map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Count must be a whole number".to_string(),
)
})?;
if parsed <= 0 {
return Err((StatusCode::BAD_REQUEST, "Count must be >= 1".to_string()));
}
parsed
}
}
};
let total = calories.checked_mul(multiplier).ok_or((
StatusCode::BAD_REQUEST,
"Calories total is too large".to_string(),
))?;
Ok((name, total))
}
fn parse_optional_positive_f64(raw: Option<&String>) -> Result<Option<f64>, AppError> {
match raw {
None => Ok(None),
@ -1173,6 +1220,81 @@ fn parse_optional_non_negative_i64(raw: Option<&String>) -> Result<Option<i64>,
}
}
fn parse_timezone(raw: Option<&String>) -> Result<String, AppError> {
let value = raw
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.ok_or((StatusCode::BAD_REQUEST, "Timezone is required".to_string()))?;
if value.eq_ignore_ascii_case("UTC") || value == "Z" {
return Ok("UTC".to_string());
}
if let Some(offset) = parse_utc_offset(value) {
return Ok(offset);
}
Err((
StatusCode::BAD_REQUEST,
"Timezone must be UTC or an offset like -08:00, +05:30, or UTC-05:00".to_string(),
))
}
fn user_local_today(timezone: &str) -> NaiveDate {
if timezone == "UTC" {
return Utc::now().date_naive();
}
if let Some(normalized) = parse_utc_offset(timezone) {
if let Some(offset) = fixed_offset_from_str(&normalized) {
return Utc::now().with_timezone(&offset).date_naive();
}
}
Utc::now().date_naive()
}
fn parse_utc_offset(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let without_prefix = if let Some(rest) = trimmed.strip_prefix("UTC") {
rest
} else {
trimmed
};
fixed_offset_from_str(without_prefix)?;
Some(normalize_offset(without_prefix))
}
fn fixed_offset_from_str(raw: &str) -> Option<FixedOffset> {
let normalized = normalize_offset(raw);
let sign = if normalized.starts_with('-') { -1 } else { 1 };
let body = normalized
.strip_prefix('+')
.or_else(|| normalized.strip_prefix('-'))?;
let (hh, mm) = body.split_once(':')?;
let hours = hh.parse::<i32>().ok()?;
let minutes = mm.parse::<i32>().ok()?;
if hours > 23 || minutes > 59 {
return None;
}
let seconds = sign * (hours * 3600 + minutes * 60);
FixedOffset::east_opt(seconds)
}
fn normalize_offset(raw: &str) -> String {
let trimmed = raw.trim();
let sign = if trimmed.starts_with('-') { "-" } else { "+" };
let body = trimmed.trim_start_matches(['+', '-']);
if body.contains(':') {
return format!("{sign}{body}");
}
if body.len() == 2 {
return format!("{sign}{body}:00");
}
if body.len() == 4 {
return format!("{sign}{}:{}", &body[0..2], &body[2..4]);
}
format!("{sign}{body}")
}
fn entry_count_label(entry_count: i64) -> String {
if entry_count == 0 {
"No entries".to_string()

View File

@ -147,6 +147,7 @@ pub struct PlanningPageView {
pub target_weight_value: String,
pub target_calories_value: String,
pub bmr_value: String,
pub timezone_value: String,
pub public_entries: bool,
pub public_weights: bool,
pub public_reports: bool,
@ -166,6 +167,7 @@ pub struct LoginPageView {
pub struct SignupPageView {
pub allow_signup: bool,
pub error: String,
pub timezone_value: String,
}
#[derive(Clone)]

View File

@ -33,7 +33,7 @@
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Add entry</h2>
<form method="post" action="/day/{{ page.date_text }}/add" class="mt-3 grid gap-2 sm:grid-cols-3">
<form method="post" action="/day/{{ page.date_text }}/add" class="mt-3 grid gap-2 sm:grid-cols-4">
<input
type="text"
name="name"
@ -44,12 +44,22 @@
<input
type="number"
name="calories"
placeholder="Calories"
placeholder="Calories each"
min="0"
required
class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
/>
<div class="sm:col-span-3">
<input
type="number"
name="multiplier"
placeholder="Count"
min="1"
step="1"
value="1"
required
class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
/>
<div class="sm:col-span-4">
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Add entry</button>
</div>
</form>

View File

@ -46,6 +46,19 @@
/>
</label>
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">Timezone</span>
<input
type="text"
name="timezone"
value="{{ page.timezone_value }}"
placeholder="UTC, -08:00, +05:30"
required
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
/>
<span class="text-xs text-slate-500">Used to determine when "today" starts for your account.</span>
</label>
<fieldset class="grid gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-sm font-semibold text-slate-700">Public profile visibility</legend>
<label class="flex items-center gap-2 text-sm text-slate-700">

View File

@ -15,6 +15,14 @@
<form method="post" action="/signup" class="mt-4 grid gap-3">
<input type="text" name="username" placeholder="Username" required class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
<input type="password" name="password" placeholder="Password (min 4 chars)" required minlength="4" class="rounded-lg border border-slate-300 px-3 py-2 text-sm" />
<input
type="text"
name="timezone"
value="{{ page.timezone_value }}"
placeholder="Timezone (UTC, -08:00, +05:30)"
required
class="rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700" {% if !page.allow_signup %}disabled{% endif %}>Sign Up</button>
</form>
<p class="mt-3 text-sm text-slate-600">Already have an account? <a class="font-semibold text-slate-900 underline" href="/login">Sign in</a></p>