diff --git a/.gitignore b/.gitignore index ea8c4bf..8662415 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +tmp/ diff --git a/Cargo.lock b/Cargo.lock index b8bbe01..90f4b04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "CurseTechnique" version = "0.1.0" dependencies = [ + "askama", "axum", "chrono", "rusqlite", @@ -33,6 +34,50 @@ dependencies = [ "libc", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -97,6 +142,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -283,6 +337,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.8.1" @@ -365,6 +428,12 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.28.0" @@ -409,6 +478,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -420,6 +505,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -548,6 +643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -731,6 +827,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/Cargo.toml b/Cargo.toml index 5ae183f..23d2174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +askama = "0.12.1" axum = "0.8.1" chrono = { version = "0.4.40", features = ["clock"] } rusqlite = { version = "0.31.0", features = ["bundled"] } diff --git a/data/app.db b/data/app.db index a15222f..18461fa 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/src/db.rs b/src/db.rs index 922fdfd..9e8339f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, params}; #[derive(Clone)] /// A single food entry row from `food_entries`. @@ -17,6 +17,13 @@ pub struct DaySummary { pub total_calories: i64, } +#[derive(Clone, Copy)] +pub struct PlanningConfig { + pub target_weight: Option, + pub target_calories: Option, + pub bmr: Option, +} + /// Creates tables/indexes if they do not already exist. pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch( @@ -27,8 +34,20 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { calories INTEGER NOT NULL CHECK (calories >= 0), created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); - CREATE INDEX IF NOT EXISTS idx_food_entries_entry_date ON food_entries(entry_date);", + CREATE INDEX IF NOT EXISTS idx_food_entries_entry_date ON food_entries(entry_date); + + CREATE TABLE IF NOT EXISTS daily_weights ( + entry_date TEXT PRIMARY KEY, + weight REAL NOT NULL CHECK (weight > 0) + ); + + CREATE TABLE IF NOT EXISTS planning ( + id INTEGER PRIMARY KEY CHECK (id = 1), + target_weight REAL, + target_calories INTEGER CHECK (target_calories >= 0) + );", )?; + ensure_planning_bmr_column(conn)?; Ok(()) } @@ -99,6 +118,161 @@ pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result Ok(entries) } +/// Returns total calories in the half-open range [start_date, end_date). +pub fn fetch_total_calories_for_range( + conn: &Connection, + start_date: &str, + end_date: &str, +) -> Result { + conn.query_row( + "SELECT COALESCE(SUM(calories), 0) + FROM food_entries + WHERE entry_date >= ?1 AND entry_date < ?2", + params![start_date, end_date], + |row| row.get(0), + ) +} + +/// Returns the earliest date with either calories or weight data. +pub fn fetch_first_activity_date(conn: &Connection) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT MIN(entry_date) + FROM ( + SELECT entry_date FROM food_entries + UNION ALL + SELECT entry_date FROM daily_weights + )", + [], + |row| row.get(0), + ) +} + +/// Returns a map date -> total calories for the half-open range [start_date, end_date). +pub fn fetch_daily_calorie_totals_for_range( + conn: &Connection, + start_date: &str, + end_date: &str, +) -> Result, rusqlite::Error> { + let mut statement = conn.prepare( + "SELECT entry_date, COALESCE(SUM(calories), 0) + FROM food_entries + WHERE entry_date >= ?1 AND entry_date < ?2 + GROUP BY entry_date", + )?; + let mut rows = statement.query(params![start_date, end_date])?; + + let mut out = HashMap::new(); + while let Some(row) = rows.next()? { + let date: String = row.get(0)?; + let total: i64 = row.get(1)?; + out.insert(date, total); + } + Ok(out) +} + +/// Returns a map date -> weight for the half-open range [start_date, end_date). +pub fn fetch_daily_weights_for_range( + conn: &Connection, + start_date: &str, + end_date: &str, +) -> Result, rusqlite::Error> { + let mut statement = conn.prepare( + "SELECT entry_date, weight + FROM daily_weights + WHERE entry_date >= ?1 AND entry_date < ?2", + )?; + let mut rows = statement.query(params![start_date, end_date])?; + + let mut out = HashMap::new(); + while let Some(row) = rows.next()? { + let date: String = row.get(0)?; + let weight: f64 = row.get(1)?; + out.insert(date, weight); + } + Ok(out) +} + +pub fn fetch_weight_for_day(conn: &Connection, date: &str) -> Result, rusqlite::Error> { + conn.query_row( + "SELECT weight FROM daily_weights WHERE entry_date = ?1", + params![date], + |row| row.get(0), + ) + .optional() +} + +pub fn upsert_weight_for_day( + conn: &Connection, + date: &str, + weight: f64, +) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO daily_weights (entry_date, weight) + VALUES (?1, ?2) + ON CONFLICT(entry_date) DO UPDATE SET weight = excluded.weight", + params![date, weight], + )?; + Ok(()) +} + +pub fn fetch_planning(conn: &Connection) -> Result { + let row: Option<(Option, Option, Option)> = conn + .query_row( + "SELECT target_weight, target_calories, bmr FROM planning WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?; + + match row { + Some((target_weight, target_calories, bmr)) => Ok(PlanningConfig { + target_weight, + target_calories, + bmr, + }), + None => Ok(PlanningConfig { + target_weight: None, + target_calories: None, + bmr: None, + }), + } +} + +pub fn upsert_planning( + conn: &Connection, + target_weight: Option, + target_calories: Option, + bmr: Option, +) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO planning (id, target_weight, target_calories, bmr) + VALUES (1, ?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE + SET target_weight = excluded.target_weight, + target_calories = excluded.target_calories, + bmr = excluded.bmr", + params![target_weight, target_calories, bmr], + )?; + Ok(()) +} + +fn ensure_planning_bmr_column(conn: &Connection) -> Result<(), rusqlite::Error> { + let mut stmt = conn.prepare("PRAGMA table_info(planning)")?; + let mut rows = stmt.query([])?; + let mut has_bmr = false; + while let Some(row) = rows.next()? { + let name: String = row.get(1)?; + if name == "bmr" { + has_bmr = true; + break; + } + } + if !has_bmr { + conn.execute("ALTER TABLE planning ADD COLUMN bmr REAL", [])?; + } + Ok(()) +} + /// Inserts one entry for a date. pub fn insert_entry( conn: &Connection, diff --git a/src/handlers.rs b/src/handlers.rs index 21b6266..ff22e2c 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -4,12 +4,15 @@ use std::sync::Arc; use axum::extract::{Form, Path, State}; use axum::http::StatusCode; use axum::response::{Html, Redirect}; -use chrono::{Datelike, Local, NaiveDate}; +use chrono::{Datelike, Days, Duration, Local, NaiveDate}; use rusqlite::Connection; use tokio::sync::Mutex; use crate::db; -use crate::views::{self, CalendarCell, DayCard}; +use crate::views::{ + self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, PlanningPageView, + ReportCardView, ReportsPageView, +}; type AppError = (StatusCode, String); @@ -21,54 +24,113 @@ pub struct AppState { /// GET `/` - calendar overview for the current month. pub async fn show_calendar(State(state): State) -> Result, AppError> { - let today = Local::now().date_naive(); - let (first_day, first_of_next_month, days_in_month) = month_bounds(today)?; + render_calendar_for_month(state, Local::now().date_naive()).await +} - let totals = { +/// GET `/calendar/{year}/{month}` - calendar overview for a specific month. +pub async fn show_calendar_for_month( + State(state): State, + Path((year, month)): Path<(i32, u32)>, +) -> Result, AppError> { + 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, focus_date).await +} + +/// GET `/reports` - summary cards + rolling trend charts. +pub async fn show_reports(State(state): State) -> Result, AppError> { + let today = Local::now().date_naive(); + let planning = { let db_conn = state.db.lock().await; - db::fetch_month_summaries( - &db_conn, - &first_day.format("%Y-%m-%d").to_string(), - &first_of_next_month.format("%Y-%m-%d").to_string(), - ) - .map_err(internal_error)? + db::fetch_planning(&db_conn).map_err(internal_db_error)? + }; + let first_activity_date = { + let db_conn = state.db.lock().await; + db::fetch_first_activity_date(&db_conn) + .map_err(internal_db_error)? + .and_then(|raw| NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()) }; - let mut cells = Vec::with_capacity(42); + let daily = build_report_card(&state, "Daily (Today)", today, 1, None).await?; + let rolling_7 = + build_report_card(&state, "Rolling 7-Day", today, 7, first_activity_date).await?; + let rolling_monthly = build_report_card( + &state, + "Rolling Monthly (Past 30 Days)", + today, + 30, + first_activity_date, + ) + .await?; + let rolling_30 = + build_report_card(&state, "Rolling 30-Day", today, 30, first_activity_date).await?; - // Calendar starts on Sunday, so pad blank cells before day 1. - let first_weekday = first_day.weekday().num_days_from_sunday() as usize; - for _ in 0..first_weekday { - cells.push(CalendarCell::Padding); - } + let chart_start = first_activity_date.unwrap_or(today - Days::new(29)); + let (labels, rolling_weight, rolling_calories) = + build_rolling_3day_chart_series(&state, chart_start, today).await?; + let rolling_loss = if let Some(bmr) = planning.bmr { + rolling_calories + .iter() + .map(|avg_cal| Some((avg_cal - bmr) / 3500.0)) + .collect::>() + } else { + vec![None; rolling_calories.len()] + }; - for day_num in 1..=days_in_month { - if let Some(date) = NaiveDate::from_ymd_opt(today.year(), today.month(), day_num as u32) { - let date_text = date.format("%Y-%m-%d").to_string(); - let summary = totals.get(&date_text); + let page = ReportsPageView { + generated_for_date: today.format("%Y-%m-%d").to_string(), + cards: vec![daily, rolling_7, rolling_monthly, rolling_30], + chart_labels_js: js_string_array(&labels), + weight_rolling_js: js_optional_f64_array(&rolling_weight), + calories_rolling_js: js_f64_array(&rolling_calories), + loss_rolling_js: js_optional_f64_array(&rolling_loss), + bmr_label: planning + .bmr + .map(|v| format!("{v:.0} cal/day")) + .unwrap_or_else(|| "Not set".to_string()), + }; - cells.push(CalendarCell::Day(DayCard { - date_text, - day_num: day_num as u32, - total: summary.map(|s| s.total_calories).unwrap_or(0), - entry_count: summary.map(|s| s.entry_count).unwrap_or(0), - is_today: date == today, - is_future: date > today, - })); - } - } + let html = views::render_reports_page(&page).map_err(internal_template_error)?; + Ok(Html(html)) +} - // Pad trailing cells so the grid always ends on Saturday. - let total_cells = first_weekday + days_in_month as usize; - let tail_padding = (7 - (total_cells % 7)) % 7; - for _ in 0..tail_padding { - cells.push(CalendarCell::Padding); - } +/// GET `/planning` - target weight/calorie/BMR configuration. +pub async fn show_planning(State(state): State) -> Result, AppError> { + let planning = { + let db_conn = state.db.lock().await; + db::fetch_planning(&db_conn).map_err(internal_db_error)? + }; - Ok(Html(views::render_calendar_page( - &today.format("%B %Y").to_string(), - &cells, - ))) + let page = PlanningPageView { + target_weight_value: planning + .target_weight + .map(|v| format!("{v:.1}")) + .unwrap_or_default(), + target_calories_value: planning + .target_calories + .map(|v| v.to_string()) + .unwrap_or_default(), + bmr_value: planning.bmr.map(|v| format!("{v:.0}")).unwrap_or_default(), + }; + let html = views::render_planning_page(&page).map_err(internal_template_error)?; + Ok(Html(html)) +} + +/// POST `/planning` - save target weight/calorie/BMR configuration. +pub async fn update_planning( + State(state): State, + Form(form): Form>, +) -> Result { + 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 db_conn = state.db.lock().await; + db::upsert_planning(&db_conn, target_weight, target_calories, bmr) + .map_err(internal_db_error)?; + Ok(Redirect::to("/planning")) } /// GET `/day/{date}` - list/edit entries for one day. @@ -78,12 +140,49 @@ pub async fn show_day_entries( ) -> Result, AppError> { validate_date(&date_text)?; - let entries = { + let (entries, weight) = { let db_conn = state.db.lock().await; - db::fetch_day_entries(&db_conn, &date_text).map_err(internal_error)? + let entries = db::fetch_day_entries(&db_conn, &date_text).map_err(internal_db_error)?; + let weight = db::fetch_weight_for_day(&db_conn, &date_text).map_err(internal_db_error)?; + (entries, weight) }; - Ok(Html(views::render_day_page(&date_text, &entries))) + let page = DayPageView { + date_text, + daily_total: entries.iter().map(|entry| entry.calories).sum(), + entries, + weight_value: weight.map(|w| format!("{w:.1}")).unwrap_or_default(), + }; + + let html = views::render_day_page(&page).map_err(internal_template_error)?; + Ok(Html(html)) +} + +/// POST `/day/{date}/weight` - set or update the day's weight entry. +pub async fn update_day_weight( + State(state): State, + Path(date_text): Path, + Form(form): Form>, +) -> Result { + validate_date(&date_text)?; + let weight = form + .get("weight") + .ok_or((StatusCode::BAD_REQUEST, "Weight is required".to_string()))? + .trim() + .parse::() + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Weight must be a number".to_string(), + ) + })?; + if weight <= 0.0 { + return Err((StatusCode::BAD_REQUEST, "Weight must be > 0".to_string())); + } + + let db_conn = state.db.lock().await; + db::upsert_weight_for_day(&db_conn, &date_text, weight).map_err(internal_db_error)?; + Ok(Redirect::to(&format!("/day/{date_text}"))) } /// POST `/day/{date}/add` - create a new entry. @@ -96,9 +195,9 @@ pub async fn create_entry( let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; - db::insert_entry(&db_conn, &date_text, &name, calories).map_err(internal_error)?; + db::insert_entry(&db_conn, &date_text, &name, calories).map_err(internal_db_error)?; - Ok(Redirect::to(&format!("/day/{}", date_text))) + Ok(Redirect::to(&format!("/day/{date_text}"))) } /// POST `/day/{date}/entry/{id}/update` - edit an existing entry. @@ -111,9 +210,9 @@ pub async fn edit_entry( let (name, calories) = parse_entry_form_fields(&form)?; let db_conn = state.db.lock().await; - db::update_entry(&db_conn, &date_text, id, &name, calories).map_err(internal_error)?; + db::update_entry(&db_conn, &date_text, id, &name, calories).map_err(internal_db_error)?; - Ok(Redirect::to(&format!("/day/{}", date_text))) + Ok(Redirect::to("/")) } /// POST `/day/{date}/entry/{id}/delete` - remove an entry. @@ -124,9 +223,251 @@ pub async fn remove_entry( validate_date(&date_text)?; let db_conn = state.db.lock().await; - db::delete_entry(&db_conn, &date_text, id).map_err(internal_error)?; + db::delete_entry(&db_conn, &date_text, id).map_err(internal_db_error)?; - Ok(Redirect::to(&format!("/day/{}", date_text))) + Ok(Redirect::to(&format!("/day/{date_text}"))) +} + +async fn render_calendar_for_month( + state: AppState, + focus_date: NaiveDate, +) -> Result, AppError> { + let today = Local::now().date_naive(); + let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?; + + let totals = { + let db_conn = state.db.lock().await; + db::fetch_month_summaries( + &db_conn, + &first_day.format("%Y-%m-%d").to_string(), + &first_of_next_month.format("%Y-%m-%d").to_string(), + ) + .map_err(internal_db_error)? + }; + let weights = { + let db_conn = state.db.lock().await; + db::fetch_daily_weights_for_range( + &db_conn, + &first_day.format("%Y-%m-%d").to_string(), + &first_of_next_month.format("%Y-%m-%d").to_string(), + ) + .map_err(internal_db_error)? + }; + + let mut cells = Vec::with_capacity(42); + + let first_weekday = first_day.weekday().num_days_from_sunday() as usize; + for _ in 0..first_weekday { + cells.push(CalendarCellView::padding()); + } + + for day_num in 1..=days_in_month { + if let Some(date) = + NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month(), day_num as u32) + { + let date_text = date.format("%Y-%m-%d").to_string(); + let summary = totals.get(&date_text); + let entry_count = summary.map(|s| s.entry_count).unwrap_or(0); + let weight_label = weights + .get(&date_text) + .map(|w| format!("{w:.1} lbs")) + .unwrap_or_else(|| "No weight (lbs)".to_string()); + + cells.push(CalendarCellView::day( + format!("/day/{date_text}"), + date_text, + day_num as u32, + summary.map(|s| s.total_calories).unwrap_or(0), + entry_count_label(entry_count), + weight_label, + date == today, + date > today, + )); + } + } + + let total_cells = first_weekday + days_in_month as usize; + let tail_padding = (7 - (total_cells % 7)) % 7; + for _ in 0..tail_padding { + cells.push(CalendarCellView::padding()); + } + + let page = CalendarPageView { + month_label: focus_date.format("%B %Y").to_string(), + today_date: today.format("%Y-%m-%d").to_string(), + focus_month_value: focus_date.format("%Y-%m").to_string(), + nav: calendar_nav_for_month(focus_date, today), + cells, + }; + + let html = views::render_calendar_page(&page).map_err(internal_template_error)?; + Ok(Html(html)) +} + +async fn build_report_card( + state: &AppState, + title: &str, + end_day_inclusive: NaiveDate, + day_count: i64, + floor_start_date: Option, +) -> Result { + let original_start = end_day_inclusive - Days::new((day_count - 1) as u64); + let effective_start = if let Some(first_date) = floor_start_date { + if first_date > original_start { + first_date + } else { + original_start + } + } else { + original_start + }; + let effective_day_count = if effective_start > end_day_inclusive { + 0 + } else { + (end_day_inclusive - effective_start).num_days() + 1 + }; + + let total_calories = if effective_day_count == 0 { + 0 + } else { + let window_end = end_day_inclusive + Days::new(1); + let start_text = effective_start.format("%Y-%m-%d").to_string(); + let end_text = window_end.format("%Y-%m-%d").to_string(); + let db_conn = state.db.lock().await; + db::fetch_total_calories_for_range(&db_conn, &start_text, &end_text) + .map_err(internal_db_error)? + }; + + let avg = if effective_day_count == 0 { + 0.0 + } else { + total_calories as f64 / effective_day_count as f64 + }; + + Ok(ReportCardView { + title: title.to_string(), + range_label: format!( + "{} to {}", + effective_start.format("%Y-%m-%d"), + end_day_inclusive.format("%Y-%m-%d") + ), + day_count: effective_day_count, + total_calories, + average_calories_per_day: format!("{avg:.1}"), + }) +} + +async fn build_rolling_3day_chart_series( + state: &AppState, + start_day: NaiveDate, + end_day: NaiveDate, +) -> Result<(Vec, Vec>, Vec), AppError> { + let range_end = end_day + Days::new(1); + let start_text = start_day.format("%Y-%m-%d").to_string(); + let end_text = range_end.format("%Y-%m-%d").to_string(); + + let (calorie_map, weight_map) = { + let db_conn = state.db.lock().await; + let calories = db::fetch_daily_calorie_totals_for_range(&db_conn, &start_text, &end_text) + .map_err(internal_db_error)?; + let weights = db::fetch_daily_weights_for_range(&db_conn, &start_text, &end_text) + .map_err(internal_db_error)?; + (calories, weights) + }; + + let mut labels = Vec::new(); + let mut daily_calories = Vec::new(); + let mut daily_weights = Vec::new(); + + let mut day = start_day; + while day <= end_day { + let date_key = day.format("%Y-%m-%d").to_string(); + labels.push(date_key.clone()); + daily_calories.push(*calorie_map.get(&date_key).unwrap_or(&0) as f64); + daily_weights.push(weight_map.get(&date_key).copied()); + day += Duration::days(1); + } + + let mut rolling_calories = Vec::with_capacity(labels.len()); + let mut rolling_weights = Vec::with_capacity(labels.len()); + for i in 0..labels.len() { + let start_idx = i.saturating_sub(2); + let cal_window = &daily_calories[start_idx..=i]; + let cal_avg = cal_window.iter().sum::() / cal_window.len() as f64; + rolling_calories.push(cal_avg); + + let mut weight_sum = 0.0; + let mut weight_count = 0; + for item in &daily_weights[start_idx..=i] { + if let Some(w) = item { + weight_sum += *w; + weight_count += 1; + } + } + if weight_count == 0 { + rolling_weights.push(None); + } else { + rolling_weights.push(Some(weight_sum / weight_count as f64)); + } + } + + Ok((labels, rolling_weights, rolling_calories)) +} + +fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppError> { + let first_day = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid date".to_string(), + ))?; + + let (next_year, next_month) = if today.month() == 12 { + (today.year() + 1, 1) + } else { + (today.year(), today.month() + 1) + }; + + let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid date".to_string(), + ))?; + + let days_in_month = (first_of_next_month - first_day).num_days(); + Ok((first_day, first_of_next_month, days_in_month)) +} + +fn calendar_nav_for_month(focus_date: NaiveDate, today: NaiveDate) -> CalendarNav { + let prev_month = if focus_date.month() == 1 { + NaiveDate::from_ymd_opt(focus_date.year() - 1, 12, 1).expect("valid date") + } else { + NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() - 1, 1).expect("valid date") + }; + + let next_month = if focus_date.month() == 12 { + NaiveDate::from_ymd_opt(focus_date.year() + 1, 1, 1).expect("valid date") + } else { + NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month() + 1, 1).expect("valid date") + }; + + let prev_year = + NaiveDate::from_ymd_opt(focus_date.year() - 1, focus_date.month(), 1).expect("valid date"); + let next_year = + NaiveDate::from_ymd_opt(focus_date.year() + 1, focus_date.month(), 1).expect("valid date"); + + CalendarNav { + prev_month_href: calendar_path(prev_month), + next_month_href: calendar_path(next_month), + prev_year_href: calendar_path(prev_year), + next_year_href: calendar_path(next_year), + current_month_href: calendar_path( + NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid date"), + ), + show_current_month_link: focus_date.year() != today.year() + || focus_date.month() != today.month(), + } +} + +fn calendar_path(date: NaiveDate) -> String { + format!("/calendar/{}/{}", date.year(), date.month()) } fn validate_date(date_text: &str) -> Result<(), AppError> { @@ -163,30 +504,106 @@ fn parse_entry_form_fields(form: &HashMap) -> Result<(String, i6 Ok((name.to_string(), calories)) } -fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppError> { - let first_day = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "Invalid date".to_string(), - ))?; - - let (next_year, next_month) = if today.month() == 12 { - (today.year() + 1, 1) - } else { - (today.year(), today.month() + 1) - }; - - let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "Invalid date".to_string(), - ))?; - - let days_in_month = (first_of_next_month - first_day).num_days(); - Ok((first_day, first_of_next_month, days_in_month)) +fn parse_optional_positive_f64(raw: Option<&String>) -> Result, AppError> { + match raw { + None => Ok(None), + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed = trimmed.parse::().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Target weight must be a number".to_string(), + ) + })?; + if parsed <= 0.0 { + return Err(( + StatusCode::BAD_REQUEST, + "Target weight must be > 0".to_string(), + )); + } + Ok(Some(parsed)) + } + } } -fn internal_error(error: rusqlite::Error) -> AppError { +fn parse_optional_non_negative_i64(raw: Option<&String>) -> Result, AppError> { + match raw { + None => Ok(None), + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed = trimmed.parse::().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Target calories must be a number".to_string(), + ) + })?; + if parsed < 0 { + return Err(( + StatusCode::BAD_REQUEST, + "Target calories must be >= 0".to_string(), + )); + } + Ok(Some(parsed)) + } + } +} + +fn entry_count_label(entry_count: i64) -> String { + if entry_count == 0 { + "No entries".to_string() + } else if entry_count == 1 { + "1 entry".to_string() + } else { + format!("{} entries", entry_count) + } +} + +fn js_string_array(values: &[String]) -> String { + let quoted = values + .iter() + .map(|v| format!("\"{}\"", v.replace('\"', "\\\""))) + .collect::>() + .join(","); + format!("[{quoted}]") +} + +fn js_f64_array(values: &[f64]) -> String { + let inner = values + .iter() + .map(|v| format!("{v:.3}")) + .collect::>() + .join(","); + format!("[{inner}]") +} + +fn js_optional_f64_array(values: &[Option]) -> String { + let inner = values + .iter() + .map(|v| match v { + Some(num) => format!("{num:.3}"), + None => "null".to_string(), + }) + .collect::>() + .join(","); + format!("[{inner}]") +} + +fn internal_db_error(error: rusqlite::Error) -> AppError { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", error), ) } + +fn internal_template_error(error: askama::Error) -> AppError { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Template error: {}", error), + ) +} diff --git a/src/main.rs b/src/main.rs index a336503..b887dc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,15 @@ async fn main() -> Result<(), Box> { // Route wiring stays here so `main` is the single startup overview. let app = Router::new() .route("/", get(handlers::show_calendar)) + .route("/reports", get(handlers::show_reports)) + .route("/planning", get(handlers::show_planning)) + .route("/planning", post(handlers::update_planning)) + .route( + "/calendar/{year}/{month}", + get(handlers::show_calendar_for_month), + ) .route("/day/{date}", get(handlers::show_day_entries)) + .route("/day/{date}/weight", post(handlers::update_day_weight)) .route("/day/{date}/add", post(handlers::create_entry)) .route("/day/{date}/entry/{id}/update", post(handlers::edit_entry)) .route( diff --git a/src/views.rs b/src/views.rs index 9a02182..4006e20 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,248 +1,214 @@ +use askama::Template; + use crate::db::FoodEntry; -pub struct DayCard { +#[derive(Clone)] +pub struct CalendarNav { + pub prev_month_href: String, + pub next_month_href: String, + pub prev_year_href: String, + pub next_year_href: String, + pub current_month_href: String, + pub show_current_month_link: bool, +} + +#[derive(Clone)] +pub struct CalendarCellView { + pub is_padding: bool, + pub is_future: bool, + pub href: String, pub date_text: String, pub day_num: u32, pub total: i64, - pub entry_count: i64, - pub is_today: bool, - pub is_future: bool, + pub entry_label: String, + pub weight_label: String, + pub card_class: String, + pub badge_class: String, + pub total_class: String, + pub entry_class: String, } -pub enum CalendarCell { - Padding, - Day(DayCard), -} - -/// Renders the month calendar page. -pub fn render_calendar_page(month_label: &str, cells: &[CalendarCell]) -> String { - let mut items = String::new(); - for cell in cells { - match cell { - CalendarCell::Padding => items.push_str( - "
", - ), - CalendarCell::Day(day) => items.push_str(&render_day_card(day)), +impl CalendarCellView { + pub fn padding() -> Self { + Self { + is_padding: true, + is_future: false, + href: String::new(), + date_text: String::new(), + day_num: 0, + total: 0, + entry_label: String::new(), + weight_label: String::new(), + card_class: String::new(), + badge_class: String::new(), + total_class: String::new(), + entry_class: String::new(), } } - format!( - " - - - - - Hello Calories - - - -
-
-

Calorie Tracker

-

{}

-

Click any day to inspect all logged food entries.

-
-
-
-
-
-
Sun
-
Mon
-
Tue
-
Wed
-
Thu
-
Fri
-
Sat
-
-
- {} -
-
-
-
-
- -", - month_label, items - ) -} + pub fn day( + href: String, + date_text: String, + day_num: u32, + total: i64, + entry_label: String, + weight_label: String, + is_today: bool, + is_future: bool, + ) -> Self { + let interactive = + "group block h-32 rounded-2xl p-3 transition hover:-translate-y-0.5 hover:shadow-md"; + let (card_tint, badge_class, total_class, entry_class) = if total > 2500 { + ( + "border border-rose-300 bg-rose-50 hover:bg-rose-100", + "rounded-full bg-rose-700 px-2 py-0.5 text-xs font-semibold text-white", + "text-xl font-bold text-rose-900", + "text-xs text-rose-700", + ) + } else if total > 1500 { + ( + "border border-amber-300 bg-amber-50 hover:bg-amber-100", + "rounded-full bg-amber-700 px-2 py-0.5 text-xs font-semibold text-white", + "text-xl font-bold text-amber-900", + "text-xs text-amber-700", + ) + } else if is_today { + ( + "border border-teal-400 bg-teal-50", + "rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white", + "text-xl font-bold text-slate-900", + "text-xs text-slate-600", + ) + } else { + ( + "border border-slate-200 bg-white", + "rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white", + "text-xl font-bold text-slate-900", + "text-xs text-slate-600", + ) + }; + let today_ring = if is_today && total > 1500 { + " ring-2 ring-teal-400 ring-offset-1" + } else { + "" + }; -/// Renders the daily page with add/edit/delete forms. -pub fn render_day_page(date_text: &str, entries: &[FoodEntry]) -> String { - let daily_total: i64 = entries.iter().map(|entry| entry.calories).sum(); - let rows = render_entry_rows(date_text, entries); - - format!( - " - - - - - Day Entries - - - -
- - - Back to calendar - -
-

{}

-

- Daily total: {} cal -

-
-

Add entry

-
- - -
- -
-
-
-
- - - - - {} -
EntryCalories
-
-
-
- -", - date_text, daily_total, date_text, rows - ) -} - -fn render_day_card(day: &DayCard) -> String { - let entry_line = entry_count_label(day.entry_count); - - if day.is_future { - return format!( - "
-
- {date} - {day_num} -
-
-

{total} cal

-

{entry_line}

-

Future

-
-
", - date = day.date_text, - day_num = day.day_num, - total = day.total, - entry_line = entry_line - ); - } - - let base_class = if day.is_today { - "group block h-32 rounded-2xl border border-teal-400 bg-teal-50 p-3 transition hover:-translate-y-0.5 hover:shadow-md" - } else { - "group block h-32 rounded-2xl border border-slate-200 bg-white p-3 transition hover:-translate-y-0.5 hover:shadow-md" - }; - - format!( - " -
- {date} - {day_num} -
-
-

{total} cal

-

{entry_line}

-
-
", - class = base_class, - date = day.date_text, - day_num = day.day_num, - total = day.total, - entry_line = entry_line - ) -} - -fn escape_html(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('\"', """) - .replace('\'', "'") -} - -fn entry_count_label(entry_count: i64) -> String { - if entry_count == 0 { - "No entries".to_string() - } else if entry_count == 1 { - "1 entry".to_string() - } else { - format!("{} entries", entry_count) + Self { + is_padding: false, + is_future, + href, + date_text, + day_num, + total, + entry_label, + weight_label, + card_class: format!("{interactive} {card_tint}{today_ring}"), + badge_class: badge_class.to_string(), + total_class: total_class.to_string(), + entry_class: entry_class.to_string(), + } } } -fn render_entry_rows(date_text: &str, entries: &[FoodEntry]) -> String { - let mut rows = String::new(); - - if entries.is_empty() { - rows.push_str( - "No entries yet for this day.", - ); - return rows; - } - - for entry in entries { - rows.push_str(&format!( - " - -
- - -
- -
-
-
- -
- - {calories} cal - ", - date = date_text, - id = entry.id, - name = escape_html(&entry.name), - calories = entry.calories - )); - } - - rows +pub struct CalendarPageView { + pub month_label: String, + pub today_date: String, + pub focus_month_value: String, + pub nav: CalendarNav, + pub cells: Vec, +} + +pub struct DayPageView { + pub date_text: String, + pub daily_total: i64, + pub entries: Vec, + pub weight_value: String, +} + +pub struct ReportCardView { + pub title: String, + pub range_label: String, + pub day_count: i64, + pub total_calories: i64, + pub average_calories_per_day: String, +} + +pub struct ReportsPageView { + pub generated_for_date: String, + pub cards: Vec, + pub chart_labels_js: String, + pub weight_rolling_js: String, + pub calories_rolling_js: String, + pub loss_rolling_js: String, + pub bmr_label: String, +} + +pub struct PlanningPageView { + pub target_weight_value: String, + pub target_calories_value: String, + pub bmr_value: String, +} + +#[derive(Template)] +#[template(path = "calendar.html")] +struct CalendarTemplate<'a> { + page: &'a CalendarPageView, +} + +#[derive(Template)] +#[template(path = "day.html")] +struct DayTemplate<'a> { + page: &'a DayPageView, +} + +#[derive(Template)] +#[template(path = "reports.html")] +struct ReportsTemplate<'a> { + page: &'a ReportsPageView, +} + +#[derive(Template)] +#[template(path = "planning.html")] +struct PlanningTemplate<'a> { + page: &'a PlanningPageView, +} + +impl CalendarTemplate<'_> { + fn active_tab(&self) -> &str { + "calendar" + } +} + +impl DayTemplate<'_> { + fn active_tab(&self) -> &str { + "calendar" + } +} + +impl ReportsTemplate<'_> { + fn active_tab(&self) -> &str { + "reports" + } +} + +impl PlanningTemplate<'_> { + fn active_tab(&self) -> &str { + "planning" + } +} + +pub fn render_calendar_page(page: &CalendarPageView) -> Result { + CalendarTemplate { page }.render() +} + +pub fn render_day_page(page: &DayPageView) -> Result { + DayTemplate { page }.render() +} + +pub fn render_reports_page(page: &ReportsPageView) -> Result { + ReportsTemplate { page }.render() +} + +pub fn render_planning_page(page: &PlanningPageView) -> Result { + PlanningTemplate { page }.render() } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2a04338 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}Calorie Tracker{% endblock %} + + + +
+ + {% block content %}{% endblock %} +
+ {% block scripts %}{% endblock %} + + diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..5a6fe83 --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} + +{% block title %}{{ page.month_label }}{% endblock %} + +{% block content %} +
+
+
+

Calorie Tracker

+

{{ page.month_label }}

+

Click any day to inspect all logged food entries.

+

Shortcuts: `Space` opens today, `?` opens keyboard help.

+
+
+ + Open Today + +
+ ← Month + This Month + Month → + ← Year +
+ +
+ Year → +
+ {% if page.nav.show_current_month_link %} + Today + {% else %} + Viewing current month + {% endif %} +
+
+
+
+
+ +
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+ {% for cell in page.cells %} + {% if cell.is_padding %} + + {% else %} + {% if cell.is_future %} +
+
+ {{ cell.date_text }} + {{ cell.day_num }} +
+
+

{{ cell.total }} cal

+

{{ cell.entry_label }}

+

{{ cell.weight_label }}

+

Future

+
+
+ {% else %} + +
+ {{ cell.date_text }} + {{ cell.day_num }} +
+
+

{{ cell.total }} cal

+

{{ cell.entry_label }}

+

{{ cell.weight_label }}

+
+
+ {% endif %} + {% endif %} + {% endfor %} +
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/day.html b/templates/day.html new file mode 100644 index 0000000..4d28d39 --- /dev/null +++ b/templates/day.html @@ -0,0 +1,200 @@ +{% extends "base.html" %} + +{% block title %}Entries for {{ page.date_text }}{% endblock %} + +{% block content %} + + + Back to calendar + + +
+

{{ page.date_text }}

+

+ Daily total: {{ page.daily_total }} cal +

+ +
+

Daily weight (lbs)

+
+ + Auto-save enabled +
+
+ +
+

Add entry

+
+ + +
+ +
+
+
+ +
+ + + + + + {% if page.entries.is_empty() %} + + {% else %} + {% for entry in page.entries %} + + + + + {% endfor %} + {% endif %} + +
EntryCalories
No entries yet for this day.
+
+ + +
Auto-save enabled
+
+
+ +
+
{{ entry.calories }} cal
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/planning.html b/templates/planning.html new file mode 100644 index 0000000..61d0c23 --- /dev/null +++ b/templates/planning.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Planning{% endblock %} + +{% block content %} +
+

Planning

+

Set target weight (lbs), target daily calories, and BMR. Leave blank to unset.

+ +
+ + + + + + +
+ +
+
+
+{% endblock %} diff --git a/templates/reports.html b/templates/reports.html new file mode 100644 index 0000000..ee4f05c --- /dev/null +++ b/templates/reports.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}Reports{% endblock %} + +{% block content %} +
+

Reports

+

Averages include days with no entries as 0 calories.

+

Generated for {{ page.generated_for_date }} (local date)

+

BMR for estimate: {{ page.bmr_label }}

+
+ +
+ {% for card in page.cards %} +
+

{{ card.title }}

+

{{ card.range_label }}

+

{{ card.average_calories_per_day }}

+

avg cal/day

+
+
+
Total
+
{{ card.total_calories }} cal
+
+
+
Days
+
{{ card.day_count }}
+
+
+
+ {% endfor %} +
+ +
+

Rolling 3-Day Trends

+

Weight uses available weigh-ins in each 3-day window. Calories include zero-entry days.

+
+
+

Calories (3-day average)

+
+ +
+
+
+

Weight (3-day average, lbs)

+
+ +
+
+
+

Estimated Daily Weight Change (lbs/day)

+

Formula: (rolling 3-day avg calories - BMR) / 3500. Negative = loss, positive = gain.

+
+ +
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %}