version 0.1

This commit is contained in:
Peter Li 2026-02-07 12:08:44 -08:00
parent fc7b2d6780
commit d781a42107
13 changed files with 1588 additions and 305 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target /target
tmp/

102
Cargo.lock generated
View File

@ -6,6 +6,7 @@ version = 4
name = "CurseTechnique" name = "CurseTechnique"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"askama",
"axum", "axum",
"chrono", "chrono",
"rusqlite", "rusqlite",
@ -33,6 +34,50 @@ dependencies = [
"libc", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -97,6 +142,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.10.0"
@ -283,6 +337,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.8.1" version = "1.8.1"
@ -365,6 +428,12 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.28.0" version = "0.28.0"
@ -409,6 +478,22 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@ -420,6 +505,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -548,6 +643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
@ -731,6 +827,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"

View File

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
askama = "0.12.1"
axum = "0.8.1" axum = "0.8.1"
chrono = { version = "0.4.40", features = ["clock"] } chrono = { version = "0.4.40", features = ["clock"] }
rusqlite = { version = "0.31.0", features = ["bundled"] } rusqlite = { version = "0.31.0", features = ["bundled"] }

Binary file not shown.

178
src/db.rs
View File

@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use rusqlite::{Connection, params}; use rusqlite::{Connection, OptionalExtension, params};
#[derive(Clone)] #[derive(Clone)]
/// A single food entry row from `food_entries`. /// A single food entry row from `food_entries`.
@ -17,6 +17,13 @@ pub struct DaySummary {
pub total_calories: i64, pub total_calories: i64,
} }
#[derive(Clone, Copy)]
pub struct PlanningConfig {
pub target_weight: Option<f64>,
pub target_calories: Option<i64>,
pub bmr: Option<f64>,
}
/// Creates tables/indexes if they do not already exist. /// Creates tables/indexes if they do not already exist.
pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch( conn.execute_batch(
@ -27,8 +34,20 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
calories INTEGER NOT NULL CHECK (calories >= 0), calories INTEGER NOT NULL CHECK (calories >= 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 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(()) Ok(())
} }
@ -99,6 +118,161 @@ pub fn fetch_day_entries(conn: &Connection, date: &str) -> Result<Vec<FoodEntry>
Ok(entries) 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<i64, rusqlite::Error> {
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<Option<String>, 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<HashMap<String, i64>, 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<HashMap<String, f64>, 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<Option<f64>, 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<PlanningConfig, rusqlite::Error> {
let row: Option<(Option<f64>, Option<i64>, Option<f64>)> = 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<f64>,
target_calories: Option<i64>,
bmr: Option<f64>,
) -> 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. /// Inserts one entry for a date.
pub fn insert_entry( pub fn insert_entry(
conn: &Connection, conn: &Connection,

View File

@ -4,12 +4,15 @@ use std::sync::Arc;
use axum::extract::{Form, Path, State}; use axum::extract::{Form, Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{Html, Redirect}; use axum::response::{Html, Redirect};
use chrono::{Datelike, Local, NaiveDate}; use chrono::{Datelike, Days, Duration, Local, NaiveDate};
use rusqlite::Connection; use rusqlite::Connection;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::db; use crate::db;
use crate::views::{self, CalendarCell, DayCard}; use crate::views::{
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, PlanningPageView,
ReportCardView, ReportsPageView,
};
type AppError = (StatusCode, String); type AppError = (StatusCode, String);
@ -21,54 +24,113 @@ pub struct AppState {
/// GET `/` - calendar overview for the current month. /// GET `/` - calendar overview for the current month.
pub async fn show_calendar(State(state): State<AppState>) -> Result<Html<String>, AppError> { pub async fn show_calendar(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let today = Local::now().date_naive(); render_calendar_for_month(state, Local::now().date_naive()).await
let (first_day, first_of_next_month, days_in_month) = month_bounds(today)?; }
let totals = { /// GET `/calendar/{year}/{month}` - calendar overview for a specific month.
pub async fn show_calendar_for_month(
State(state): State<AppState>,
Path((year, month)): Path<(i32, u32)>,
) -> Result<Html<String>, 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<AppState>) -> Result<Html<String>, AppError> {
let today = Local::now().date_naive();
let planning = {
let db_conn = state.db.lock().await; let db_conn = state.db.lock().await;
db::fetch_month_summaries( db::fetch_planning(&db_conn).map_err(internal_db_error)?
&db_conn, };
&first_day.format("%Y-%m-%d").to_string(), let first_activity_date = {
&first_of_next_month.format("%Y-%m-%d").to_string(), let db_conn = state.db.lock().await;
) db::fetch_first_activity_date(&db_conn)
.map_err(internal_error)? .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 chart_start = first_activity_date.unwrap_or(today - Days::new(29));
let first_weekday = first_day.weekday().num_days_from_sunday() as usize; let (labels, rolling_weight, rolling_calories) =
for _ in 0..first_weekday { build_rolling_3day_chart_series(&state, chart_start, today).await?;
cells.push(CalendarCell::Padding); let rolling_loss = if let Some(bmr) = planning.bmr {
} rolling_calories
.iter()
.map(|avg_cal| Some((avg_cal - bmr) / 3500.0))
.collect::<Vec<_>>()
} else {
vec![None; rolling_calories.len()]
};
for day_num in 1..=days_in_month { let page = ReportsPageView {
if let Some(date) = NaiveDate::from_ymd_opt(today.year(), today.month(), day_num as u32) { generated_for_date: today.format("%Y-%m-%d").to_string(),
let date_text = date.format("%Y-%m-%d").to_string(); cards: vec![daily, rolling_7, rolling_monthly, rolling_30],
let summary = totals.get(&date_text); 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 { let html = views::render_reports_page(&page).map_err(internal_template_error)?;
date_text, Ok(Html(html))
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,
}));
}
}
// Pad trailing cells so the grid always ends on Saturday. /// GET `/planning` - target weight/calorie/BMR configuration.
let total_cells = first_weekday + days_in_month as usize; pub async fn show_planning(State(state): State<AppState>) -> Result<Html<String>, AppError> {
let tail_padding = (7 - (total_cells % 7)) % 7; let planning = {
for _ in 0..tail_padding { let db_conn = state.db.lock().await;
cells.push(CalendarCell::Padding); db::fetch_planning(&db_conn).map_err(internal_db_error)?
} };
Ok(Html(views::render_calendar_page( let page = PlanningPageView {
&today.format("%B %Y").to_string(), target_weight_value: planning
&cells, .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<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Redirect, AppError> {
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. /// GET `/day/{date}` - list/edit entries for one day.
@ -78,12 +140,49 @@ pub async fn show_day_entries(
) -> Result<Html<String>, AppError> { ) -> Result<Html<String>, AppError> {
validate_date(&date_text)?; validate_date(&date_text)?;
let entries = { let (entries, weight) = {
let db_conn = state.db.lock().await; 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<AppState>,
Path(date_text): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Redirect, AppError> {
validate_date(&date_text)?;
let weight = form
.get("weight")
.ok_or((StatusCode::BAD_REQUEST, "Weight is required".to_string()))?
.trim()
.parse::<f64>()
.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. /// 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 (name, calories) = parse_entry_form_fields(&form)?;
let db_conn = state.db.lock().await; 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. /// 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 (name, calories) = parse_entry_form_fields(&form)?;
let db_conn = state.db.lock().await; 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. /// POST `/day/{date}/entry/{id}/delete` - remove an entry.
@ -124,9 +223,251 @@ pub async fn remove_entry(
validate_date(&date_text)?; validate_date(&date_text)?;
let db_conn = state.db.lock().await; 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<Html<String>, 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<NaiveDate>,
) -> Result<ReportCardView, AppError> {
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<String>, Vec<Option<f64>>, Vec<f64>), 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::<f64>() / 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> { fn validate_date(date_text: &str) -> Result<(), AppError> {
@ -163,30 +504,106 @@ fn parse_entry_form_fields(form: &HashMap<String, String>) -> Result<(String, i6
Ok((name.to_string(), calories)) Ok((name.to_string(), calories))
} }
fn month_bounds(today: NaiveDate) -> Result<(NaiveDate, NaiveDate, i64), AppError> { fn parse_optional_positive_f64(raw: Option<&String>) -> Result<Option<f64>, AppError> {
let first_day = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or(( match raw {
StatusCode::INTERNAL_SERVER_ERROR, None => Ok(None),
"Invalid date".to_string(), Some(value) => {
))?; let trimmed = value.trim();
if trimmed.is_empty() {
let (next_year, next_month) = if today.month() == 12 { return Ok(None);
(today.year() + 1, 1) }
} else { let parsed = trimmed.parse::<f64>().map_err(|_| {
(today.year(), today.month() + 1) (
}; StatusCode::BAD_REQUEST,
"Target weight must be a number".to_string(),
let first_of_next_month = NaiveDate::from_ymd_opt(next_year, next_month, 1).ok_or(( )
StatusCode::INTERNAL_SERVER_ERROR, })?;
"Invalid date".to_string(), if parsed <= 0.0 {
))?; return Err((
StatusCode::BAD_REQUEST,
let days_in_month = (first_of_next_month - first_day).num_days(); "Target weight must be > 0".to_string(),
Ok((first_day, first_of_next_month, days_in_month)) ));
}
Ok(Some(parsed))
}
}
} }
fn internal_error(error: rusqlite::Error) -> AppError { fn parse_optional_non_negative_i64(raw: Option<&String>) -> Result<Option<i64>, AppError> {
match raw {
None => Ok(None),
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(None);
}
let parsed = trimmed.parse::<i64>().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::<Vec<_>>()
.join(",");
format!("[{quoted}]")
}
fn js_f64_array(values: &[f64]) -> String {
let inner = values
.iter()
.map(|v| format!("{v:.3}"))
.collect::<Vec<_>>()
.join(",");
format!("[{inner}]")
}
fn js_optional_f64_array(values: &[Option<f64>]) -> String {
let inner = values
.iter()
.map(|v| match v {
Some(num) => format!("{num:.3}"),
None => "null".to_string(),
})
.collect::<Vec<_>>()
.join(",");
format!("[{inner}]")
}
fn internal_db_error(error: rusqlite::Error) -> AppError {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", error), format!("Database error: {}", error),
) )
} }
fn internal_template_error(error: askama::Error) -> AppError {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Template error: {}", error),
)
}

View File

@ -31,7 +31,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Route wiring stays here so `main` is the single startup overview. // Route wiring stays here so `main` is the single startup overview.
let app = Router::new() let app = Router::new()
.route("/", get(handlers::show_calendar)) .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}", 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}/add", post(handlers::create_entry))
.route("/day/{date}/entry/{id}/update", post(handlers::edit_entry)) .route("/day/{date}/entry/{id}/update", post(handlers::edit_entry))
.route( .route(

View File

@ -1,248 +1,214 @@
use askama::Template;
use crate::db::FoodEntry; 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 date_text: String,
pub day_num: u32, pub day_num: u32,
pub total: i64, pub total: i64,
pub entry_count: i64, pub entry_label: String,
pub is_today: bool, pub weight_label: String,
pub is_future: bool, pub card_class: String,
pub badge_class: String,
pub total_class: String,
pub entry_class: String,
} }
pub enum CalendarCell { impl CalendarCellView {
Padding, pub fn padding() -> Self {
Day(DayCard), Self {
} is_padding: true,
is_future: false,
/// Renders the month calendar page. href: String::new(),
pub fn render_calendar_page(month_label: &str, cells: &[CalendarCell]) -> String { date_text: String::new(),
let mut items = String::new(); day_num: 0,
for cell in cells { total: 0,
match cell { entry_label: String::new(),
CalendarCell::Padding => items.push_str( weight_label: String::new(),
"<div class=\"h-32 rounded-2xl border border-dashed border-slate-300/80 bg-white/40\" aria-hidden=\"true\"></div>", card_class: String::new(),
), badge_class: String::new(),
CalendarCell::Day(day) => items.push_str(&render_day_card(day)), total_class: String::new(),
entry_class: String::new(),
} }
} }
format!( pub fn day(
"<!doctype html> href: String,
<html> date_text: String,
<head> day_num: u32,
<meta charset=\"utf-8\" /> total: i64,
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> entry_label: String,
<title>Hello Calories</title> weight_label: String,
<script src=\"https://cdn.tailwindcss.com\"></script> is_today: bool,
</head> is_future: bool,
<body class=\"min-h-screen bg-gradient-to-br from-slate-100 via-white to-cyan-100 text-slate-900\"> ) -> Self {
<main class=\"mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8\"> let interactive =
<header class=\"mb-8 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur\"> "group block h-32 rounded-2xl p-3 transition hover:-translate-y-0.5 hover:shadow-md";
<p class=\"text-xs uppercase tracking-[0.22em] text-slate-500\">Calorie Tracker</p> let (card_tint, badge_class, total_class, entry_class) = if total > 2500 {
<h1 class=\"mt-2 text-3xl font-bold tracking-tight sm:text-4xl\">{}</h1> (
<p class=\"mt-3 text-sm text-slate-600\">Click any day to inspect all logged food entries.</p> "border border-rose-300 bg-rose-50 hover:bg-rose-100",
</header> "rounded-full bg-rose-700 px-2 py-0.5 text-xs font-semibold text-white",
<section class=\"rounded-3xl border border-slate-200/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6\"> "text-xl font-bold text-rose-900",
<div class=\"overflow-x-auto\"> "text-xs text-rose-700",
<div class=\"min-w-[720px]\"> )
<div class=\"mb-4 grid grid-cols-7 gap-2\"> } else if total > 1500 {
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Sun</div> (
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Mon</div> "border border-amber-300 bg-amber-50 hover:bg-amber-100",
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Tue</div> "rounded-full bg-amber-700 px-2 py-0.5 text-xs font-semibold text-white",
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Wed</div> "text-xl font-bold text-amber-900",
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Thu</div> "text-xs text-amber-700",
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Fri</div> )
<div class=\"text-center text-xs font-semibold uppercase tracking-wide text-slate-500\">Sat</div> } else if is_today {
</div> (
<div class=\"grid grid-cols-7 gap-2\"> "border border-teal-400 bg-teal-50",
{} "rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white",
</div> "text-xl font-bold text-slate-900",
</div> "text-xs text-slate-600",
</div> )
</section> } else {
</main> (
</body> "border border-slate-200 bg-white",
</html>", "rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white",
month_label, items "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. Self {
pub fn render_day_page(date_text: &str, entries: &[FoodEntry]) -> String { is_padding: false,
let daily_total: i64 = entries.iter().map(|entry| entry.calories).sum(); is_future,
let rows = render_entry_rows(date_text, entries); href,
date_text,
format!( day_num,
"<!doctype html> total,
<html> entry_label,
<head> weight_label,
<meta charset=\"utf-8\" /> card_class: format!("{interactive} {card_tint}{today_ring}"),
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> badge_class: badge_class.to_string(),
<title>Day Entries</title> total_class: total_class.to_string(),
<script src=\"https://cdn.tailwindcss.com\"></script> entry_class: entry_class.to_string(),
</head> }
<body class=\"min-h-screen bg-gradient-to-b from-slate-100 via-white to-cyan-100 text-slate-900\">
<main class=\"mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8\">
<a href=\"/\" class=\"inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition hover:text-slate-900\">
<span>&larr;</span>
<span>Back to calendar</span>
</a>
<section class=\"mt-4 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur\">
<h1 class=\"text-3xl font-bold tracking-tight\">{}</h1>
<p class=\"mt-3 inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-900\">
Daily total: {} cal
</p>
<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/{}/add\" class=\"mt-3 grid gap-2 sm:grid-cols-3\">
<input
type=\"text\"
name=\"name\"
placeholder=\"Food name\"
required
class=\"sm:col-span-2 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\"
/>
<input
type=\"number\"
name=\"calories\"
placeholder=\"Calories\"
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\">
<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>
</div>
<div class=\"mt-5 overflow-hidden rounded-2xl border border-slate-200\">
<table class=\"w-full border-collapse\">
<thead class=\"bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500\">
<tr><th class=\"px-5 py-3\">Entry</th><th class=\"px-5 py-3\">Calories</th></tr>
</thead>
<tbody class=\"bg-white\">{}</tbody>
</table>
</div>
</section>
</main>
</body>
</html>",
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!(
"<div class=\"h-32 rounded-2xl border border-slate-200 bg-slate-100/80 p-3 opacity-65\">
<div class=\"flex items-start justify-between\">
<span class=\"text-sm font-semibold text-slate-500\">{date}</span>
<span class=\"rounded-full bg-slate-400 px-2 py-0.5 text-xs font-semibold text-white\">{day_num}</span>
</div>
<div class=\"mt-6\">
<p class=\"text-xl font-bold text-slate-500\">{total} cal</p>
<p class=\"text-xs text-slate-500\">{entry_line}</p>
<p class=\"mt-1 text-[11px] uppercase tracking-wide text-slate-400\">Future</p>
</div>
</div>",
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!(
"<a class=\"{class}\" href=\"/day/{date}\">
<div class=\"flex items-start justify-between\">
<span class=\"text-sm font-semibold text-slate-500\">{date}</span>
<span class=\"rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white\">{day_num}</span>
</div>
<div class=\"mt-6\">
<p class=\"text-xl font-bold text-slate-900\">{total} cal</p>
<p class=\"text-xs text-slate-600\">{entry_line}</p>
</div>
</a>",
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('\"', "&quot;")
.replace('\'', "&#39;")
}
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 render_entry_rows(date_text: &str, entries: &[FoodEntry]) -> String { pub struct CalendarPageView {
let mut rows = String::new(); pub month_label: String,
pub today_date: String,
if entries.is_empty() { pub focus_month_value: String,
rows.push_str( pub nav: CalendarNav,
"<tr><td colspan=\"2\" class=\"px-5 py-8 text-center text-slate-500\">No entries yet for this day.</td></tr>", pub cells: Vec<CalendarCellView>,
); }
return rows;
} pub struct DayPageView {
pub date_text: String,
for entry in entries { pub daily_total: i64,
rows.push_str(&format!( pub entries: Vec<FoodEntry>,
"<tr class=\"border-t border-slate-200 align-top\"> pub weight_value: String,
<td class=\"px-5 py-3\"> }
<form method=\"post\" action=\"/day/{date}/entry/{id}/update\" class=\"grid gap-2 md:grid-cols-2\">
<input pub struct ReportCardView {
type=\"text\" pub title: String,
name=\"name\" pub range_label: String,
value=\"{name}\" pub day_count: i64,
required pub total_calories: i64,
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\" pub average_calories_per_day: String,
/> }
<input
type=\"number\" pub struct ReportsPageView {
name=\"calories\" pub generated_for_date: String,
value=\"{calories}\" pub cards: Vec<ReportCardView>,
min=\"0\" pub chart_labels_js: String,
required pub weight_rolling_js: String,
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\" pub calories_rolling_js: String,
/> pub loss_rolling_js: String,
<div class=\"md:col-span-2\"> pub bmr_label: String,
<button type=\"submit\" class=\"rounded-lg bg-teal-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-teal-800\">Save</button> }
</div>
</form> pub struct PlanningPageView {
<form method=\"post\" action=\"/day/{date}/entry/{id}/delete\" class=\"mt-2\"> pub target_weight_value: String,
<button type=\"submit\" class=\"rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100\">Delete</button> pub target_calories_value: String,
</form> pub bmr_value: String,
</td> }
<td class=\"px-5 py-3 font-semibold text-slate-900\">{calories} cal</td>
</tr>", #[derive(Template)]
date = date_text, #[template(path = "calendar.html")]
id = entry.id, struct CalendarTemplate<'a> {
name = escape_html(&entry.name), page: &'a CalendarPageView,
calories = entry.calories }
));
} #[derive(Template)]
#[template(path = "day.html")]
rows 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<String, askama::Error> {
CalendarTemplate { page }.render()
}
pub fn render_day_page(page: &DayPageView) -> Result<String, askama::Error> {
DayTemplate { page }.render()
}
pub fn render_reports_page(page: &ReportsPageView) -> Result<String, askama::Error> {
ReportsTemplate { page }.render()
}
pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::Error> {
PlanningTemplate { page }.render()
} }

37
templates/base.html Normal file
View File

@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Calorie Tracker{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-100 via-white to-cyan-100 text-slate-900">
<main class="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
<nav class="mb-6 rounded-2xl border border-slate-200/80 bg-white/80 p-2 shadow-sm backdrop-blur">
<div class="flex gap-2">
<a
href="/"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "calendar" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Calendar
</a>
<a
href="/reports"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "reports" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Reports
</a>
<a
href="/planning"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "planning" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Planning
</a>
</div>
</nav>
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

180
templates/calendar.html Normal file
View File

@ -0,0 +1,180 @@
{% extends "base.html" %}
{% block title %}{{ page.month_label }}{% endblock %}
{% block content %}
<header class="mb-6 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs uppercase tracking-[0.22em] text-slate-500">Calorie Tracker</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">{{ page.month_label }}</h1>
<p class="mt-3 text-sm text-slate-600">Click any day to inspect all logged food entries.</p>
<p class="mt-2 text-xs text-slate-500">Shortcuts: `Space` opens today, `?` opens keyboard help.</p>
</div>
<div class="flex flex-col items-stretch gap-2 md:items-end">
<a href="/day/{{ page.today_date }}" class="inline-flex items-center justify-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700">
Open Today
</a>
<div class="grid grid-cols-3 gap-2">
<a id="prev-month-link" href="{{ page.nav.prev_month_href }}" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">&larr; Month</a>
<a href="{{ page.nav.current_month_href }}" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">This Month</a>
<a id="next-month-link" href="{{ page.nav.next_month_href }}" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Month &rarr;</a>
<a href="{{ page.nav.prev_year_href }}" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">&larr; Year</a>
<form id="month-picker-form" class="col-span-1">
<input id="month-picker" type="month" value="{{ page.focus_month_value }}" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-xs font-semibold text-slate-700" />
</form>
<a href="{{ page.nav.next_year_href }}" class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50">Year &rarr;</a>
<div class="col-span-3">
{% if page.nav.show_current_month_link %}
<a href="{{ page.nav.current_month_href }}" class="inline-flex items-center justify-center rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800 hover:bg-emerald-100">Today</a>
{% else %}
<span class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-400">Viewing current month</span>
{% endif %}
</div>
</div>
</div>
</div>
</header>
<section class="rounded-3xl border border-slate-200/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6">
<div class="overflow-x-auto">
<div class="min-w-[720px]">
<div class="mb-4 grid grid-cols-7 gap-2">
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Sun</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Mon</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Tue</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Wed</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Thu</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Fri</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Sat</div>
</div>
<div class="grid grid-cols-7 gap-2">
{% for cell in page.cells %}
{% if cell.is_padding %}
<div class="h-32 rounded-2xl border border-dashed border-slate-300/80 bg-white/40" aria-hidden="true"></div>
{% else %}
{% if cell.is_future %}
<div class="h-32 rounded-2xl border border-slate-200 bg-slate-100/80 p-3 opacity-65">
<div class="flex items-start justify-between">
<span class="text-sm font-semibold text-slate-500">{{ cell.date_text }}</span>
<span class="rounded-full bg-slate-400 px-2 py-0.5 text-xs font-semibold text-white">{{ cell.day_num }}</span>
</div>
<div class="mt-6">
<p class="text-xl font-bold text-slate-500">{{ cell.total }} cal</p>
<p class="text-xs text-slate-500">{{ cell.entry_label }}</p>
<p class="text-xs text-slate-500">{{ cell.weight_label }}</p>
<p class="mt-1 text-[11px] uppercase tracking-wide text-slate-400">Future</p>
</div>
</div>
{% else %}
<a class="{{ cell.card_class }}" href="{{ cell.href }}">
<div class="flex items-start justify-between">
<span class="text-sm font-semibold text-slate-500">{{ cell.date_text }}</span>
<span class="{{ cell.badge_class }}">{{ cell.day_num }}</span>
</div>
<div class="mt-6">
<p class="{{ cell.total_class }}">{{ cell.total }} cal</p>
<p class="{{ cell.entry_class }}">{{ cell.entry_label }}</p>
<p class="{{ cell.entry_class }}">{{ cell.weight_label }}</p>
</div>
</a>
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</section>
<div id="shortcut-help" class="hidden fixed inset-0 z-50 bg-slate-900/60 p-4">
<div class="mx-auto mt-12 max-w-md rounded-2xl bg-white p-5 shadow-xl">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-slate-900">Keyboard Shortcuts</h2>
<button id="shortcut-close" class="rounded-md border border-slate-300 px-2 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50">Close</button>
</div>
<ul class="mt-4 space-y-2 text-sm text-slate-700">
<li><span class="font-semibold">Space</span>: Open today's entry</li>
<li><span class="font-semibold">Left Arrow</span>: Previous month</li>
<li><span class="font-semibold">Right Arrow</span>: Next month</li>
<li><span class="font-semibold">?</span>: Open/close this help</li>
<li><span class="font-semibold">Esc</span>: Close this help</li>
</ul>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const shortcutHelp = document.getElementById("shortcut-help");
const shortcutClose = document.getElementById("shortcut-close");
const monthPicker = document.getElementById("month-picker");
const prevMonthHref = "{{ page.nav.prev_month_href }}";
const nextMonthHref = "{{ page.nav.next_month_href }}";
function toggleShortcutHelp(forceOpen) {
const shouldOpen = typeof forceOpen === "boolean"
? forceOpen
: shortcutHelp.classList.contains("hidden");
shortcutHelp.classList.toggle("hidden", !shouldOpen);
}
function goToMonthFromPicker() {
if (!monthPicker.value) return;
const parts = monthPicker.value.split("-");
if (parts.length !== 2) return;
window.location.href = "/calendar/" + Number(parts[0]) + "/" + Number(parts[1]);
}
shortcutHelp.addEventListener("click", function (event) {
if (event.target === shortcutHelp) toggleShortcutHelp(false);
});
shortcutClose.addEventListener("click", function () {
toggleShortcutHelp(false);
});
monthPicker.addEventListener("change", goToMonthFromPicker);
document.getElementById("month-picker-form").addEventListener("submit", function (event) {
event.preventDefault();
goToMonthFromPicker();
});
document.addEventListener("keydown", function (event) {
const target = event.target;
if (target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
)) {
return;
}
if (event.key === "?") {
event.preventDefault();
toggleShortcutHelp();
return;
}
if (event.key === "Escape") {
toggleShortcutHelp(false);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
window.location.href = prevMonthHref;
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
window.location.href = nextMonthHref;
return;
}
if (event.code === "Space" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
event.preventDefault();
window.location.href = "/day/{{ page.today_date }}";
}
});
</script>
{% endblock %}

200
templates/day.html Normal file
View File

@ -0,0 +1,200 @@
{% extends "base.html" %}
{% block title %}Entries for {{ page.date_text }}{% endblock %}
{% block content %}
<a id="back-to-calendar" href="/" class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition hover:text-slate-900">
<span>&larr;</span>
<span>Back to calendar</span>
</a>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<h1 class="text-3xl font-bold tracking-tight">{{ page.date_text }}</h1>
<p class="mt-3 inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-900">
Daily total: {{ page.daily_total }} cal
</p>
<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">Daily weight (lbs)</h2>
<form id="weight-form" method="post" action="/day/{{ page.date_text }}/weight" data-autosave="true" class="mt-3 flex flex-wrap items-center gap-2">
<input
type="number"
step="0.1"
min="1"
name="weight"
value="{{ page.weight_value }}"
placeholder="Weight (lbs)"
required
class="w-40 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 data-status-for="weight-form" class="text-xs text-slate-500">Auto-save enabled</span>
</form>
</div>
<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">
<input
type="text"
name="name"
placeholder="Food name"
required
class="sm:col-span-2 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"
/>
<input
type="number"
name="calories"
placeholder="Calories"
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">
<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>
</div>
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-200">
<table class="w-full border-collapse">
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr><th class="px-5 py-3">Entry</th><th class="px-5 py-3">Calories</th></tr>
</thead>
<tbody class="bg-white">
{% if page.entries.is_empty() %}
<tr><td colspan="2" class="px-5 py-8 text-center text-slate-500">No entries yet for this day.</td></tr>
{% else %}
{% for entry in page.entries %}
<tr class="border-t border-slate-200 align-top">
<td class="px-5 py-3">
<form id="entry-form-{{ entry.id }}" method="post" action="/day/{{ page.date_text }}/entry/{{ entry.id }}/update" data-autosave="true" class="grid gap-2 md:grid-cols-2">
<input
type="text"
name="name"
value="{{ entry.name }}"
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"
/>
<input
type="number"
name="calories"
value="{{ entry.calories }}"
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="md:col-span-2 text-xs text-slate-500" data-status-for="entry-form-{{ entry.id }}">Auto-save enabled</div>
</form>
<form method="post" action="/day/{{ page.date_text }}/entry/{{ entry.id }}/delete" class="mt-2">
<button type="submit" class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100">Delete</button>
</form>
</td>
<td class="px-5 py-3 font-semibold text-slate-900">{{ entry.calories }} cal</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
const AUTO_SAVE_DELAY_MS = 700;
const autosaveForms = Array.from(document.querySelectorAll("form[data-autosave='true']"));
const backLink = document.getElementById("back-to-calendar");
const pending = new Map(); // form -> timer id
const dirty = new Set();
function formStatus(form) {
return document.querySelector(`[data-status-for="${form.id}"]`);
}
function setStatus(form, text, cssClass = "text-slate-500") {
const node = formStatus(form);
if (!node) return;
node.className = `text-xs ${cssClass}`;
node.textContent = text;
}
function serializeForm(form) {
return new URLSearchParams(new FormData(form));
}
async function saveForm(form) {
const body = serializeForm(form);
setStatus(form, "Saving...", "text-amber-700");
try {
const response = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
credentials: "same-origin"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
dirty.delete(form);
setStatus(form, "Saved", "text-emerald-700");
} catch (error) {
setStatus(form, "Save failed", "text-rose-700");
}
}
function queueAutosave(form) {
dirty.add(form);
setStatus(form, "Editing...", "text-slate-500");
if (pending.has(form)) {
clearTimeout(pending.get(form));
}
const timer = setTimeout(async () => {
pending.delete(form);
await saveForm(form);
}, AUTO_SAVE_DELAY_MS);
pending.set(form, timer);
}
async function flushAllAutosaves() {
for (const [form, timer] of pending.entries()) {
clearTimeout(timer);
pending.delete(form);
}
for (const form of Array.from(dirty)) {
await saveForm(form);
}
}
autosaveForms.forEach((form) => {
form.addEventListener("input", () => queueAutosave(form));
form.addEventListener("change", () => queueAutosave(form));
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (pending.has(form)) {
clearTimeout(pending.get(form));
pending.delete(form);
}
dirty.add(form);
await saveForm(form);
});
});
backLink.addEventListener("click", async (event) => {
event.preventDefault();
await flushAllAutosaves();
window.location.href = backLink.getAttribute("href");
});
document.addEventListener("keydown", async (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
await flushAllAutosaves();
window.location.href = "/";
});
window.addEventListener("beforeunload", () => {
// Best-effort last-moment save for browser back/close/refresh.
for (const form of Array.from(dirty)) {
navigator.sendBeacon(form.action, serializeForm(form));
}
});
</script>
{% endblock %}

54
templates/planning.html Normal file
View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Planning{% endblock %}
{% block content %}
<section class="rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<h1 class="text-3xl font-bold tracking-tight">Planning</h1>
<p class="mt-3 text-sm text-slate-600">Set target weight (lbs), target daily calories, and BMR. Leave blank to unset.</p>
<form method="post" action="/planning" class="mt-6 grid max-w-xl gap-4">
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">Target Weight (lbs)</span>
<input
type="number"
step="0.1"
min="1"
name="target_weight"
value="{{ page.target_weight_value }}"
placeholder="e.g. 175.0"
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"
/>
</label>
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">Target Daily Calories</span>
<input
type="number"
min="0"
name="target_calories"
value="{{ page.target_calories_value }}"
placeholder="e.g. 2200"
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"
/>
</label>
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">BMR (cal/day)</span>
<input
type="number"
min="1"
step="1"
name="bmr"
value="{{ page.bmr_value }}"
placeholder="e.g. 1900"
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"
/>
</label>
<div>
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Save Planning</button>
</div>
</form>
</section>
{% endblock %}

143
templates/reports.html Normal file
View File

@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Reports{% endblock %}
{% block content %}
<section class="rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<h1 class="text-3xl font-bold tracking-tight">Reports</h1>
<p class="mt-3 text-sm text-slate-600">Averages include days with no entries as 0 calories.</p>
<p class="mt-1 text-xs text-slate-500">Generated for {{ page.generated_for_date }} (local date)</p>
<p class="mt-1 text-xs text-slate-500">BMR for estimate: {{ page.bmr_label }}</p>
</section>
<section class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{% for card in page.cards %}
<article class="rounded-2xl border border-slate-200/90 bg-white p-4 shadow-sm">
<h2 class="text-base font-bold text-slate-900">{{ card.title }}</h2>
<p class="mt-1 text-xs text-slate-500">{{ card.range_label }}</p>
<p class="mt-4 text-3xl font-bold tracking-tight text-slate-900">{{ card.average_calories_per_day }}</p>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">avg cal/day</p>
<dl class="mt-4 space-y-1 text-sm text-slate-700">
<div class="flex justify-between">
<dt>Total</dt>
<dd class="font-semibold">{{ card.total_calories }} cal</dd>
</div>
<div class="flex justify-between">
<dt>Days</dt>
<dd class="font-semibold">{{ card.day_count }}</dd>
</div>
</dl>
</article>
{% endfor %}
</section>
<section class="mt-5 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<h2 class="text-xl font-bold tracking-tight text-slate-900">Rolling 3-Day Trends</h2>
<p class="mt-1 text-sm text-slate-600">Weight uses available weigh-ins in each 3-day window. Calories include zero-entry days.</p>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<article class="rounded-2xl border border-slate-200 bg-white p-4">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Calories (3-day average)</h3>
<div class="mt-3 h-72">
<canvas id="calorie-trend-chart"></canvas>
</div>
</article>
<article class="rounded-2xl border border-slate-200 bg-white p-4">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Weight (3-day average, lbs)</h3>
<div class="mt-3 h-72">
<canvas id="weight-trend-chart"></canvas>
</div>
</article>
<article class="rounded-2xl border border-slate-200 bg-white p-4 lg:col-span-2">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Estimated Daily Weight Change (lbs/day)</h3>
<p class="mt-1 text-xs text-slate-500">Formula: (rolling 3-day avg calories - BMR) / 3500. Negative = loss, positive = gain.</p>
<div class="mt-3 h-72">
<canvas id="loss-trend-chart"></canvas>
</div>
</article>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const labels = {{ page.chart_labels_js|safe }};
const rollingCalories = {{ page.calories_rolling_js|safe }};
const rollingWeight = {{ page.weight_rolling_js|safe }};
const rollingLoss = {{ page.loss_rolling_js|safe }};
new Chart(
document.getElementById("calorie-trend-chart"),
{
type: "line",
data: {
labels,
datasets: [
{
label: "Rolling 3-day calories",
data: rollingCalories,
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 1.5
}
]
},
options: {
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
}
);
new Chart(
document.getElementById("weight-trend-chart"),
{
type: "line",
data: {
labels,
datasets: [
{
label: "Rolling 3-day weight (lbs)",
data: rollingWeight,
borderColor: "#2563eb",
backgroundColor: "rgba(37, 99, 235, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 1.5,
spanGaps: true
}
]
},
options: {
maintainAspectRatio: false
}
}
);
new Chart(
document.getElementById("loss-trend-chart"),
{
type: "line",
data: {
labels,
datasets: [
{
label: "Estimated lbs/day",
data: rollingLoss,
borderColor: "#7c3aed",
backgroundColor: "rgba(124, 58, 237, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 1.5
}
]
},
options: {
maintainAspectRatio: false
}
}
);
</script>
{% endblock %}