version 0.1
This commit is contained in:
parent
fc7b2d6780
commit
d781a42107
|
|
@ -1 +1,2 @@
|
|||
/target
|
||||
tmp/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
178
src/db.rs
178
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<f64>,
|
||||
pub target_calories: Option<i64>,
|
||||
pub bmr: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<Vec<FoodEntry>
|
|||
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.
|
||||
pub fn insert_entry(
|
||||
conn: &Connection,
|
||||
|
|
|
|||
557
src/handlers.rs
557
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<AppState>) -> Result<Html<String>, 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<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;
|
||||
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::<Vec<_>>()
|
||||
} 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<AppState>) -> Result<Html<String>, 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<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.
|
||||
|
|
@ -78,12 +140,49 @@ pub async fn show_day_entries(
|
|||
) -> Result<Html<String>, 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<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.
|
||||
|
|
@ -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<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> {
|
||||
|
|
@ -163,30 +504,106 @@ fn parse_entry_form_fields(form: &HashMap<String, String>) -> 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<Option<f64>, AppError> {
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let parsed = trimmed.parse::<f64>().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<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,
|
||||
format!("Database error: {}", error),
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_template_error(error: askama::Error) -> AppError {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Template error: {}", error),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
// 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(
|
||||
|
|
|
|||
424
src/views.rs
424
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(
|
||||
"<div class=\"h-32 rounded-2xl border border-dashed border-slate-300/80 bg-white/40\" aria-hidden=\"true\"></div>",
|
||||
),
|
||||
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!(
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||
<title>Hello Calories</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-10 sm:px-6 lg:px-8\">
|
||||
<header class=\"mb-8 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur\">
|
||||
<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\">{}</h1>
|
||||
<p class=\"mt-3 text-sm text-slate-600\">Click any day to inspect all logged food entries.</p>
|
||||
</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\">
|
||||
{}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>",
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
"<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||
<title>Day Entries</title>
|
||||
<script src=\"https://cdn.tailwindcss.com\"></script>
|
||||
</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>←</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
|
||||
} 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",
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
(
|
||||
"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 {
|
||||
""
|
||||
};
|
||||
|
||||
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('&', "&")
|
||||
.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(
|
||||
"<tr><td colspan=\"2\" class=\"px-5 py-8 text-center text-slate-500\">No entries yet for this day.</td></tr>",
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
rows.push_str(&format!(
|
||||
"<tr class=\"border-t border-slate-200 align-top\">
|
||||
<td class=\"px-5 py-3\">
|
||||
<form method=\"post\" action=\"/day/{date}/entry/{id}/update\" class=\"grid gap-2 md:grid-cols-2\">
|
||||
<input
|
||||
type=\"text\"
|
||||
name=\"name\"
|
||||
value=\"{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=\"{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\">
|
||||
<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>
|
||||
<form method=\"post\" action=\"/day/{date}/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\">{calories} cal</td>
|
||||
</tr>",
|
||||
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<CalendarCellView>,
|
||||
}
|
||||
|
||||
pub struct DayPageView {
|
||||
pub date_text: String,
|
||||
pub daily_total: i64,
|
||||
pub entries: Vec<FoodEntry>,
|
||||
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<ReportCardView>,
|
||||
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<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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">← 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 →</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">← 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 →</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 %}
|
||||
|
|
@ -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>←</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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in New Issue