From 4338d1026c062ddc0551d02495668a632743698c Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sat, 7 Feb 2026 16:36:10 -0800 Subject: [PATCH] adding food multiplier and timezone fixes --- src/db.rs | 52 ++++++++++++-- src/handlers.rs | 148 ++++++++++++++++++++++++++++++++++++---- src/views.rs | 2 + templates/day.html | 16 ++++- templates/planning.html | 13 ++++ templates/signup.html | 8 +++ 6 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/db.rs b/src/db.rs index 3ad2c9d..2327727 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 { 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, 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, 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 { + 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) +} diff --git a/src/handlers.rs b/src/handlers.rs index 76ceff0..17999c0 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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>, ) -> Result { 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 { 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 Result { +async fn render_signup_with_error( + state: &AppState, + error: &str, + timezone_value: &str, +) -> Result { 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) -> Result<(String, i6 Ok((name.to_string(), calories)) } +fn parse_new_entry_form_fields(form: &HashMap) -> 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::().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, AppError> { match raw { None => Ok(None), @@ -1173,6 +1220,81 @@ fn parse_optional_non_negative_i64(raw: Option<&String>) -> Result, } } +fn parse_timezone(raw: Option<&String>) -> Result { + 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 { + 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 { + 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::().ok()?; + let minutes = mm.parse::().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() diff --git a/src/views.rs b/src/views.rs index 11ca6dc..4849d7f 100644 --- a/src/views.rs +++ b/src/views.rs @@ -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)] diff --git a/templates/day.html b/templates/day.html index 4d28d39..7b39b54 100644 --- a/templates/day.html +++ b/templates/day.html @@ -33,7 +33,7 @@

Add entry

-
+ -
+ +
diff --git a/templates/planning.html b/templates/planning.html index 5d8968d..e27816d 100644 --- a/templates/planning.html +++ b/templates/planning.html @@ -46,6 +46,19 @@ /> + +
Public profile visibility