From 97412a8589298024012e58e7831e67c1f877d995 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sun, 8 Feb 2026 00:11:49 -0800 Subject: [PATCH] saving --- src/db.rs | 161 ++++++++++++++++++++ src/handlers.rs | 276 +++++++++++++++++++++++++++++++++- src/main.rs | 8 + src/views.rs | 59 +++++++- templates/base.html | 18 +++ templates/inbox.html | 44 ++++++ templates/public_profile.html | 68 +++++++++ templates/subscribe.html | 46 ++++++ 8 files changed, 676 insertions(+), 4 deletions(-) create mode 100644 templates/inbox.html create mode 100644 templates/subscribe.html diff --git a/src/db.rs b/src/db.rs index 2327727..65d3cb8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -42,6 +42,23 @@ pub struct PlanningConfig { pub public_planning: bool, } +#[derive(Clone)] +pub struct SubscriptionTarget { + pub user_id: i64, + pub username: String, +} + +#[derive(Clone)] +pub struct NotificationRecord { + pub id: i64, + pub target_username: String, + pub previous_day: String, + pub message: String, + pub is_loud: bool, + pub is_viewed: bool, + pub created_at: String, +} + pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS users ( @@ -89,6 +106,28 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { public_weights INTEGER NOT NULL DEFAULT 0, public_reports INTEGER NOT NULL DEFAULT 0, public_planning INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS subscriptions ( + subscriber_user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (subscriber_user_id, target_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_subscriptions_subscriber + ON subscriptions(subscriber_user_id); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + notification_date TEXT NOT NULL, + previous_day TEXT NOT NULL, + message TEXT NOT NULL, + is_loud INTEGER NOT NULL DEFAULT 0, + is_viewed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, target_user_id, notification_date) );", )?; @@ -192,6 +231,128 @@ pub fn update_user_timezone( Ok(()) } +pub fn create_subscription( + conn: &Connection, + subscriber_user_id: i64, + target_user_id: i64, +) -> Result { + let changed = conn.execute( + "INSERT INTO subscriptions (subscriber_user_id, target_user_id) + VALUES (?1, ?2) + ON CONFLICT(subscriber_user_id, target_user_id) DO NOTHING", + params![subscriber_user_id, target_user_id], + )?; + Ok(changed > 0) +} + +pub fn fetch_subscription_targets( + conn: &Connection, + subscriber_user_id: i64, +) -> Result, rusqlite::Error> { + let mut statement = conn.prepare( + "SELECT u.id, u.username + FROM subscriptions s + JOIN users u ON u.id = s.target_user_id + WHERE s.subscriber_user_id = ?1 + ORDER BY LOWER(u.username)", + )?; + let iter = statement.query_map(params![subscriber_user_id], |row| { + Ok(SubscriptionTarget { + user_id: row.get(0)?, + username: row.get(1)?, + }) + })?; + let mut out = Vec::new(); + for row in iter { + out.push(row?); + } + Ok(out) +} + +pub fn count_activity_for_day( + conn: &Connection, + user_id: i64, + day: &str, +) -> Result { + conn.query_row( + "SELECT + (SELECT COUNT(*) FROM user_food_entries WHERE user_id = ?1 AND entry_date = ?2) + + (SELECT COUNT(*) FROM user_daily_weights WHERE user_id = ?1 AND entry_date = ?2)", + params![user_id, day], + |row| row.get(0), + ) +} + +pub fn create_daily_notification( + conn: &Connection, + user_id: i64, + target_user_id: i64, + notification_date: &str, + previous_day: &str, + message: &str, + is_loud: bool, +) -> Result { + let changed = conn.execute( + "INSERT INTO notifications ( + user_id, target_user_id, notification_date, previous_day, message, is_loud + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(user_id, target_user_id, notification_date) DO NOTHING", + params![ + user_id, + target_user_id, + notification_date, + previous_day, + message, + if is_loud { 1 } else { 0 } + ], + )?; + Ok(changed > 0) +} + +pub fn fetch_notifications_for_user( + conn: &Connection, + user_id: i64, +) -> Result, rusqlite::Error> { + let mut statement = conn.prepare( + "SELECT n.id, u.username, n.previous_day, n.message, n.is_loud, n.is_viewed, n.created_at + FROM notifications n + JOIN users u ON u.id = n.target_user_id + WHERE n.user_id = ?1 + ORDER BY n.id DESC", + )?; + let iter = statement.query_map(params![user_id], |row| { + Ok(NotificationRecord { + id: row.get(0)?, + target_username: row.get(1)?, + previous_day: row.get(2)?, + message: row.get(3)?, + is_loud: row.get::<_, i64>(4)? == 1, + is_viewed: row.get::<_, i64>(5)? == 1, + created_at: row.get(6)?, + }) + })?; + let mut out = Vec::new(); + for row in iter { + out.push(row?); + } + Ok(out) +} + +pub fn mark_notification_viewed( + conn: &Connection, + user_id: i64, + notification_id: i64, +) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE notifications + SET is_viewed = 1 + WHERE id = ?1 AND user_id = ?2", + params![notification_id, user_id], + )?; + Ok(()) +} + pub fn create_or_replace_session( conn: &Connection, token: &str, diff --git a/src/handlers.rs b/src/handlers.rs index a1ecbb7..3f8a7ca 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -15,8 +15,9 @@ use crate::config::AppConfig; use crate::db; use crate::views::{ self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView, - LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView, - ReportCardView, ReportsPageView, SignupPageView, + InboxNotificationView, InboxPageView, LoginPageView, PlanningPageView, PublicDayPageView, + PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, SignupPageView, + SubscribePageView, }; type AppError = (StatusCode, String); @@ -200,6 +201,147 @@ pub async fn logout( Ok(clear_session_cookie(Redirect::to("/login").into_response())) } +pub async fn show_profile( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + Ok(with_session_cookie( + Redirect::to(&format!("/u/{}", auth.user.username)).into_response(), + &auth.token, + )) +} + +pub async fn show_subscribe( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + ensure_daily_notifications_for_user(&state, &auth.user).await?; + render_subscribe_page_with_messages(&state, &auth, "", "").await +} + +pub async fn subscribe_to_user( + State(state): State, + headers: HeaderMap, + Form(form): Form>, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + + let username = form.get("username").map(|v| v.trim()).unwrap_or(""); + if username.is_empty() { + return render_subscribe_page_with_messages(&state, &auth, "", "Username is required").await; + } + if username.eq_ignore_ascii_case(&auth.user.username) { + return render_subscribe_page_with_messages( + &state, + &auth, + "", + "You cannot subscribe to yourself", + ) + .await; + } + let target = { + let db_conn = state.db.lock().await; + db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)? + }; + let Some(target) = target else { + return render_subscribe_page_with_messages( + &state, + &auth, + "", + "User not found", + ) + .await; + }; + + let created = { + let db_conn = state.db.lock().await; + db::create_subscription(&db_conn, auth.user.id, target.id).map_err(internal_db_error)? + }; + + if created { + render_subscribe_page_with_messages( + &state, + &auth, + &format!("Subscribed to @{}", target.username), + "", + ) + .await + } else { + render_subscribe_page_with_messages( + &state, + &auth, + "", + &format!("You are already subscribed to @{}", target.username), + ) + .await + } +} + +pub async fn show_inbox( + State(state): State, + headers: HeaderMap, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + ensure_daily_notifications_for_user(&state, &auth.user).await?; + + let notifications = { + let db_conn = state.db.lock().await; + db::fetch_notifications_for_user(&db_conn, auth.user.id).map_err(internal_db_error)? + }; + let unread_count = notifications.iter().filter(|item| !item.is_viewed).count(); + let items = notifications + .into_iter() + .map(|item| InboxNotificationView { + id: item.id, + target_username: item.target_username, + previous_day: item.previous_day, + message: item.message, + is_loud: item.is_loud, + is_viewed: item.is_viewed, + created_at: item.created_at, + }) + .collect::>(); + + let page = InboxPageView { unread_count, items }; + let resp = Html(views::render_inbox_page(&page).map_err(internal_template_error)?) + .into_response(); + Ok(with_session_cookie(resp, &auth.token)) +} + +pub async fn mark_inbox_notification_viewed( + State(state): State, + headers: HeaderMap, + Path(notification_id): Path, +) -> Result { + let auth = get_auth_user(&state, &headers).await?; + let Some(auth) = auth else { + return Ok(Redirect::to("/login").into_response()); + }; + { + let db_conn = state.db.lock().await; + db::mark_notification_viewed(&db_conn, auth.user.id, notification_id) + .map_err(internal_db_error)?; + } + Ok(with_session_cookie( + Redirect::to("/inbox").into_response(), + &auth.token, + )) +} + pub async fn show_public_profile( State(state): State, Path(username): Path, @@ -218,6 +360,10 @@ pub async fn show_public_profile( }; let today = user_local_today(&user.timezone); + let focus_date = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid date".to_string(), + ))?; let start = today - Days::new(13); let (calorie_map, weight_map) = { let db_conn = state.db.lock().await; @@ -237,6 +383,64 @@ pub async fn show_public_profile( .map_err(internal_db_error)?; (c, w) }; + let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?; + let (month_totals, month_weights) = { + let db_conn = state.db.lock().await; + let totals = db::fetch_month_summaries( + &db_conn, + user.id, + &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 = db::fetch_daily_weights_for_range( + &db_conn, + user.id, + &first_day.format("%Y-%m-%d").to_string(), + &first_of_next_month.format("%Y-%m-%d").to_string(), + ) + .map_err(internal_db_error)?; + (totals, weights) + }; + + let mut calendar_cells = Vec::with_capacity(42); + let first_weekday = first_day.weekday().num_days_from_sunday() as usize; + for _ in 0..first_weekday { + calendar_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 = month_totals.get(&date_text); + let entry_count = summary.map(|s| s.entry_count).unwrap_or(0); + let weight_label = month_weights + .get(&date_text) + .map(|w| format!("{w:.1} lbs")) + .unwrap_or_else(|| "No weight (lbs)".to_string()); + let href = if planning.public_entries && date <= today { + format!("/u/{}/day/{}", user.username, date_text) + } else { + "#".to_string() + }; + calendar_cells.push(CalendarCellView::day( + href, + 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 { + calendar_cells.push(CalendarCellView::padding()); + } let mut recent_days = Vec::new(); let mut day = start; @@ -302,6 +506,8 @@ pub async fn show_public_profile( show_weights: planning.public_weights, show_reports: planning.public_reports, show_planning: planning.public_planning, + month_label: focus_date.format("%B %Y").to_string(), + calendar_cells, recent_days, report_cards, chart_labels_js: js_string_array(&chart_labels), @@ -986,6 +1192,72 @@ async fn render_signup_with_error( Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response()) } +async fn render_subscribe_page_with_messages( + state: &AppState, + auth: &AuthUser, + message: &str, + error: &str, +) -> Result { + let subscriptions = { + let db_conn = state.db.lock().await; + db::fetch_subscription_targets(&db_conn, auth.user.id).map_err(internal_db_error)? + }; + let page = SubscribePageView { + message: message.to_string(), + error: error.to_string(), + subscriptions: subscriptions + .into_iter() + .map(|item| item.username) + .collect::>(), + }; + let resp = Html(views::render_subscribe_page(&page).map_err(internal_template_error)?) + .into_response(); + Ok(with_session_cookie(resp, &auth.token)) +} + +async fn ensure_daily_notifications_for_user( + state: &AppState, + user: &db::UserRecord, +) -> Result<(), AppError> { + let today = user_local_today(&user.timezone); + let previous_day = today - Days::new(1); + let notification_date = today.format("%Y-%m-%d").to_string(); + let previous_day_text = previous_day.format("%Y-%m-%d").to_string(); + let targets = { + let db_conn = state.db.lock().await; + db::fetch_subscription_targets(&db_conn, user.id).map_err(internal_db_error)? + }; + + let db_conn = state.db.lock().await; + for target in targets { + let activity_count = db::count_activity_for_day(&db_conn, target.user_id, &previous_day_text) + .map_err(internal_db_error)?; + let is_loud = activity_count == 0; + let message = if is_loud { + format!( + "@{} had no entries yesterday ({})", + target.username, previous_day_text + ) + } else { + format!( + "@{} logged {} update(s) yesterday ({})", + target.username, activity_count, previous_day_text + ) + }; + let _ = db::create_daily_notification( + &db_conn, + user.id, + target.user_id, + ¬ification_date, + &previous_day_text, + &message, + is_loud, + ) + .map_err(internal_db_error)?; + } + Ok(()) +} + fn render_change_password_page( token: &str, error: &str, diff --git a/src/main.rs b/src/main.rs index 7a8a535..5d87c3c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,14 @@ async fn main() -> Result<(), Box> { .route("/signup", get(handlers::show_signup)) .route("/signup", post(handlers::signup)) .route("/logout", post(handlers::logout)) + .route("/profile", get(handlers::show_profile)) + .route("/inbox", get(handlers::show_inbox)) + .route( + "/inbox/{id}/view", + post(handlers::mark_inbox_notification_viewed), + ) + .route("/subscribe", get(handlers::show_subscribe)) + .route("/subscribe", post(handlers::subscribe_to_user)) .route("/u/{username}", get(handlers::show_public_profile)) .route("/u/{username}/day/{date}", get(handlers::show_public_day)) .route("/reports", get(handlers::show_reports)) diff --git a/src/views.rs b/src/views.rs index 4849d7f..99993bd 100644 --- a/src/views.rs +++ b/src/views.rs @@ -159,6 +159,27 @@ pub struct ChangePasswordPageView { pub success: String, } +pub struct SubscribePageView { + pub message: String, + pub error: String, + pub subscriptions: Vec, +} + +pub struct InboxNotificationView { + pub id: i64, + pub target_username: String, + pub previous_day: String, + pub message: String, + pub is_loud: bool, + pub is_viewed: bool, + pub created_at: String, +} + +pub struct InboxPageView { + pub unread_count: usize, + pub items: Vec, +} + pub struct LoginPageView { pub allow_signup: bool, pub error: String, @@ -184,6 +205,8 @@ pub struct PublicProfilePageView { pub show_weights: bool, pub show_reports: bool, pub show_planning: bool, + pub month_label: String, + pub calendar_cells: Vec, pub recent_days: Vec, pub report_cards: Vec, pub chart_labels_js: String, @@ -235,6 +258,18 @@ struct ChangePasswordTemplate<'a> { page: &'a ChangePasswordPageView, } +#[derive(Template)] +#[template(path = "subscribe.html")] +struct SubscribeTemplate<'a> { + page: &'a SubscribePageView, +} + +#[derive(Template)] +#[template(path = "inbox.html")] +struct InboxTemplate<'a> { + page: &'a InboxPageView, +} + #[derive(Template)] #[template(path = "login.html")] struct LoginTemplate<'a> { @@ -289,6 +324,18 @@ impl ChangePasswordTemplate<'_> { } } +impl SubscribeTemplate<'_> { + fn active_tab(&self) -> &str { + "subscribe" + } +} + +impl InboxTemplate<'_> { + fn active_tab(&self) -> &str { + "inbox" + } +} + impl LoginTemplate<'_> { fn active_tab(&self) -> &str { "" @@ -303,13 +350,13 @@ impl SignupTemplate<'_> { impl PublicProfileTemplate<'_> { fn active_tab(&self) -> &str { - "" + "profile" } } impl PublicDayTemplate<'_> { fn active_tab(&self) -> &str { - "" + "profile" } } @@ -333,6 +380,14 @@ pub fn render_change_password_page(page: &ChangePasswordPageView) -> Result Result { + SubscribeTemplate { page }.render() +} + +pub fn render_inbox_page(page: &InboxPageView) -> Result { + InboxTemplate { page }.render() +} + pub fn render_login_page(page: &LoginPageView) -> Result { LoginTemplate { page }.render() } diff --git a/templates/base.html b/templates/base.html index 5a37bbf..4aad62a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -29,6 +29,24 @@ > Planning + + Profile + + + Subscribe + + + Inbox +
+
+ {% else %} + Viewed + {% endif %} + + + {% endfor %} + + {% endif %} + +{% endblock %} diff --git a/templates/public_profile.html b/templates/public_profile.html index f2d97dd..e8d83a6 100644 --- a/templates/public_profile.html +++ b/templates/public_profile.html @@ -9,6 +9,74 @@ {% if page.show_entries || page.show_weights %} +
+
+

