adding chang epassword and public

This commit is contained in:
Peter Li 2026-02-07 16:23:03 -08:00
parent c2fec04090
commit bc4cf24f23
11 changed files with 479 additions and 12 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target /target
.DS_Store
config/ config/
tmp/ tmp/
data/ data/

View File

@ -25,6 +25,7 @@ Open: http://127.0.0.1:3000
```conf ```conf
allow_signup = true allow_signup = true
bind_port = 3000
``` ```
If the file is missing, the app creates it with defaults on startup. If the file is missing, the app creates it with defaults on startup.

View File

@ -4,11 +4,15 @@ use std::path::Path;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct AppConfig { pub struct AppConfig {
pub allow_signup: bool, pub allow_signup: bool,
pub bind_port: u16,
} }
impl Default for AppConfig { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { allow_signup: true } Self {
allow_signup: true,
bind_port: 3000,
}
} }
} }
@ -27,9 +31,17 @@ pub fn load_config() -> AppConfig {
continue; continue;
} }
if let Some((key, value)) = line.split_once('=') { if let Some((key, value)) = line.split_once('=') {
if key.trim() == "allow_signup" { let key = key.trim();
let v = value.trim(); let value = value.trim();
cfg.allow_signup = v.eq_ignore_ascii_case("true") || v == "1";
if key == "allow_signup" {
cfg.allow_signup = value.eq_ignore_ascii_case("true") || value == "1";
}
if key == "bind_port" {
if let Ok(port) = value.parse::<u16>() {
cfg.bind_port = port;
}
} }
} }
} }
@ -45,6 +57,6 @@ fn ensure_default_config_file(path: &str) {
let _ = fs::create_dir_all(parent); let _ = fs::create_dir_all(parent);
} }
let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n"; let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n\n# TCP port the web server binds to.\nbind_port = 3000\n";
let _ = fs::write(path, default); let _ = fs::write(path, default);
} }

View File

