use axum::extract::State; use axum::response::IntoResponse; use chrono::{Local, NaiveDate, TimeDelta}; use maud::{Markup, html}; use sqlx::sqlite::SqlitePool; use super::Layout; use crate::db::DbId; use crate::models::user::AuthSession; use crate::models::{Birthday, HydratedContact, JournalEntry}; use crate::{AppError, AppState}; #[derive(Debug, Clone)] struct ContactFreshness { contact_id: DbId, display: String, fresh_date: NaiveDate, fresh_str: String, elapsed_str: String, } fn freshness_section(freshens: &Vec) -> Result { Ok(html! { div id="freshness" { h2 { "Stale Contacts" } div class="grid" { span .th { "name" } span .th { "freshened" } span .th { "elapsed" } @for contact in &freshens[0..std::cmp::min(5, freshens.len())] { span { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } } span { (contact.fresh_str) } span { (contact.elapsed_str) } } } } }) } #[derive(Debug, Clone)] struct KnownBirthdayContact { contact_id: i64, display: String, prev_birthday: NaiveDate, next_birthday: NaiveDate, } fn birthdays_section( prev_birthdays: &Vec, upcoming_birthdays: &Vec, ) -> Result { Ok(html! { div id="birthdays" { h2 { "Birthdays" } #birthday-sections { .datelist { h3 { "upcoming" } @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } span { (contact.next_birthday.format("%m-%d")) } } } .datelist { h3 { "recent" } @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } span { (contact.prev_birthday.format("%m-%d")) } } } } } }) } pub async fn journal_section( pool: &SqlitePool, entries: &Vec, ) -> Result { Ok(html! { div id="journal" x-data="{ edit: false }" { header { h2 { "Journal" } input id="journal-edit-mode" type="checkbox" x-model="edit" { label for="journal-edit-mode" { "Edit" } } } .disclaimer { "Leave off year or year and month in the date field to default to what they are now, or leave everything blank to default to 'today'. Entries will be added to the top of the list regardless of date; refresh the page to re-sort." } form hx-post="/journal_entry" hx-target="next .entries" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" { input name="date" placeholder=(Local::now().date_naive().to_string()); textarea name="value" placeholder="New entry..." autofocus {} input type="submit" value="Add Entry"; } .entries { @for entry in entries { (entry.to_html(pool).await?) } } } }) } pub mod get { use super::*; pub async fn home( auth_session: AuthSession, State(state): State, layout: Layout, ) -> Result { let pool = &state.db(&auth_session.user.unwrap()).pool; let contacts: Vec = sqlx::query_as( "select id, birthday, manually_freshened_at, ( select string_agg(name,'\x1c' order by sort) from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes join contact_mentions cms on cms.entry_id = jes.id where cms.contact_id = c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c", ) .fetch_all(pool) .await?; let mut freshens: Vec = contacts .clone() .into_iter() .map(|contact| { let zero = NaiveDate::from_epoch_days(0).unwrap(); let fresh_date = std::cmp::max( contact .manually_freshened_at .map(|x| x.date_naive()) .unwrap_or(zero), contact.last_mention_date.unwrap_or(zero), ); if fresh_date == zero { ContactFreshness { contact_id: contact.id, display: contact.display_name(), fresh_date, fresh_str: "never".to_string(), elapsed_str: "".to_string(), } } else { let mut duration = Local::now().date_naive().signed_duration_since(fresh_date); let mut elapsed: Vec = Vec::new(); let y = duration.num_weeks() / 52; let count = |n: i64, noun: &str| { format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" }) }; if y > 0 { elapsed.push(count(y, "year")); duration -= TimeDelta::weeks(y * 52); } let w = duration.num_weeks(); if w > 0 { elapsed.push(count(w, "week")); duration -= TimeDelta::weeks(w); } let d = duration.num_days(); if d > 0 { elapsed.push(count(d, "day")); } let elapsed_str = if elapsed.is_empty() { "today".to_string() } else { elapsed.join(", ") }; ContactFreshness { contact_id: contact.id, display: contact.display_name(), fresh_date, fresh_str: fresh_date.to_string(), elapsed_str, } } }) .collect(); freshens.sort_by_key(|x| x.fresh_date); let birthdays = contacts .into_iter() .map(|contact| { if let Some(Birthday::Date(date)) = &contact.birthday { Some(KnownBirthdayContact { contact_id: contact.id, display: contact.display_name(), prev_birthday: date.prev_month_day_occurrence().unwrap(), next_birthday: date.next_month_day_occurrence().unwrap(), }) } else { None } }) .filter(|x| x.is_some()) .map(|x| x.unwrap()) .collect::>(); let mut prev_birthdays = birthdays.clone(); prev_birthdays.sort_by_key(|x| x.prev_birthday); prev_birthdays.reverse(); let mut upcoming_birthdays = birthdays; upcoming_birthdays.sort_by_key(|x| x.next_birthday); // I'm writing this as an n+1 query pattern deliberately // since I *think* the overhead of string_agg+split might // be worse than that of the n+1 since we're in sqlite. let entries: Vec = sqlx::query_as("select id,value,date from journal_entries order by date desc") .fetch_all(pool) .await?; Ok(layout.render( Some(vec!["/static/home.css", "/static/journal.css"]), html! { (freshness_section(&freshens)?) (birthdays_section(&prev_birthdays, &upcoming_birthdays)?) (journal_section(pool, &entries).await?) }, )) } }