{{ page.month_label }}

+

Calendar view

+
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+ {% for cell in page.calendar_cells %} + {% if cell.is_padding %} + + {% else %} + {% if cell.is_future %} +
+
+ {{ cell.date_text }} + {{ cell.day_num }} +
+
+

{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}

+

{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}

+

{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}

+

Future

+
+
+ {% else %} + {% if cell.href != "#" %} + +
+ {{ cell.date_text }} + {{ cell.day_num }} +
+
+

{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}

+

{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}

+

{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}

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

{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}

+

{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}

+

{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}

+
+
+ {% endif %} + {% endif %} + {% endif %} + {% endfor %} +
+
+
+
+

Recent Days

diff --git a/templates/subscribe.html b/templates/subscribe.html new file mode 100644 index 0000000..6ce1100 --- /dev/null +++ b/templates/subscribe.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Subscribe{% endblock %} + +{% block content %} +
+

Subscribe

+

Enter a friend's username to subscribe. No user search is provided.

+ + {% if !page.error.is_empty() %} +

{{ page.error }}

+ {% endif %} + {% if !page.message.is_empty() %} +

{{ page.message }}

+ {% endif %} + +
+ + +
+ +
+

Subscribed Users

+ {% if page.subscriptions.is_empty() %} +

No subscriptions yet.

+ {% else %} +
+ {% for username in page.subscriptions %} + + @{{ username }} + + {% endfor %} +
+ {% endif %} +
+
+{% endblock %}