adding food multiplier and timezone fixes
This commit is contained in:
parent
bc4cf24f23
commit
4338d1026c
52
src/db.rs
52
src/db.rs
|
|
@ -20,6 +20,7 @@ pub struct UserRecord {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
|
pub timezone: String,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +48,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,11 +114,12 @@ pub fn create_user(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
|
timezone: &str,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
) -> Result<i64, rusqlite::Error> {
|
) -> Result<i64, rusqlite::Error> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (?1, ?2, ?3)",
|
"INSERT INTO users (username, password_hash, timezone, is_admin) VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![username, password_hash, if is_admin { 1 } else { 0 }],
|
params![username, password_hash, timezone, if is_admin { 1 } else { 0 }],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +129,7 @@ pub fn fetch_user_by_username(
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<Option<UserRecord>, rusqlite::Error> {
|
) -> Result<Option<UserRecord>, rusqlite::Error> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, username, password_hash, is_admin
|
"SELECT id, username, password_hash, timezone, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = ?1",
|
WHERE username = ?1",
|
||||||
params![username],
|
params![username],
|
||||||
|
|
@ -128,7 +138,8 @@ pub fn fetch_user_by_username(
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
username: row.get(1)?,
|
username: row.get(1)?,
|
||||||
password_hash: row.get(2)?,
|
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,
|
user_id: i64,
|
||||||
) -> Result<Option<UserRecord>, rusqlite::Error> {
|
) -> Result<Option<UserRecord>, rusqlite::Error> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, username, password_hash, is_admin
|
"SELECT id, username, password_hash, timezone, is_admin
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![user_id],
|
params![user_id],
|
||||||
|
|
@ -149,7 +160,8 @@ pub fn fetch_user_by_id(
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
username: row.get(1)?,
|
username: row.get(1)?,
|
||||||
password_hash: row.get(2)?,
|
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(())
|
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(
|
pub fn create_or_replace_session(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
|
@ -494,3 +518,19 @@ pub fn delete_entry(
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
148
src/handlers.rs
148
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::extract::{Form, Path, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
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 rand::RngCore;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
@ -43,7 +43,8 @@ pub async fn show_calendar(
|
||||||
let Some(auth) = auth else {
|
let Some(auth) = auth else {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
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(
|
pub async fn show_calendar_for_month(
|
||||||
|
|
@ -55,11 +56,12 @@ pub async fn show_calendar_for_month(
|
||||||
let Some(auth) = auth else {
|
let Some(auth) = auth else {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
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((
|
let focus_date = NaiveDate::from_ymd_opt(year, month, 1).ok_or((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"Month route must use /calendar/{year}/{month} with month 1-12".to_string(),
|
"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(
|
pub async fn show_login(
|
||||||
|
|
@ -87,6 +89,7 @@ pub async fn show_signup(
|
||||||
let page = SignupPageView {
|
let page = SignupPageView {
|
||||||
allow_signup: allowed,
|
allow_signup: allowed,
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
|
timezone_value: "UTC".to_string(),
|
||||||
};
|
};
|
||||||
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
|
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>>,
|
Form(form): Form<HashMap<String, String>>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if !signup_allowed(&state).await? {
|
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 username = form.get("username").map(|v| v.trim()).unwrap_or("");
|
||||||
let password = form.get("password").map(|v| v.as_str()).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 {
|
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 {
|
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) = {
|
let (user_count, existing) = {
|
||||||
|
|
@ -150,13 +159,13 @@ pub async fn signup(
|
||||||
(count, existing)
|
(count, existing)
|
||||||
};
|
};
|
||||||
if existing.is_some() {
|
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 password_hash = hash_password(password)?;
|
||||||
let user_id = {
|
let user_id = {
|
||||||
let db_conn = state.db.lock().await;
|
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)?
|
.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)?
|
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 start = today - Days::new(13);
|
||||||
let (calorie_map, weight_map) = {
|
let (calorie_map, weight_map) = {
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
|
|
@ -319,7 +328,7 @@ pub async fn show_reports(
|
||||||
return Ok(Redirect::to("/login").into_response());
|
return Ok(Redirect::to("/login").into_response());
|
||||||
};
|
};
|
||||||
let user_id = auth.user.id;
|
let user_id = auth.user.id;
|
||||||
let today = Local::now().date_naive();
|
let today = user_local_today(&auth.user.timezone);
|
||||||
let planning = {
|
let planning = {
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
db::fetch_planning(&db_conn, user_id).map_err(internal_db_error)?
|
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())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
bmr_value: planning.bmr.map(|v| format!("{v:.0}")).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_entries: planning.public_entries,
|
||||||
public_weights: planning.public_weights,
|
public_weights: planning.public_weights,
|
||||||
public_reports: planning.public_reports,
|
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_weight = parse_optional_positive_f64(form.get("target_weight"))?;
|
||||||
let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
|
let target_calories = parse_optional_non_negative_i64(form.get("target_calories"))?;
|
||||||
let bmr = parse_optional_positive_f64(form.get("bmr"))?;
|
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_entries = form.contains_key("public_entries");
|
||||||
let public_weights = form.contains_key("public_weights");
|
let public_weights = form.contains_key("public_weights");
|
||||||
let public_reports = form.contains_key("public_reports");
|
let public_reports = form.contains_key("public_reports");
|
||||||
|
|
@ -516,6 +527,7 @@ pub async fn update_planning(
|
||||||
public_planning,
|
public_planning,
|
||||||
)
|
)
|
||||||
.map_err(internal_db_error)?;
|
.map_err(internal_db_error)?;
|
||||||
|
db::update_user_timezone(&db_conn, auth.user.id, &timezone).map_err(internal_db_error)?;
|
||||||
Ok(with_session_cookie(
|
Ok(with_session_cookie(
|
||||||
Redirect::to("/planning").into_response(),
|
Redirect::to("/planning").into_response(),
|
||||||
&auth.token,
|
&auth.token,
|
||||||
|
|
@ -599,7 +611,7 @@ pub async fn create_entry(
|
||||||
return Ok(Redirect::to("/login").into_response());
|
return Ok(Redirect::to("/login").into_response());
|
||||||
};
|
};
|
||||||
validate_date(&date_text)?;
|
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;
|
let db_conn = state.db.lock().await;
|
||||||
db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories)
|
db::insert_entry(&db_conn, auth.user.id, &date_text, &name, calories)
|
||||||
|
|
@ -712,9 +724,9 @@ async fn render_calendar_for_month(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
auth: &AuthUser,
|
auth: &AuthUser,
|
||||||
focus_date: NaiveDate,
|
focus_date: NaiveDate,
|
||||||
|
today: NaiveDate,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let user_id = auth.user.id;
|
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 (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?;
|
||||||
|
|
||||||
let totals = {
|
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())
|
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 {
|
let page = SignupPageView {
|
||||||
allow_signup: signup_allowed(state).await?,
|
allow_signup: signup_allowed(state).await?,
|
||||||
error: error.to_string(),
|
error: error.to_string(),
|
||||||
|
timezone_value: timezone_value.to_string(),
|
||||||
};
|
};
|
||||||
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
|
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))
|
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> {
|
fn parse_optional_positive_f64(raw: Option<&String>) -> Result<Option<f64>, AppError> {
|
||||||
match raw {
|
match raw {
|
||||||
None => Ok(None),
|
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 {
|
fn entry_count_label(entry_count: i64) -> String {
|
||||||
if entry_count == 0 {
|
if entry_count == 0 {
|
||||||
"No entries".to_string()
|
"No entries".to_string()
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ pub struct PlanningPageView {
|
||||||
pub target_weight_value: String,
|
pub target_weight_value: String,
|
||||||
pub target_calories_value: String,
|
pub target_calories_value: String,
|
||||||
pub bmr_value: String,
|
pub bmr_value: String,
|
||||||
|
pub timezone_value: String,
|
||||||
pub public_entries: bool,
|
pub public_entries: bool,
|
||||||
pub public_weights: bool,
|
pub public_weights: bool,
|
||||||
pub public_reports: bool,
|
pub public_reports: bool,
|
||||||
|
|
@ -166,6 +167,7 @@ pub struct LoginPageView {
|
||||||
pub struct SignupPageView {
|
pub struct SignupPageView {
|
||||||
pub allow_signup: bool,
|
pub allow_signup: bool,
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
pub timezone_value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
<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>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
|
|
@ -44,12 +44,22 @@
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="calories"
|
name="calories"
|
||||||
placeholder="Calories"
|
placeholder="Calories each"
|
||||||
min="0"
|
min="0"
|
||||||
required
|
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"
|
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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,19 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<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>
|
<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">
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@
|
||||||
<form method="post" action="/signup" class="mt-4 grid gap-3">
|
<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="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="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>
|
<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>
|
</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>
|
<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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue