mascarpone/src/web/home.rs

239 lines
8.4 KiB
Rust
Raw Normal View History

2025-11-27 13:45:21 -06:00
use axum::extract::State;
use axum::response::IntoResponse;
2025-12-01 15:23:56 -06:00
use cache_bust::asset;
2026-04-03 11:54:36 -05:00
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
2025-11-27 13:45:21 -06:00
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,
2026-04-03 11:54:36 -05:00
fresh_date: civil::Date,
2025-11-27 13:45:21 -06:00
fresh_str: String,
elapsed_str: String,
}
fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppError> {
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,
2026-04-03 11:54:36 -05:00
prev_birthday: civil::Date,
next_birthday: civil::Date,
2025-11-27 13:45:21 -06:00
}
fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>,
upcoming_birthdays: &Vec<KnownBirthdayContact>,
) -> Result<Markup, AppError> {
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 {
2026-04-03 11:54:36 -05:00
(contact.next_birthday.strftime("%m-%d"))
2025-11-27 13:45:21 -06:00
}
}
}
.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 {
2026-04-03 11:54:36 -05:00
(contact.prev_birthday.strftime("%m-%d"))
2025-11-27 13:45:21 -06:00
}
}
}
}
}
})
}
2026-01-26 17:56:00 -06:00
#[tracing::instrument(level = "info")]
2025-11-27 13:45:21 -06:00
pub async fn journal_section(
pool: &SqlitePool,
entries: &Vec<JournalEntry>,
) -> Result<Markup, AppError> {
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."
}
2026-02-04 13:07:26 -06:00
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
2026-04-03 11:54:36 -05:00
input name="date" placeholder=(Zoned::now().date().to_string());
2025-11-27 13:45:21 -06:00
textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry";
}
2026-02-04 13:07:26 -06:00
#journal-error {}
2025-11-27 13:45:21 -06:00
.entries {
@for entry in entries {
2026-01-26 17:56:00 -06:00
(entry.to_html(pool).await?)
2025-11-27 13:45:21 -06:00
}
}
}
})
}
pub mod get {
use super::*;
pub async fn home(
auth_session: AuthSession,
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
2026-01-26 22:14:58 -06:00
let contacts = HydratedContact::all(&pool).await?;
2025-11-27 13:45:21 -06:00
let mut freshens: Vec<ContactFreshness> = contacts
.clone()
.into_iter()
2026-04-03 13:47:23 -05:00
.filter_map(|contact| {
if !contact.can_stale || !contact.active {
return None;
}
2026-04-03 11:54:36 -05:00
let zero = jiff::civil::Date::ZERO;
2025-11-27 13:45:21 -06:00
let fresh_date = std::cmp::max(
contact
.manually_freshened_at
2026-04-03 11:54:36 -05:00
.map(|ts| ts.to_zoned(TimeZone::UTC).date())
2025-11-27 13:45:21 -06:00
.unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero),
);
if fresh_date == zero {
2026-04-03 13:47:23 -05:00
Some(ContactFreshness {
2025-11-27 13:45:21 -06:00
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: "never".to_string(),
elapsed_str: "".to_string(),
2026-04-03 13:47:23 -05:00
})
2025-11-27 13:45:21 -06:00
} else {
2026-04-03 11:54:36 -05:00
let utc = TimeZone::UTC;
let todate = Timestamp::now().to_zoned(utc.clone()).date();
2026-04-03 13:47:23 -05:00
let elapsed = todate
2026-04-03 11:54:36 -05:00
.since(&fresh_date.to_zoned(utc).unwrap())
.unwrap()
.round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
2025-11-27 13:45:21 -06:00
2026-04-03 13:47:23 -05:00
if let Some(cmp) = elapsed.compare((contact.periodicity, todate)).ok() {
if cmp == std::cmp::Ordering::Less {
return None;
}
}
let elapsed_str = if elapsed.is_zero() {
2025-11-27 13:45:21 -06:00
"today".to_string()
} else {
2026-04-03 13:47:23 -05:00
format!("{:#}", elapsed)
2025-11-27 13:45:21 -06:00
};
2026-04-03 13:47:23 -05:00
Some(ContactFreshness {
2025-11-27 13:45:21 -06:00
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: fresh_date.to_string(),
elapsed_str,
2026-04-03 13:47:23 -05:00
})
2025-11-27 13:45:21 -06:00
}
})
.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(),
2026-04-03 11:54:36 -05:00
prev_birthday: date.prev_month_day_occurrence()?,
next_birthday: date.next_month_day_occurrence()?,
2025-11-27 13:45:21 -06:00
})
} else {
None
}
})
.filter(|x| x.is_some())
.map(|x| x.unwrap())
.collect::<Vec<KnownBirthdayContact>>();
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<JournalEntry> =
sqlx::query_as("select id,value,date from journal_entries order by date desc")
.fetch_all(pool)
.await?;
Ok(layout.render(
"Home",
2025-12-01 15:23:56 -06:00
Some(vec![asset!("home.css"), asset!("journal.css")]),
2025-11-27 13:45:21 -06:00
html! {
(freshness_section(&freshens)?)
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?)
(journal_section(pool, &entries).await?)
},
))
}
}