major features update
This commit is contained in:
parent
519fb49901
commit
4e2fab67c5
48 changed files with 3925 additions and 208 deletions
241
src/web/home.rs
Normal file
241
src/web/home.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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?)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue