saving
This commit is contained in:
parent
b6ae017457
commit
97412a8589
161
src/db.rs
161
src/db.rs
|
|
@ -42,6 +42,23 @@ pub struct PlanningConfig {
|
||||||
pub public_planning: bool,
|
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> {
|
pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS users (
|
"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_weights INTEGER NOT NULL DEFAULT 0,
|
||||||
public_reports INTEGER NOT NULL DEFAULT 0,
|
public_reports INTEGER NOT NULL DEFAULT 0,
|
||||||
public_planning 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_subscription(
|
||||||
|
conn: &Connection,
|
||||||
|
subscriber_user_id: i64,
|
||||||
|
target_user_id: i64,
|
||||||
|
) -> Result<bool, rusqlite::Error> {
|
||||||
|
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<Vec<SubscriptionTarget>, 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<i64, rusqlite::Error> {
|
||||||
|
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<bool, rusqlite::Error> {
|
||||||
|
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<Vec<NotificationRecord>, 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(
|
pub fn create_or_replace_session(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
|
|
||||||
276
src/handlers.rs
276
src/handlers.rs
|
|
@ -15,8 +15,9 @@ use crate::config::AppConfig;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::views::{
|
use crate::views::{
|
||||||
self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
|
self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
|
||||||
LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView,
|
InboxNotificationView, InboxPageView, LoginPageView, PlanningPageView, PublicDayPageView,
|
||||||
ReportCardView, ReportsPageView, SignupPageView,
|
PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, SignupPageView,
|
||||||
|
SubscribePageView,
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppError = (StatusCode, String);
|
type AppError = (StatusCode, String);
|
||||||
|
|
@ -200,6 +201,147 @@ pub async fn logout(
|
||||||
Ok(clear_session_cookie(Redirect::to("/login").into_response()))
|
Ok(clear_session_cookie(Redirect::to("/login").into_response()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn show_profile(
|
||||||
|
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());
|
||||||
|
};
|
||||||
|
Ok(with_session_cookie(
|
||||||
|
Redirect::to(&format!("/u/{}", auth.user.username)).into_response(),
|
||||||
|
&auth.token,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn show_subscribe(
|
||||||
|
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());
|
||||||
|
};
|
||||||
|
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<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 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<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());
|
||||||
|
};
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(notification_id): Path<i64>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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(
|
pub async fn show_public_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
|
@ -218,6 +360,10 @@ pub async fn show_public_profile(
|
||||||
};
|
};
|
||||||
|
|
||||||
let today = user_local_today(&user.timezone);
|
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 start = today - Days::new(13);
|
||||||
let (calorie_map, weight_map) = {
|
let (calorie_map, weight_map) = {
|
||||||
let db_conn = state.db.lock().await;
|
let db_conn = state.db.lock().await;
|
||||||
|
|
@ -237,6 +383,64 @@ pub async fn show_public_profile(
|
||||||
.map_err(internal_db_error)?;
|
.map_err(internal_db_error)?;
|
||||||
(c, w)
|
(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 recent_days = Vec::new();
|
||||||
let mut day = start;
|
let mut day = start;
|
||||||
|
|
@ -302,6 +506,8 @@ pub async fn show_public_profile(
|
||||||
show_weights: planning.public_weights,
|
show_weights: planning.public_weights,
|
||||||
show_reports: planning.public_reports,
|
show_reports: planning.public_reports,
|
||||||
show_planning: planning.public_planning,
|
show_planning: planning.public_planning,
|
||||||
|
month_label: focus_date.format("%B %Y").to_string(),
|
||||||
|
calendar_cells,
|
||||||
recent_days,
|
recent_days,
|
||||||
report_cards,
|
report_cards,
|
||||||
chart_labels_js: js_string_array(&chart_labels),
|
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())
|
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<Response, AppError> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
};
|
||||||
|
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(
|
fn render_change_password_page(
|
||||||
token: &str,
|
token: &str,
|
||||||
error: &str,
|
error: &str,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
.route("/signup", get(handlers::show_signup))
|
.route("/signup", get(handlers::show_signup))
|
||||||
.route("/signup", post(handlers::signup))
|
.route("/signup", post(handlers::signup))
|
||||||
.route("/logout", post(handlers::logout))
|
.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}", get(handlers::show_public_profile))
|
||||||
.route("/u/{username}/day/{date}", get(handlers::show_public_day))
|
.route("/u/{username}/day/{date}", get(handlers::show_public_day))
|
||||||
.route("/reports", get(handlers::show_reports))
|
.route("/reports", get(handlers::show_reports))
|
||||||
|
|
|
||||||
59
src/views.rs
59
src/views.rs
|
|
@ -159,6 +159,27 @@ pub struct ChangePasswordPageView {
|
||||||
pub success: String,
|
pub success: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SubscribePageView {
|
||||||
|
pub message: String,
|
||||||
|
pub error: String,
|
||||||
|
pub subscriptions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InboxNotificationView>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct LoginPageView {
|
pub struct LoginPageView {
|
||||||
pub allow_signup: bool,
|
pub allow_signup: bool,
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
|
@ -184,6 +205,8 @@ pub struct PublicProfilePageView {
|
||||||
pub show_weights: bool,
|
pub show_weights: bool,
|
||||||
pub show_reports: bool,
|
pub show_reports: bool,
|
||||||
pub show_planning: bool,
|
pub show_planning: bool,
|
||||||
|
pub month_label: String,
|
||||||
|
pub calendar_cells: Vec<CalendarCellView>,
|
||||||
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 chart_labels_js: String,
|
||||||
|
|
@ -235,6 +258,18 @@ struct ChangePasswordTemplate<'a> {
|
||||||
page: &'a ChangePasswordPageView,
|
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)]
|
#[derive(Template)]
|
||||||
#[template(path = "login.html")]
|
#[template(path = "login.html")]
|
||||||
struct LoginTemplate<'a> {
|
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<'_> {
|
impl LoginTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
""
|
""
|
||||||
|
|
@ -303,13 +350,13 @@ impl SignupTemplate<'_> {
|
||||||
|
|
||||||
impl PublicProfileTemplate<'_> {
|
impl PublicProfileTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
""
|
"profile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PublicDayTemplate<'_> {
|
impl PublicDayTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
""
|
"profile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +380,14 @@ pub fn render_change_password_page(page: &ChangePasswordPageView) -> Result<Stri
|
||||||
ChangePasswordTemplate { page }.render()
|
ChangePasswordTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_subscribe_page(page: &SubscribePageView) -> Result<String, askama::Error> {
|
||||||
|
SubscribeTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_inbox_page(page: &InboxPageView) -> Result<String, askama::Error> {
|
||||||
|
InboxTemplate { 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,24 @@
|
||||||
>
|
>
|
||||||
Planning
|
Planning
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "profile" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/subscribe"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "subscribe" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/inbox"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "inbox" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
|
||||||
|
>
|
||||||
|
Inbox
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Inbox{% 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">Inbox</h1>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Daily subscription notifications.</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Unread: {{ page.unread_count }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
{% if page.items.is_empty() %}
|
||||||
|
<p class="text-sm text-slate-600">No notifications yet. Subscribe to someone first.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid gap-3">
|
||||||
|
{% for item in page.items %}
|
||||||
|
<article class="rounded-2xl border p-4 {% if item.is_viewed %}border-slate-200 bg-slate-50{% else %}border-teal-200 bg-teal-50{% endif %}">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-semibold {% if item.is_viewed %}text-slate-700{% else %}text-slate-900{% endif %}">
|
||||||
|
{% if item.is_loud %}Loud alert:{% else %}Update:{% endif %} @{{ item.target_username }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ item.created_at }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm {% if item.is_loud %}font-semibold text-rose-700{% else %}text-slate-700{% endif %}">
|
||||||
|
{{ item.message }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">For day: {{ item.previous_day }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<a href="/u/{{ item.target_username }}" class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 hover:bg-slate-100">Open profile</a>
|
||||||
|
{% if !item.is_viewed %}
|
||||||
|
<form method="post" action="/inbox/{{ item.id }}/view">
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700">Mark viewed</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-500">Viewed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -9,6 +9,74 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if page.show_entries || page.show_weights %}
|
{% 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">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">{{ page.month_label }}</h2>
|
||||||
|
<p class="text-xs text-slate-500">Calendar view</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 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.calendar_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">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
|
||||||
|
<p class="text-xs text-slate-500">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
|
||||||
|
<p class="text-xs text-slate-500">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
|
||||||
|
<p class="mt-1 text-[11px] uppercase tracking-wide text-slate-400">Future</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if cell.href != "#" %}
|
||||||
|
<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 }}">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
|
||||||
|
<p class="{{ cell.entry_class }}">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
|
||||||
|
<p class="{{ cell.entry_class }}">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="h-32 rounded-2xl border border-slate-200 bg-white p-3">
|
||||||
|
<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-900 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-900">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
|
||||||
|
<p class="text-xs text-slate-600">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
|
||||||
|
<p class="text-xs text-slate-600">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
<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">Recent Days</h2>
|
<h2 class="text-xl font-bold tracking-tight">Recent Days</h2>
|
||||||
<div class="mt-3 overflow-x-auto">
|
<div class="mt-3 overflow-x-auto">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Subscribe{% 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">Subscribe</h1>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Enter a friend's username to subscribe. No user search is provided.</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.message.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.message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/subscribe" class="mt-5 flex max-w-lg flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="Friend username"
|
||||||
|
required
|
||||||
|
class="flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Subscribe</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-lg font-bold tracking-tight text-slate-900">Subscribed Users</h2>
|
||||||
|
{% if page.subscriptions.is_empty() %}
|
||||||
|
<p class="mt-2 text-sm text-slate-600">No subscriptions yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{% for username in page.subscriptions %}
|
||||||
|
<a
|
||||||
|
href="/u/{{ username }}"
|
||||||
|
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
@{{ username }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue