242 lines
8.6 KiB
Rust
242 lines
8.6 KiB
Rust
|
|
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<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,
|
||
|
|
prev_birthday: NaiveDate,
|
||
|
|
next_birthday: NaiveDate,
|
||
|
|
}
|
||
|
|
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 {
|
||
|
|
(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<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."
|
||
|
|
}
|
||
|
|
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<AppState>,
|
||
|
|
layout: Layout,
|
||
|
|
) -> Result<impl IntoResponse, AppError> {
|
||
|
|
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||
|
|
let contacts: Vec<HydratedContact> = 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<ContactFreshness> = 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<String> = 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::<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(
|
||
|
|
Some(vec!["/static/home.css", "/static/journal.css"]),
|
||
|
|
html! {
|
||
|
|
(freshness_section(&freshens)?)
|
||
|
|
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?)
|
||
|
|
(journal_section(pool, &entries).await?)
|
||
|
|
},
|
||
|
|
))
|
||
|
|
}
|
||
|
|
}
|