@ -156,6 +156,18 @@ pub fn fetch_user_by_id(
.optional() .optional()
} }
pub fn update_user_password(
conn: &Connection,
user_id: i64,
password_hash: &str,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2",
params![password_hash, user_id],
)?;
Ok(())
}
pub fn create_or_replace_session( pub fn create_or_replace_session(
conn: &Connection, conn: &Connection,
token: &str, token: &str,

View File

@ -14,9 +14,9 @@ use tokio::sync::Mutex;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::views::{ use crate::views::{
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView, self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView,
SignupPageView, ReportCardView, ReportsPageView, SignupPageView,
}; };
type AppError = (StatusCode, String); type AppError = (StatusCode, String);
@ -228,6 +228,7 @@ pub async fn show_public_profile(
let key = day.format("%Y-%m-%d").to_string(); let key = day.format("%Y-%m-%d").to_string();
recent_days.push(PublicDaySummaryView { recent_days.push(PublicDaySummaryView {
date_text: key.clone(), date_text: key.clone(),
day_href: format!("/u/{}/day/{}", user.username, key),
calories: *calorie_map.get(&key).unwrap_or(&0), calories: *calorie_map.get(&key).unwrap_or(&0),
weight_label: weight_map weight_label: weight_map
.get(&key) .get(&key)
@ -269,6 +270,16 @@ pub async fn show_public_profile(
Vec::new() Vec::new()
}; };
let mut chart_labels = Vec::new();
let mut calories_series = Vec::new();
let mut weights_series = Vec::new();
for row in &recent_days {
chart_labels.push(row.date_text.clone());
calories_series.push(row.calories as f64);
let weight = weight_map.get(&row.date_text).copied();
weights_series.push(weight);
}
let page = PublicProfilePageView { let page = PublicProfilePageView {
username: user.username, username: user.username,
show_entries: planning.public_entries, show_entries: planning.public_entries,
@ -277,6 +288,9 @@ pub async fn show_public_profile(
show_planning: planning.public_planning, show_planning: planning.public_planning,
recent_days, recent_days,
report_cards, report_cards,
chart_labels_js: js_string_array(&chart_labels),
calories_js: js_f64_array(&calories_series),
weights_js: js_optional_f64_array(&weights_series),
target_weight_label: planning target_weight_label: planning
.target_weight .target_weight
.map(|v| format!("{v:.1} lbs")) .map(|v| format!("{v:.1} lbs"))
@ -410,6 +424,67 @@ pub async fn show_planning(
Ok(with_session_cookie(resp, &auth.token)) Ok(with_session_cookie(resp, &auth.token))
} }
pub async fn show_change_password(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let auth = get_auth_user(&state, &headers).await?;
let Some(auth) = auth else {
return Ok(Redirect::to("/login").into_response());
};
let page = ChangePasswordPageView {
error: String::new(),
success: String::new(),
};
let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?)
.into_response();
Ok(with_session_cookie(resp, &auth.token))
}
pub async fn change_password(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let auth = get_auth_user(&state, &headers).await?;
let Some(auth) = auth else {
return Ok(Redirect::to("/login").into_response());
};
let current = form.get("current_password").map(|v| v.as_str()).unwrap_or("");
let new_password = form.get("new_password").map(|v| v.as_str()).unwrap_or("");
let confirm = form.get("confirm_password").map(|v| v.as_str()).unwrap_or("");
if current.is_empty() || new_password.is_empty() || confirm.is_empty() {
return render_change_password_page(
&auth.token,
"All password fields are required",
"",
);
}
if !verify_password(&auth.user.password_hash, current) {
return render_change_password_page(&auth.token, "Current password is incorrect", "");
}
if new_password.len() < 4 {
return render_change_password_page(
&auth.token,
"New password must be at least 4 chars",
"",
);
}
if new_password != confirm {
return render_change_password_page(&auth.token, "New passwords do not match", "");
}
let new_hash = hash_password(new_password)?;
{
let db_conn = state.db.lock().await;
db::update_user_password(&db_conn, auth.user.id, &new_hash).map_err(internal_db_error)?;
}
render_change_password_page(&auth.token, "", "Password updated")
}
pub async fn update_planning( pub async fn update_planning(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -578,6 +653,61 @@ pub async fn remove_entry(
)) ))
} }
pub async fn show_public_day(
State(state): State<AppState>,
Path((username, date_text)): Path<(String, String)>,
) -> Result<Response, AppError> {
validate_date(&date_text)?;
let user = {
let db_conn = state.db.lock().await;
db::fetch_user_by_username(&db_conn, &username).map_err(internal_db_error)?
};
let Some(user) = user else {
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
};
let planning = {
let db_conn = state.db.lock().await;
db::fetch_planning(&db_conn, user.id).map_err(internal_db_error)?
};
if !planning.public_entries {
return Err((StatusCode::NOT_FOUND, "Page not found".to_string()));
}
let (entries, weight) = {
let db_conn = state.db.lock().await;
let entries =
db::fetch_day_entries(&db_conn, user.id, &date_text).map_err(internal_db_error)?;
let weight = db::fetch_weight_for_day(&db_conn, user.id, &date_text)
.map_err(internal_db_error)?;
(entries, weight)
};
let daily_total = entries.iter().map(|entry| entry.calories).sum();
let chart_labels = entries
.iter()
.map(|entry| entry.name.clone())
.collect::<Vec<_>>();
let chart_values = entries
.iter()
.map(|entry| entry.calories as f64)
.collect::<Vec<_>>();
let page = PublicDayPageView {
username: user.username,
date_text,
daily_total,
entries,
show_weight: planning.public_weights,
weight_label: weight
.map(|w| format!("{w:.1} lbs"))
.unwrap_or_else(|| "-".to_string()),
chart_labels_js: js_string_array(&chart_labels),
chart_values_js: js_f64_array(&chart_values),
};
Ok(Html(views::render_public_day_page(&page).map_err(internal_template_error)?).into_response())
}
async fn render_calendar_for_month( async fn render_calendar_for_month(
state: &AppState, state: &AppState,
auth: &AuthUser, auth: &AuthUser,
@ -832,6 +962,20 @@ async fn render_signup_with_error(state: &AppState, error: &str) -> Result<Respo
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
} }
fn render_change_password_page(
token: &str,
error: &str,
success: &str,
) -> Result<Response, AppError> {
let page = ChangePasswordPageView {
error: error.to_string(),
success: success.to_string(),
};
let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?)
.into_response();
Ok(with_session_cookie(resp, token))
}
fn hash_password(password: &str) -> Result<String, AppError> { fn hash_password(password: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut ArgonOsRng); let salt = SaltString::generate(&mut ArgonOsRng);
Argon2::default() Argon2::default()

View File

@ -15,7 +15,7 @@ use crate::handlers::AppState;
const DB_DIR: &str = "data"; const DB_DIR: &str = "data";
const DB_PATH: &str = "data/app.db"; const DB_PATH: &str = "data/app.db";
const LISTEN_ADDR: &str = "127.0.0.1:3000"; const LISTEN_HOST: &str = "127.0.0.1";
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -40,9 +40,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/signup", post(handlers::signup)) .route("/signup", post(handlers::signup))
.route("/logout", post(handlers::logout)) .route("/logout", post(handlers::logout))
.route("/u/{username}", get(handlers::show_public_profile)) .route("/u/{username}", get(handlers::show_public_profile))
.route("/u/{username}/day/{date}", get(handlers::show_public_day))
.route("/reports", get(handlers::show_reports)) .route("/reports", get(handlers::show_reports))
.route("/planning", get(handlers::show_planning)) .route("/planning", get(handlers::show_planning))
.route("/planning", post(handlers::update_planning)) .route("/planning", post(handlers::update_planning))
.route("/planning/password", get(handlers::show_change_password))
.route("/planning/password", post(handlers::change_password))
.route( .route(
"/calendar/{year}/{month}", "/calendar/{year}/{month}",
get(handlers::show_calendar_for_month), get(handlers::show_calendar_for_month),
@ -57,8 +60,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
) )
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(LISTEN_ADDR).await?; let listen_addr = format!("{}:{}", LISTEN_HOST, app_config.bind_port);
println!("Listening on http://{}", LISTEN_ADDR); let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
println!("Listening on http://{}", listen_addr);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())

View File

@ -153,6 +153,11 @@ pub struct PlanningPageView {
pub public_planning: bool, pub public_planning: bool,
} }
pub struct ChangePasswordPageView {
pub error: String,
pub success: String,
}
pub struct LoginPageView { pub struct LoginPageView {
pub allow_signup: bool, pub allow_signup: bool,
pub error: String, pub error: String,
@ -166,6 +171,7 @@ pub struct SignupPageView {
#[derive(Clone)] #[derive(Clone)]
pub struct PublicDaySummaryView { pub struct PublicDaySummaryView {
pub date_text: String, pub date_text: String,
pub day_href: String,
pub calories: i64, pub calories: i64,
pub weight_label: String, pub weight_label: String,
} }
@ -178,11 +184,25 @@ pub struct PublicProfilePageView {
pub show_planning: bool, pub show_planning: bool,
pub recent_days: Vec<PublicDaySummaryView>, pub recent_days: Vec<PublicDaySummaryView>,
pub report_cards: Vec<ReportCardView>, pub report_cards: Vec<ReportCardView>,
pub chart_labels_js: String,
pub calories_js: String,
pub weights_js: String,
pub target_weight_label: String, pub target_weight_label: String,
pub target_calories_label: String, pub target_calories_label: String,
pub bmr_label: String, pub bmr_label: String,
} }
pub struct PublicDayPageView {
pub username: String,
pub date_text: String,
pub daily_total: i64,
pub entries: Vec<FoodEntry>,
pub show_weight: bool,
pub weight_label: String,
pub chart_labels_js: String,
pub chart_values_js: String,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "calendar.html")] #[template(path = "calendar.html")]
struct CalendarTemplate<'a> { struct CalendarTemplate<'a> {
@ -207,6 +227,12 @@ struct PlanningTemplate<'a> {
page: &'a PlanningPageView, page: &'a PlanningPageView,
} }
#[derive(Template)]
#[template(path = "change_password.html")]
struct ChangePasswordTemplate<'a> {
page: &'a ChangePasswordPageView,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
struct LoginTemplate<'a> { struct LoginTemplate<'a> {
@ -225,6 +251,12 @@ struct PublicProfileTemplate<'a> {
page: &'a PublicProfilePageView, page: &'a PublicProfilePageView,
} }
#[derive(Template)]
#[template(path = "public_day.html")]
struct PublicDayTemplate<'a> {
page: &'a PublicDayPageView,
}
impl CalendarTemplate<'_> { impl CalendarTemplate<'_> {
fn active_tab(&self) -> &str { fn active_tab(&self) -> &str {
"calendar" "calendar"
@ -249,6 +281,12 @@ impl PlanningTemplate<'_> {
} }
} }
impl ChangePasswordTemplate<'_> {
fn active_tab(&self) -> &str {
"planning"
}
}
impl LoginTemplate<'_> { impl LoginTemplate<'_> {
fn active_tab(&self) -> &str { fn active_tab(&self) -> &str {
"" ""
@ -267,6 +305,12 @@ impl PublicProfileTemplate<'_> {
} }
} }
impl PublicDayTemplate<'_> {
fn active_tab(&self) -> &str {
""
}
}
pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> { pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> {
CalendarTemplate { page }.render() CalendarTemplate { page }.render()
} }
@ -283,6 +327,10 @@ pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::E
PlanningTemplate { page }.render() PlanningTemplate { page }.render()
} }
pub fn render_change_password_page(page: &ChangePasswordPageView) -> Result<String, askama::Error> {
ChangePasswordTemplate { page }.render()
}
pub fn render_login_page(page: &LoginPageView) -> Result<String, askama::Error> { pub fn render_login_page(page: &LoginPageView) -> Result<String, askama::Error> {
LoginTemplate { page }.render() LoginTemplate { page }.render()
} }
@ -294,3 +342,7 @@ pub fn render_signup_page(page: &SignupPageView) -> Result<String, askama::Error
pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result<String, askama::Error> { pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result<String, askama::Error> {
PublicProfileTemplate { page }.render() PublicProfileTemplate { page }.render()
} }
pub fn render_public_day_page(page: &PublicDayPageView) -> Result<String, askama::Error> {
PublicDayTemplate { page }.render()
}

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Change Password{% 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">Change Password</h1>
<p class="mt-3 text-sm text-slate-600">Use your current password to set a new one.</p>
{% if !page.error.is_empty() %}
<p class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{{ page.error }}</p>
{% endif %}
{% if !page.success.is_empty() %}
<p class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ page.success }}</p>
{% endif %}
<form method="post" action="/planning/password" class="mt-6 grid max-w-xl gap-4">
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">Current Password</span>
<input
type="password"
name="current_password"
required
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
/>
</label>
<label class="grid gap-2">
<span class="text-sm font-semibold text-slate-700">New Password</span>
<input
type="password"
name="new_password"
required
minlength="4"
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">Confirm New Password</span>
<input
type="password"
name="confirm_password"
required
minlength="4"
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 class="flex flex-wrap items-center gap-2">
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Update Password</button>
<a href="/planning" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100">Back to Planning</a>
</div>
</form>
</section>
{% endblock %}

View File

@ -71,4 +71,12 @@
</div> </div>
</form> </form>
</section> </section>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
<h2 class="text-xl font-bold tracking-tight">Security</h2>
<p class="mt-2 text-sm text-slate-600">Update your account password.</p>
<a href="/planning/password" class="mt-4 inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100">
Change Password
</a>
</section>
{% endblock %} {% endblock %}

84
templates/public_day.html Normal file
View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Shared Day {{ page.date_text }}{% endblock %}
{% block content %}
<a href="/u/{{ page.username }}" 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 @{{ page.username }}</span>
</a>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
<h1 class="text-3xl font-bold tracking-tight">@{{ page.username }} - {{ 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>
{% if page.show_weight %}
<p class="mt-2 text-sm text-slate-700">Weight: {{ page.weight_label }}</p>
{% endif %}
</section>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
<h2 class="text-xl font-bold tracking-tight">Food Entries</h2>
<div class="mt-4 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">Food</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 shared for this day.</td></tr>
{% else %}
{% for entry in page.entries %}
<tr class="border-t border-slate-200">
<td class="px-5 py-3 text-sm text-slate-800">{{ entry.name }}</td>
<td class="px-5 py-3 text-sm font-semibold text-slate-900">{{ entry.calories }} cal</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</section>
{% if !page.entries.is_empty() %}
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
<h2 class="text-xl font-bold tracking-tight text-slate-900">Calories by Entry</h2>
<div class="mt-4 h-80">
<canvas id="public-day-chart"></canvas>
</div>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
{% if !page.entries.is_empty() %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const labels = {{ page.chart_labels_js|safe }};
const values = {{ page.chart_values_js|safe }};
new Chart(
document.getElementById("public-day-chart"),
{
type: "bar",
data: {
labels,
datasets: [
{
label: "Calories",
data: values,
backgroundColor: "rgba(15, 118, 110, 0.25)",
borderColor: "#0f766e",
borderWidth: 1
}
]
},
options: {
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
}
);
</script>
{% endif %}
{% endblock %}

View File

@ -19,7 +19,13 @@
<tbody> <tbody>
{% for row in page.recent_days %} {% for row in page.recent_days %}
<tr class="border-t border-slate-200"> <tr class="border-t border-slate-200">
<td class="px-3 py-2 text-sm">{{ row.date_text }}</td> <td class="px-3 py-2 text-sm">
{% if page.show_entries %}
<a href="{{ row.day_href }}" class="font-semibold text-teal-700 hover:text-teal-900 hover:underline">{{ row.date_text }}</a>
{% else %}
{{ row.date_text }}
{% endif %}
</td>
<td class="px-3 py-2 text-sm">{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}</td> <td class="px-3 py-2 text-sm">{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}</td>
<td class="px-3 py-2 text-sm">{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td> <td class="px-3 py-2 text-sm">{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td>
</tr> </tr>
@ -30,6 +36,31 @@
</section> </section>
{% endif %} {% endif %}
{% if page.show_entries || page.show_weights %}
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
<h2 class="text-xl font-bold tracking-tight text-slate-900">Recent Trends</h2>
<p class="mt-1 text-sm text-slate-600">Past 14 days</p>
<div class="mt-4 grid gap-4 lg:grid-cols-2">
{% if page.show_entries %}
<article class="rounded-2xl border border-slate-200 bg-white p-4">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Daily Calories</h3>
<div class="mt-3 h-72">
<canvas id="public-calorie-chart"></canvas>
</div>
</article>
{% endif %}
{% if page.show_weights %}
<article class="rounded-2xl border border-slate-200 bg-white p-4">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Daily Weight (lbs)</h3>
<div class="mt-3 h-72">
<canvas id="public-weight-chart"></canvas>
</div>
</article>
{% endif %}
</div>
</section>
{% endif %}
{% if page.show_reports %} {% if page.show_reports %}
<section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{% for card in page.report_cards %} {% for card in page.report_cards %}
@ -60,3 +91,68 @@
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scripts %}
{% if page.show_entries || page.show_weights %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const labels = {{ page.chart_labels_js|safe }};
{% if page.show_entries %}
const calories = {{ page.calories_js|safe }};
new Chart(
document.getElementById("public-calorie-chart"),
{
type: "line",
data: {
labels,
datasets: [
{
label: "Daily calories",
data: calories,
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 1.5
}
]
},
options: {
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } }
}
}
);
{% endif %}
{% if page.show_weights %}
const weights = {{ page.weights_js|safe }};
new Chart(
document.getElementById("public-weight-chart"),
{
type: "line",
data: {
labels,
datasets: [
{
label: "Daily weight (lbs)",
data: weights,
borderColor: "#2563eb",
backgroundColor: "rgba(37, 99, 235, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 1.5,
spanGaps: true
}
]
},
options: {
maintainAspectRatio: false
}
}
);
{% endif %}
</script>
{% endif %}
{% endblock %}