From 3ffdf8f0d7ad91a32185e95d68693ba5e0eb40dd Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 3 Apr 2026 11:54:36 -0500 Subject: [PATCH 01/20] refactor: switch from chrono to jiff --- Cargo.lock | 57 +++++++++++++++++++ Cargo.toml | 1 + e2e/pages/contact.spec.ts | 23 +++++--- src/models/birthday.rs | 24 +++++--- src/models/contact.rs | 11 ++-- src/models/journal.rs | 6 +- src/models/year_optional_date.rs | 57 +++++++++---------- src/switchboard.rs | 10 +--- src/web/contact/mod.rs | 94 +++++++++++++++----------------- src/web/home.rs | 57 +++++++++---------- src/web/ics.rs | 6 +- src/web/journal.rs | 20 +++---- 12 files changed, 205 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b300c23..3c03174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,6 +1280,47 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -1427,6 +1468,7 @@ dependencies = [ "http", "icalendar", "itertools 0.14.0", + "jiff", "listenfd", "markdown", "maud", @@ -1847,6 +1889,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index d246956..5b03dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ clap = { version = "4.5.53", features = ["derive"] } http = "1.3.1" icalendar = "0.17.5" itertools = "0.14.0" +jiff = { version = "0.2.23", features = ["serde"] } listenfd = "1.0.2" markdown = "1.0.0" maud = { version = "0.27.0", features = ["axum"] } diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index dad20e5..46af08e 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -30,14 +30,14 @@ test.skip("groups wrap nicely", async ({ page }) => { const groupBox = page.getByPlaceholder(/group name/i); await groupBox.fill('this is a long group name'); - await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('button', { name: /save/i }).click(); await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); - // TODO: this drives to the right location but i can't figure out how to assert - // that the text is all on one line. Manual inspection looks good at time of writing. + // TODO: this drives to the right location but i can't figure out how to assert + // that the text is all on one line. Manual inspection looks good at time of writing. }); -test('allow marking as hidden', async ({ page }) => { +test('allow marking as inactive', async ({ page }) => { }); @@ -45,15 +45,20 @@ test('allow exempting from stale', async ({ page }) => { }); +test('stale list considers periodicity', async ({ page }) => { + +}); + +test('page title has contact primary name', async ({ page }) => { + await expect(page.title()).toContain("Test Testerson"); +}); + + +/* test('bullet points in free text display well', async ({ page }) => { }); -twst('page title has contact primary name', async ({ page }) => { - await expect(page.title()).toContain("Test Testerson"); -}); - -/* home: contact list scrolls in screen, not off screen home: clicking off contact list closes it home: contact list is sorted ignoring case diff --git a/src/models/birthday.rs b/src/models/birthday.rs index 603191a..f7c0256 100644 --- a/src/models/birthday.rs +++ b/src/models/birthday.rs @@ -1,4 +1,4 @@ -use chrono::Local; +use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone}; use sqlx::sqlite::SqliteRow; use sqlx::{FromRow, Row}; use std::fmt::Display; @@ -29,25 +29,31 @@ impl Display for Birthday { } impl Birthday { - pub fn next_occurrence(&self) -> Option { + pub fn next_occurrence(&self) -> Option { match &self { Birthday::Text(_) => None, - Birthday::Date(date) => Some(date.next_month_day_occurrence()?), + Birthday::Date(date) => date.next_month_day_occurrence(), } } - pub fn until_next(&self) -> Option { + pub fn until_next(&self) -> Option { self.next_occurrence() - .map(|when| when.signed_duration_since(Local::now().date_naive())) + .map(|when| when.since(Zoned::now().date()).ok())? } /// None if this is a text birthday or doesn't have a year - pub fn age(&self) -> Option { + pub fn age(&self) -> Option { match &self { Birthday::Text(_) => None, - Birthday::Date(date) => date - .to_date_naive() - .map(|birthdate| Local::now().date_naive().years_since(birthdate))?, + Birthday::Date(date) => { + let now = Timestamp::now().to_zoned(TimeZone::UTC); + date.to_civil_date().map(|birthdate| { + now.since(&birthdate.to_zoned(TimeZone::UTC).unwrap()) + .unwrap() + .total((Unit::Year, &now)) + .unwrap() as i32 + }) + } } } diff --git a/src/models/contact.rs b/src/models/contact.rs index bad0ff4..8ed0be6 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, NaiveDate, Utc}; +use jiff::{Timestamp, civil}; use sqlx::sqlite::SqlitePool; use std::str::FromStr; @@ -18,7 +18,7 @@ struct RawContact { pub struct Contact { pub id: DbId, pub birthday: Option, - pub manually_freshened_at: Option>, + pub manually_freshened_at: Option, pub lives_with: String, } @@ -31,8 +31,7 @@ impl Into for RawContact { .and_then(|s| Birthday::from_str(s.as_ref()).ok()), manually_freshened_at: self .manually_freshened_at - .and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok()) - .map(|d| d.to_utc()), + .and_then(|str| str.parse::().ok()), lives_with: self.lives_with, } } @@ -50,7 +49,7 @@ struct RawHydratedContact { #[derive(Clone, Debug)] pub struct HydratedContact { pub contact: Contact, - pub last_mention_date: Option, + pub last_mention_date: Option, pub names: Vec, } @@ -71,7 +70,7 @@ impl Into for RawHydratedContact { .collect::>(), last_mention_date: self .last_mention_date - .and_then(|str| NaiveDate::from_str(str.as_ref()).ok()), + .and_then(|str| str.parse::().ok()), } } } diff --git a/src/models/journal.rs b/src/models/journal.rs index 4a90d86..fdc9940 100644 --- a/src/models/journal.rs +++ b/src/models/journal.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use jiff::civil::Date; use maud::{Markup, html}; use serde_json::json; use sqlx::sqlite::{SqlitePool, SqliteRow}; @@ -12,7 +12,7 @@ use crate::switchboard::{MentionHost, MentionHostType}; pub struct JournalEntry { pub id: DbId, pub value: String, - pub date: NaiveDate, + pub date: Date, } impl<'a> Into> for &'a JournalEntry { @@ -69,7 +69,7 @@ impl FromRow<'_, SqliteRow> for JournalEntry { let id: DbId = row.try_get("id")?; let value: String = row.try_get("value")?; let date_str: &str = row.try_get("date")?; - let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(); + let date: Date = date_str.parse().unwrap(); Ok(Self { id, value, date }) } } diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index 196360d..fa1fc1f 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -1,4 +1,4 @@ -use chrono::{Datelike, Local, NaiveDate}; +use jiff::{Timestamp, civil::Date, tz::TimeZone}; use regex::Regex; use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull}; use std::fmt::Display; @@ -6,38 +6,39 @@ use std::str::FromStr; #[derive(Debug, Clone)] pub struct YearOptionalDate { - pub year: Option, - pub month: u32, - pub day: u32, + pub year: Option, + pub month: i8, + pub day: i8, } impl YearOptionalDate { - pub fn prev_month_day_occurrence(&self) -> Option { - let now = Local::now(); + pub fn prev_month_day_occurrence(&self) -> Option { + let now = Timestamp::now().to_zoned(TimeZone::UTC); let year = now.year(); - let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day); - if let Some(real_date) = date { - if real_date >= now.date_naive() { - date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day); + Date::new(year, self.month, self.day).ok().and_then(|date| { + if date >= now.date() { + Date::new(year - 1, self.month, self.day).ok() + } else { + Some(date) } - } - date - } - pub fn next_month_day_occurrence(&self) -> Option { - let now = Local::now(); - let year = now.year(); - let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day); - if let Some(real_date) = date { - if real_date < now.date_naive() { - date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day); - } - } - date + }) } - pub fn to_date_naive(&self) -> Option { + pub fn next_month_day_occurrence(&self) -> Option { + let now = Timestamp::now().to_zoned(TimeZone::UTC); + let year = now.year(); + Date::new(year, self.month, self.day).ok().and_then(|date| { + if date < now.date() { + Date::new(year + 1, self.month, self.day).ok() + } else { + Some(date) + } + }) + } + + pub fn to_civil_date(&self) -> Option { if let Some(year) = self.year { - NaiveDate::from_ymd_opt(year, self.month, self.day) + Date::new(year, self.month, self.day).ok() } else { None } @@ -68,12 +69,12 @@ impl FromStr for YearOptionalDate { let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap(); if let Some(caps) = date_re.captures(str) { let year_str = &caps[1]; - let month = u32::from_str(&caps[2]).unwrap(); - let day = u32::from_str(&caps[3]).unwrap(); + let month = i8::from_str(&caps[2]).unwrap(); + let day = i8::from_str(&caps[3]).unwrap(); let year = if year_str == "--" { None } else { - Some(i32::from_str(year_str).unwrap()) + Some(i16::from_str(year_str).unwrap()) }; return Ok(Self { year, month, day }); diff --git a/src/switchboard.rs b/src/switchboard.rs index 0b7d3f8..6635700 100644 --- a/src/switchboard.rs +++ b/src/switchboard.rs @@ -74,7 +74,7 @@ impl MentionHost<'_> { } impl Switchboard { - pub async fn gen_trie(pool: &SqlitePool) -> Result, AppError> { + pub async fn gen_trie(pool: &SqlitePool) -> Result, AppError> { let mut trie = radix_trie::Trie::new(); let mentionables = sqlx::query_as!( @@ -109,14 +109,6 @@ impl Switchboard { } } - pub fn remove(self: &mut Self, text: &String) { - self.trie.remove(text); - } - - pub fn add_mentionable(self: &mut Self, text: String, uri: String) { - self.trie.insert(text, uri); - } - pub fn extract_mentions<'a>(&self, host: impl Into>) -> HashSet { let host: MentionHost = host.into(); let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 35f66e0..6386bdd 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -7,19 +7,19 @@ use axum::{ }; use axum_extra::extract::Form; use cache_bust::asset; -use chrono::DateTime; +use jiff::{Timestamp, Unit, tz::TimeZone}; use maud::{Markup, html}; use serde::Deserialize; use serde_json::json; use slug::slugify; -use sqlx::{QueryBuilder, Sqlite}; +use sqlx::QueryBuilder; use super::Layout; use super::home::journal_section; use crate::db::DbId; use crate::models::user::AuthSession; use crate::models::{HydratedContact, JournalEntry}; -use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard}; +use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions}; use crate::{AppError, AppState}; pub mod fields; @@ -40,22 +40,22 @@ pub fn router() -> Router { .route("/contact/{contact_id}/edit", get(self::get::contact_edit)) } -fn human_delta(delta: &chrono::TimeDelta) -> String { - if delta.num_days() == 0 { - return "today".to_string(); - } +fn human_delta(span: &jiff::Span) -> String { + let todate = Timestamp::now().to_zoned(TimeZone::UTC).date(); + let span = span + .round( + jiff::SpanRound::new() + .largest(Unit::Year) + .smallest(Unit::Day) + .relative(todate), + ) + .unwrap(); - let mut result = "in ".to_string(); - let mut rem = delta.clone(); - if rem.num_days().abs() >= 7 { - let weeks = rem.num_days() / 7; - rem -= chrono::TimeDelta::days(weeks * 7); - result.push_str(&format!("{}w ", weeks)); + if span.is_zero() { + "today".to_string() + } else { + format!("in {:#}", span) } - if rem.num_days().abs() > 0 { - result.push_str(&format!("{}d ", rem.num_days())); - } - result.trim().to_string() } mod get { @@ -88,7 +88,9 @@ mod get { .await?; let freshened = std::cmp::max( - contact.manually_freshened_at.map(|when| when.date_naive()), + contact + .manually_freshened_at + .map(|when| when.to_zoned(jiff::tz::TimeZone::UTC).date()), entries.get(0).map(|entry| entry.date), ); @@ -213,7 +215,7 @@ mod get { let mfresh_str = contact .manually_freshened_at .clone() - .map_or("".to_string(), |m| m.to_rfc3339()); + .map_or("".to_string(), |m| m.to_string()); let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) @@ -224,7 +226,7 @@ mod get { Ok(layout.render( format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))), - Some(vec![asset!("contact.css")]), + Some(vec![asset!("contact.css")]), html! { form hx-ext="response-targets" { div { @@ -351,14 +353,15 @@ mod put { Some(payload.birthday) }; - let manually_freshened_at = if payload.manually_freshened_at.is_empty() { + let manually_freshened_at: Option = if payload.manually_freshened_at.is_empty() { None } else { Some( - DateTime::parse_from_str(&payload.manually_freshened_at, "%+") + payload + .manually_freshened_at + .parse::() .map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))? - .to_utc() - .to_rfc3339(), + .to_string(), ) }; @@ -386,9 +389,6 @@ mod put { .execute(pool) .await?; - if old_contact.text_body != text_body { - } - // these blocks are not in functions because payload gets progressively // partially moved as we handle each field and i don't want to deal with it @@ -488,25 +488,21 @@ mod put { let old_names: Vec = old_names.into_iter().map(|(s,)| s).collect(); if old_names != new_names { - sqlx::query!( - "delete from names where contact_id = $1", - contact_id - ) - .execute(pool) - .await?; - - if !new_names.is_empty() { - QueryBuilder::new( - "insert into names (contact_id, sort, name) " - ).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { - b - .push_bind(contact_id) - .push_bind(DbId::try_from(sort).unwrap()) - .push_bind(name); - }).build() - .persistent(false) + sqlx::query!("delete from names where contact_id = $1", contact_id) .execute(pool) .await?; + + if !new_names.is_empty() { + QueryBuilder::new("insert into names (contact_id, sort, name) ") + .push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { + b.push_bind(contact_id) + .push_bind(DbId::try_from(sort).unwrap()) + .push_bind(name); + }) + .build() + .persistent(false) + .execute(pool) + .await?; } } @@ -524,12 +520,9 @@ mod put { let old_groups: Vec = old_groups.into_iter().map(|(s,)| s).collect(); if new_groups != old_groups { - sqlx::query!( - "delete from groups where contact_id = $1", - contact_id - ) - .execute(pool) - .await?; + sqlx::query!("delete from groups where contact_id = $1", contact_id) + .execute(pool) + .await?; if new_groups.len() > 0 { QueryBuilder::new("insert into groups (contact_id, name, slug) ") @@ -566,7 +559,6 @@ mod put { .await?; } - if regen_text_body { sqlx::query!( "delete from mentions where entity_id = $1 and entity_type = $2", diff --git a/src/web/home.rs b/src/web/home.rs index 36c2a31..8af2d07 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -1,7 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use cache_bust::asset; -use chrono::{Local, NaiveDate, TimeDelta}; +use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone}; use maud::{Markup, html}; use sqlx::sqlite::SqlitePool; @@ -15,7 +15,7 @@ use crate::{AppError, AppState}; struct ContactFreshness { contact_id: DbId, display: String, - fresh_date: NaiveDate, + fresh_date: civil::Date, fresh_str: String, elapsed_str: String, } @@ -46,8 +46,8 @@ fn freshness_section(freshens: &Vec) -> Result, @@ -64,7 +64,7 @@ fn birthdays_section( (contact.display) } span { - (contact.next_birthday.format("%m-%d")) + (contact.next_birthday.strftime("%m-%d")) } } } @@ -75,7 +75,7 @@ fn birthdays_section( (contact.display) } span { - (contact.prev_birthday.format("%m-%d")) + (contact.prev_birthday.strftime("%m-%d")) } } } @@ -103,7 +103,7 @@ pub async fn journal_section( 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-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" { - input name="date" placeholder=(Local::now().date_naive().to_string()); + input name="date" placeholder=(Zoned::now().date().to_string()); textarea name="value" placeholder="New entry..." autofocus {} input type="submit" value="Add Entry"; } @@ -135,11 +135,11 @@ pub mod get { .clone() .into_iter() .map(|contact| { - let zero = NaiveDate::from_epoch_days(0).unwrap(); + let zero = jiff::civil::Date::ZERO; let fresh_date = std::cmp::max( contact .manually_freshened_at - .map(|x| x.date_naive()) + .map(|ts| ts.to_zoned(TimeZone::UTC).date()) .unwrap_or(zero), contact.last_mention_date.unwrap_or(zero), ); @@ -152,30 +152,23 @@ pub mod get { 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 utc = TimeZone::UTC; + let todate = Timestamp::now().to_zoned(utc.clone()).date(); + let duration = todate + .since(&fresh_date.to_zoned(utc).unwrap()) + .unwrap() + .round( + jiff::SpanRound::new() + .largest(Unit::Year) + .smallest(Unit::Day) + .relative(todate), + ) + .unwrap(); - let elapsed_str = if elapsed.is_empty() { + let elapsed_str = if duration.is_zero() { "today".to_string() } else { - elapsed.join(", ") + format!("{:#}", duration) }; ContactFreshness { @@ -197,8 +190,8 @@ pub mod get { 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(), + prev_birthday: date.prev_month_day_occurrence()?, + next_birthday: date.next_month_day_occurrence()?, }) } else { None diff --git a/src/web/ics.rs b/src/web/ics.rs index b7780ec..162b3f9 100644 --- a/src/web/ics.rs +++ b/src/web/ics.rs @@ -61,9 +61,9 @@ mod get { for contact in &contacts { if let Some(Birthday::Date(yo_date)) = &contact.birthday { if let Some(date) = NaiveDate::from_ymd_opt( - yo_date.year.unwrap_or(1900), - yo_date.month, - yo_date.day, + yo_date.year.unwrap_or(1900).into(), + yo_date.month.try_into().unwrap(), + yo_date.day.try_into().unwrap(), ) { calendar.push( Event::new() diff --git a/src/web/journal.rs b/src/web/journal.rs index b3bb30e..2ff6ddc 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -4,14 +4,14 @@ use axum::{ response::IntoResponse, routing::{delete, patch, post}, }; -use chrono::{Datelike, Local, NaiveDate}; +use jiff::{Zoned, civil::Date}; use maud::Markup; use regex::Regex; use serde::Deserialize; use crate::models::JournalEntry; use crate::models::user::AuthSession; -use crate::switchboard::{MentionHost, MentionHostType, insert_mentions}; +use crate::switchboard::{MentionHostType, insert_mentions}; use crate::{AppError, AppState, DbId}; pub fn router() -> Router { @@ -39,10 +39,10 @@ mod post { let pool = &state.db(&user).pool; let sw_lock = state.switchboard(&user); - let now = Local::now().date_naive(); + let now = Zoned::now(); let date = if payload.date.is_empty() { - now + now.date() } else { let date_re = Regex::new(r"^(?:(?[0-9]{4})-)?(?:(?[0-9]{2})-)?(?[0-9]{2})$") @@ -54,17 +54,16 @@ mod post { // unwrapping these parses is safe since it's matching [0-9]{2,4} let year = caps .name("year") - .map(|m| m.as_str().parse::().unwrap()) + .map(|m| m.as_str().parse::().unwrap()) .unwrap_or(now.year()); let month = caps .name("month") - .map(|m| m.as_str().parse::().unwrap()) + .map(|m| m.as_str().parse::().unwrap()) .unwrap_or(now.month()); - let day = caps.name("day").unwrap().as_str().parse::().unwrap(); + let day = caps.name("day").unwrap().as_str().parse::().unwrap(); - NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg( - "invalid date: failed NaiveDate construction", - ))? + Date::new(year, month, day) + .map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))? }; // not a macro query, we want to use JournalEntry's custom FromRow @@ -131,7 +130,6 @@ mod patch { insert_mentions(&mentions, pool).await?; } - Ok(new_entry.to_html(pool).await?) } } From b361c1ab5815bf612fa7fdd3e301145ef26959ca Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 3 Apr 2026 13:47:23 -0500 Subject: [PATCH 02/20] feat: can_stale and periodicity --- .../each_user/0013_contact-periodicity.sql | 8 +++ src/models/contact.rs | 50 +++++++++++++++++-- src/web/contact/mod.rs | 41 +++++++++++++-- src/web/home.rs | 26 +++++++--- 4 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 migrations/each_user/0013_contact-periodicity.sql diff --git a/migrations/each_user/0013_contact-periodicity.sql b/migrations/each_user/0013_contact-periodicity.sql new file mode 100644 index 0000000..05e65ad --- /dev/null +++ b/migrations/each_user/0013_contact-periodicity.sql @@ -0,0 +1,8 @@ +alter table contacts add column + can_stale boolean not null default true; + +alter table contacts add column + periodicity text not null default 'P0D'; + +alter table contacts add column + active boolean not null default true; diff --git a/src/models/contact.rs b/src/models/contact.rs index 8ed0be6..165c43b 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -1,4 +1,4 @@ -use jiff::{Timestamp, civil}; +use jiff::{Span, Timestamp, civil::Date}; use sqlx::sqlite::SqlitePool; use std::str::FromStr; @@ -12,6 +12,9 @@ struct RawContact { birthday: Option, manually_freshened_at: Option, lives_with: String, + can_stale: bool, + active: bool, + periodicity: String, } #[derive(Clone, Debug)] @@ -20,6 +23,9 @@ pub struct Contact { pub birthday: Option, pub manually_freshened_at: Option, pub lives_with: String, + pub can_stale: bool, + pub active: bool, + pub periodicity: Span, } impl Into for RawContact { @@ -33,6 +39,9 @@ impl Into for RawContact { .manually_freshened_at .and_then(|str| str.parse::().ok()), lives_with: self.lives_with, + can_stale: self.can_stale, + active: self.active, + periodicity: self.periodicity.parse().unwrap(), } } } @@ -42,6 +51,10 @@ struct RawHydratedContact { birthday: Option, manually_freshened_at: Option, lives_with: String, + can_stale: bool, + active: bool, + periodicity: String, + last_mention_date: Option, names: Option, } @@ -49,7 +62,7 @@ struct RawHydratedContact { #[derive(Clone, Debug)] pub struct HydratedContact { pub contact: Contact, - pub last_mention_date: Option, + pub last_mention_date: Option, pub names: Vec, } @@ -61,6 +74,9 @@ impl Into for RawHydratedContact { birthday: self.birthday, manually_freshened_at: self.manually_freshened_at, lives_with: self.lives_with, + can_stale: self.can_stale, + active: self.active, + periodicity: self.periodicity, }), names: self .names @@ -70,7 +86,7 @@ impl Into for RawHydratedContact { .collect::>(), last_mention_date: self .last_mention_date - .and_then(|str| str.parse::().ok()), + .and_then(|str| str.parse::().ok()), } } } @@ -91,11 +107,27 @@ impl HydratedContact { } } + pub fn status(&self) -> &'static str { + if self.can_stale { + if self.active { "normal" } else { "inactive" } + } else { + "permanent" + } + } + pub async fn load(id: DbId, pool: &SqlitePool) -> Result { // copy-paste the query from 'all', then add "where c.id = $2" to the last line let raw = sqlx::query_as!( RawHydratedContact, - r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( + r#"select + id, + birthday, + lives_with, + manually_freshened_at as "manually_freshened_at: String", + can_stale, + active, + periodicity, + ( select string_agg(name,x'1c' order by sort) from names where contact_id = c.id ) as names, ( @@ -122,7 +154,15 @@ impl HydratedContact { pub async fn all(pool: &SqlitePool) -> Result, AppError> { let contacts = sqlx::query_as!( RawHydratedContact, - r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( + r#"select + id, + birthday, + lives_with, + manually_freshened_at as "manually_freshened_at: String", + can_stale, + active, + periodicity, + ( select string_agg(name,x'1c' order by sort) from names where contact_id = c.id ) as names, ( diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 6386bdd..88bd303 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -132,6 +132,14 @@ mod get { div { (name) } } } + @if contact.status() != "normal" { + label { "status" } + div { (contact.status()) } + } + @if contact.status() == "normal" && contact.periodicity.is_positive() { + label { "periodicity" } + div { (format!("{:#}", contact.periodicity)) } + } @if let Some(bday) = &contact.birthday { label { "birthday" } div { @@ -235,7 +243,7 @@ mod get { div #error; } - div #fields { + #fields x-data=(json!({ "status": contact.status() })){ label { @if contact.names.len() > 1 { "names" } @else { "name" }} div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { template x-for="(name, idx) in names" { @@ -251,6 +259,19 @@ mod get { input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''"; } } + label { "status" } + div { + select name="status" x-model=("status") { + option value="normal" { "Normal" } + option value="permanent" { "Cannot go stale" } + option value="inactive" { "Inactive" } + } + } + label x-show="status === 'normal'" { "minimum stale time" } + div x-show="status === 'normal'"{ + input name="periodicity" value=(format!("{:#}", contact.periodicity)); + span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" } + } label { "birthday" } div { input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize())); @@ -326,6 +347,8 @@ mod put { #[derive(Deserialize)] pub struct PutContact { name: Option>, + status: String, + periodicity: Option, birthday: String, manually_freshened_at: String, lives_with: String, @@ -365,6 +388,10 @@ mod put { ) }; + let active: bool = payload.status != "inactive"; + let can_stale: bool = payload.status != "permanent"; + let periodicity: String = payload.periodicity.unwrap_or("P0D".to_string()); + let text_body = if payload.text_body.is_empty() { None } else { @@ -377,13 +404,19 @@ mod put { sqlx::query!( "update contacts set - (birthday, manually_freshened_at, lives_with, text_body) = - ($1, $2, $3, $4) - where id = $5", + ( + birthday, manually_freshened_at, lives_with, text_body, + active, can_stale, periodicity + ) = + (?, ?, ?, ?, ?, ?, ?) + where id = ?", birthday, manually_freshened_at, payload.lives_with, text_body, + active, + can_stale, + periodicity, contact_id ) .execute(pool) diff --git a/src/web/home.rs b/src/web/home.rs index 8af2d07..231ee6a 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -134,7 +134,11 @@ pub mod get { let mut freshens: Vec = contacts .clone() .into_iter() - .map(|contact| { + .filter_map(|contact| { + if !contact.can_stale || !contact.active { + return None; + } + let zero = jiff::civil::Date::ZERO; let fresh_date = std::cmp::max( contact @@ -144,17 +148,17 @@ pub mod get { contact.last_mention_date.unwrap_or(zero), ); if fresh_date == zero { - ContactFreshness { + Some(ContactFreshness { contact_id: contact.id, display: contact.display_name(), fresh_date, fresh_str: "never".to_string(), elapsed_str: "".to_string(), - } + }) } else { let utc = TimeZone::UTC; let todate = Timestamp::now().to_zoned(utc.clone()).date(); - let duration = todate + let elapsed = todate .since(&fresh_date.to_zoned(utc).unwrap()) .unwrap() .round( @@ -165,19 +169,25 @@ pub mod get { ) .unwrap(); - let elapsed_str = if duration.is_zero() { + 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() { "today".to_string() } else { - format!("{:#}", duration) + format!("{:#}", elapsed) }; - ContactFreshness { + Some(ContactFreshness { contact_id: contact.id, display: contact.display_name(), fresh_date, fresh_str: fresh_date.to_string(), elapsed_str, - } + }) } }) .collect(); From f75260c079388ac3b711c63642b4e11227c3bbc6 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 3 Apr 2026 13:56:06 -0500 Subject: [PATCH 03/20] chore: gitkeep dbs folder --- .gitignore | 2 +- dbs/.gitkeep | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 dbs/.gitkeep diff --git a/.gitignore b/.gitignore index 774c5d2..3869d49 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ e2e/node_modules e2e/playwright-report e2e/test-results /some_user.db -/dbs +/dbs/* /hashed_static /users.db /.sqlx diff --git a/dbs/.gitkeep b/dbs/.gitkeep new file mode 100644 index 0000000..e69de29 From b079001cc52cb8330206dc474da168e4d36623ec Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Fri, 3 Apr 2026 16:03:16 -0500 Subject: [PATCH 04/20] feat: inactive contacts hidden in sidebar --- e2e/pages/contact.spec.ts | 12 +++++- e2e/pages/journal.spec.ts | 1 - e2e/playwright.config.ts | 90 +++++++++++++++++++-------------------- migrations/demo.sql | 4 +- src/web/contact/mod.rs | 22 +++++++--- src/web/mod.rs | 40 ++++++++++++++++- static/index.css | 12 +++++- 7 files changed, 123 insertions(+), 58 deletions(-) diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index 46af08e..a1e411d 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => { test('manual-freshen date is editable', async ({ page }) => { await page.getByRole('link', { name: /edit/i }).click(); - await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); + await expect(page.locator('input[name="manually_freshened_on"]')).toBeVisible(); }); test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => { @@ -38,7 +38,15 @@ test.skip("groups wrap nicely", async ({ page }) => { }); test('allow marking as inactive', async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + // TODO: this only works if there's no other comboboxes on the page :/ + await page.getByRole('combobox').selectOption('Inactive') + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + await expect(page.locator('#contacts-sidebar').getByText("Test Testerson")).not.toBeVisible(); }); test('allow exempting from stale', async ({ page }) => { @@ -50,7 +58,7 @@ test('stale list considers periodicity', async ({ page }) => { }); test('page title has contact primary name', async ({ page }) => { - await expect(page.title()).toContain("Test Testerson"); + expect(await page.title()).toContain("Test Testerson"); }); diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index f30efa3..fe9ecb9 100644 --- a/e2e/pages/journal.spec.ts +++ b/e2e/pages/journal.spec.ts @@ -43,7 +43,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => { await page.getByRole('button', { name: 'Add' }).nth(1).click(); await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click(); - console.log(await journal.innerHTML()); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); // delete an existing name diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 1065269..f1b1fab 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,72 +1,72 @@ import { defineConfig, devices } from '@playwright/test'; -import 'custom-expects'; +import './custom-expects'; // purposefully not using ??: we want to replace empty empty string with default const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; let addlConfig = { - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 0, }; let projects = [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, ]; const pfil = process.env.PROJECT_FILTER; if (pfil) { - if (pfil.startsWith('!')) { - projects = projects.filter(p => p.name !== pfil.slice(1)); - } else { - projects = projects.filter(p => p.name === pfil); - } + if (pfil.startsWith('!')) { + projects = projects.filter(p => p.name !== pfil.slice(1)); + } else { + projects = projects.filter(p => p.name === pfil); + } } /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './pages', - fullyParallel: true, - workers: 1, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: Boolean(process.env.CI), - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: BASE_URL, + testDir: './pages', + fullyParallel: true, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: Boolean(process.env.CI), + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: BASE_URL, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, - /* Configure projects for major browsers */ - projects, - ...addlConfig, + /* Configure projects for major browsers */ + projects, + ...addlConfig, }); diff --git a/migrations/demo.sql b/migrations/demo.sql index 17bda4c..8eb0d76 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -7,8 +7,8 @@ insert into names(contact_id, sort, name) values insert into groups(contact_id, name, slug) values (0, 'ABC', 'abc'); -insert into contacts(id, birthday) values - (1, 'April?'); +insert into contacts(id, birthday, active) values + (1, 'April?', false); insert into names(contact_id, sort, name) values (1, 0, 'Bazel Bagend'), (1, 1, 'Bazel'); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 88bd303..9f4bf8f 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -223,7 +223,9 @@ mod get { let mfresh_str = contact .manually_freshened_at .clone() - .map_or("".to_string(), |m| m.to_string()); + .map_or("".to_string(), |m| { + m.to_zoned(TimeZone::UTC).date().to_string() + }); let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) @@ -278,10 +280,20 @@ mod get { span .hint { code { "(yyyy|--)mmdd" } " or free text" } } label { "freshened" } - div x-data=(json!({ "date": mfresh_str })) { - input type="hidden" name="manually_freshened_at" x-model="date"; - span x-text="date.length ? date.split('T')[0] : '(never)'" {} - input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()"; + div x-data=(json!({ "date": mfresh_str, "stamp": "" })) x-init="today = () => (new Date().toISOString().split('T')[0])" { + input + type="hidden" + name="manually_freshened_at" + x-model="stamp"; + input + type="date" + name="manually_freshened_on" + x-model="date" + x-bind:max="today()" + x-on:input="stamp = new Date(date).toISOString()"; + + input type="button" value="Mark fresh now" x-on:click="date = today(); stamp = new Date().toISOString()"; + span .hint x-text="`max ${today()}`"; } label { "phone" } #phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) { diff --git a/src/web/mod.rs b/src/web/mod.rs index 6a6e141..a7f755d 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -25,6 +25,7 @@ struct ContactLink { #[derive(Debug)] pub struct Layout { contact_links: Vec, + inactive_contact_links: Vec, user: User, } @@ -48,6 +49,20 @@ impl FromRequestParts for Layout { from contacts c left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 + and c.active = true + order by name asc", + ) + .fetch_all(&state.db(&user).pool) + .await?; + + let inactive_contact_links = sqlx::query_as!( + ContactLink, + "select c.id as contact_id, + coalesce(n.name, '(unnamed)') as name + from contacts c + left join names n on c.id = n.contact_id + where n.sort is null or n.sort = 0 + and c.active = false order by name asc", ) .fetch_all(&state.db(&user).pool) @@ -55,13 +70,19 @@ impl FromRequestParts for Layout { Ok(Layout { contact_links, + inactive_contact_links, user, }) } } impl Layout { - pub fn render(&self, title: impl AsRef, css: Option>, content: Markup) -> Markup { + pub fn render( + &self, + title: impl AsRef, + css: Option>, + content: Markup, + ) -> Markup { html! { (DOCTYPE) html { @@ -101,6 +122,23 @@ impl Layout { } } } + + @if !self.inactive_contact_links.is_empty() { + li .inactive { + details { + summary { "Inactive contacts" } + ul { + @for link in &self.inactive_contact_links { + li { + a href=(format!("/contact/{}", link.contact_id)) { + (link.name) + } + } + } + } + } + } + } } } main { diff --git a/static/index.css b/static/index.css index 03bf9da..5889146 100644 --- a/static/index.css +++ b/static/index.css @@ -8,7 +8,7 @@ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abb body { width: 100%; - min-height: 100vh; + height: 100vh; display: flex; flex-direction: column; align-items: center; @@ -35,6 +35,7 @@ section#content { display: flex; flex-direction: row; width: 100%; + height: 100%; @media only screen and (max-width: 650px) { position: relative; } @@ -44,6 +45,8 @@ section#content { display: flex; flex-direction: column; padding-right: 1em; + height: 100%; + overflow-y: auto; @media only screen and (max-width: 650px) { position: absolute; float: left; @@ -58,7 +61,7 @@ section#content { } } - ul { + & > ul { flex: 1; width: fit-content; background-color: var(--main-bg-color); @@ -79,12 +82,17 @@ section#content { border-bottom: none; } } + + li.inactive { + font-size: small; + } } main { display: flex; flex-direction: column; flex: 1; + overflow-y: auto; } .icon { From 5866c94535963b235dde32f12e7f0b2e6171a0f6 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sat, 4 Apr 2026 22:47:23 -0500 Subject: [PATCH 05/20] fix: manually-freshened-at date overwritten --- Taskfile | 4 ++-- e2e/Taskfile | 10 +--------- e2e/pages/contact.spec.ts | 35 ++++++++++++++++++++++++++++++++--- src/web/contact/mod.rs | 19 ++++++++++++------- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/Taskfile b/Taskfile index 9eeaa50..9fa64cf 100755 --- a/Taskfile +++ b/Taskfile @@ -1,11 +1,11 @@ #!/usr/bin/env bash playwright:local() { - bash e2e/Taskfile playwright:local + bash e2e/Taskfile playwright:local "$@" } playwright:ui() { - bash e2e/Taskfile playwright:ui + bash e2e/Taskfile playwright:ui "$@" } refresh_sqlx_db() { diff --git a/e2e/Taskfile b/e2e/Taskfile index 8cb16ea..ee88f38 100755 --- a/e2e/Taskfile +++ b/e2e/Taskfile @@ -10,20 +10,12 @@ playwright:local() { exec docker run \ --interactive --tty --rm --ipc=host --net=host \ --volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \ - --env ASTRO_TELEMETRY_DISABLED=1 \ "mcr.microsoft.com/playwright:$(_playwright_version)" \ bash -c "cd /e2e && env PROJECT_FILTER=firefox ./Taskfile _test $*" } playwright:ui() { - xhost +local:docker - exec docker run \ - --interactive --tty --rm --ipc=host --net=host\ - --env DISPLAY="$DISPLAY" \ - --volume /tmp/.X11-unix:/tmp/.X11-unix \ - --volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \ - "mcr.microsoft.com/playwright:$(_playwright_version)" \ - /bin/bash -c "cd /e2e && ./Taskfile _test --ui $*" + playwright:local --ui-host=0.0.0.0 } playwright:ci() { diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index a1e411d..8b4318d 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -41,23 +41,52 @@ test('allow marking as inactive', async ({ page }) => { await page.getByRole('link', { name: /edit/i }).click(); await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); - // TODO: this only works if there's no other comboboxes on the page :/ - await page.getByRole('combobox').selectOption('Inactive') + await page.getByLabel('status').selectOption('Inactive'); await page.getByRole('button', { name: /save/i }).click(); - await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); await expect(page.locator('#contacts-sidebar').getByText("Test Testerson")).not.toBeVisible(); }); test('allow exempting from stale', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#freshness')).toContainText('Test Testersonnever'); + await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByLabel('status').selectOption('Cannot go stale'); + await page.getByRole('button', { name: /save/i }).click(); + await page.goto('/'); + await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever'); }); test('stale list considers periodicity', async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + const last_week = (() => { + let last_week = new Date(); + last_week.setDate(last_week.getDate() - 7); + return last_week.toISOString().split("T")[0]; + })(); + await page.getByLabel('freshened').fill(last_week); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#journal')).toBeVisible(); + await expect(page.locator('#fields')).toContainText(`freshened${last_week}`); + await page.goto('/'); + await expect(page.locator('#freshness')).toContainText(`Test Testerson${last_week}7d`); + await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click(); + await page.getByRole('link', { name: /edit/i }).click(); + + await page.getByLabel('minimum stale time').fill('2 weeks'); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#journal')).toBeVisible(); + + await page.goto('/'); + await expect(page.locator('#freshness')).not.toContainText(`Test Testerson`); }); test('page title has contact primary name', async ({ page }) => { + // wait for page load to finish + await expect(page.locator('#journal')).toBeVisible(); expect(await page.title()).toContain("Test Testerson"); }); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 9f4bf8f..7dc487b 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -220,12 +220,16 @@ mod get { .await?; let cid_url = format!("/contact/{}", contact.id); - let mfresh_str = contact + let mfresh_on_str = contact .manually_freshened_at .clone() .map_or("".to_string(), |m| { m.to_zoned(TimeZone::UTC).date().to_string() }); + let mfresh_at_str = contact + .manually_freshened_at + .clone() + .map_or("".to_string(), |m| m.to_zoned(TimeZone::UTC).to_string()); let text_body: String = sqlx::query!("select text_body from contacts where id = $1", contact_id) @@ -261,17 +265,17 @@ mod get { input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''"; } } - label { "status" } + label for="status" { "status" } div { - select name="status" x-model=("status") { + select #status name="status" x-model=("status") { option value="normal" { "Normal" } option value="permanent" { "Cannot go stale" } option value="inactive" { "Inactive" } } } - label x-show="status === 'normal'" { "minimum stale time" } + label x-show="status === 'normal'" for="periodicity" { "minimum stale time" } div x-show="status === 'normal'"{ - input name="periodicity" value=(format!("{:#}", contact.periodicity)); + input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity)); span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" } } label { "birthday" } @@ -279,8 +283,8 @@ mod get { input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize())); span .hint { code { "(yyyy|--)mmdd" } " or free text" } } - label { "freshened" } - div x-data=(json!({ "date": mfresh_str, "stamp": "" })) x-init="today = () => (new Date().toISOString().split('T')[0])" { + label for="manually_freshened_on" { "freshened" } + div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" { input type="hidden" name="manually_freshened_at" @@ -288,6 +292,7 @@ mod get { input type="date" name="manually_freshened_on" + id="manually_freshened_on" x-model="date" x-bind:max="today()" x-on:input="stamp = new Date(date).toISOString()"; From 4f141b01c30a4e8e35de6484879d347fac28618c Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sat, 4 Apr 2026 22:59:39 -0500 Subject: [PATCH 06/20] docs: more README instructions --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90fb9b5..fad7cb6 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,56 @@ I think of when I see "CRM". * Last-contact-time mapping * Address as single field (plus code? lat/long? go crazy!) * Free-text-entry field + * Desired contact periodicity * Journal with Obsidian-like `[[link]]` syntax +* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner") * ical server for birthday reminders +## Explore + +My instance is at https://crm.rperce.net. Username "demo" and password "demo" let +you log into an ephemeral demo user if you want to poke around. + +If you want an account, contact me directly or use the "self-hosting" instructions below. + ## Planned features * Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server * Act as CardDAV server for other clients * For each contact: * Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar * Relationship mapping - * Desired contact periodicity * Additional arbitrary fields (no special handling) -* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner") * "Named in journal but has no contact entry" detection * Email birthday reminders over SMTP + +--- + +## Development / self-hosting + +1. Clone the repo. +2. Build for your system with `./Taskfile _cargo build --release`. +3. Deploy the binary from `./target/release/mascarpone` to wherever you want that's in PATH + (or use it from here if you want) +4. In the working directory that you want the server to save its databases in, + 1. Create a user for yourself with `mascarpone set-password YOUR_USERNAME`. This will create a `users.db` file. + 2. Run `mkdir dbs`. + 3. Copy the `hashed_static` directory from the code repository. +5. Run `mascarpone serve [port]` from that working directory. The default port is 3000. + If you need to be able to bind to a host other than `0.0.0.0`, contact me directly. + +### Example systemd service file +``` +[Unit] +Description=Mascarpone CRM +After=network.target + +[Service] +Type=simple +WorkingDirectory=/var/local/mascarpone/ +ExecStart=/usr/bin/mascarpone serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` From c12975926d52595f8e46d9a1c6d00097a30557d1 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 12:05:13 -0500 Subject: [PATCH 07/20] spec: show birthdays until a week out --- e2e/pages/home.spec.ts | 78 ++++++++++++++++++++++++-------- src/models/birthday.rs | 16 ++----- src/models/year_optional_date.rs | 11 +---- src/web/home.rs | 4 +- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 13b9fa1..8ec39dd 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -2,44 +2,82 @@ import { test, expect } from '@playwright/test'; import { login, verifyCreateUser, todate } from './util'; test.beforeEach(async ({ page }) => { - await login(page); + await login(page); }); test('can log out', async ({ page }) => { - await page.getByText("Logout").click(); - await expect(page.getByLabel("Username")).toBeVisible(); + await page.getByText("Logout").click(); + await expect(page.getByLabel("Username")).toBeVisible(); }); test('has no contacts', async ({ page }) => { - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); + await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); }); test('can add contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await verifyCreateUser(page, { names: ['Jack Contact'] }); - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); + await verifyCreateUser(page, { names: ['John Contact'] }); + await verifyCreateUser(page, { names: ['Jack Contact'] }); + await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); }); test('shows "never" for unfreshened contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: 'Mascarpone' }).click(); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect(page.locator('#freshness')).toContainText('John Contactnever'); + await expect(page.locator('#freshness')).toContainText('John Contactnever'); }); test('shows the date for fresh contacts', async ({ page }) => { - await verifyCreateUser(page, { names: ['John Contact'] }); - await page.getByRole('link', { name: /edit/i }).click(); - await page.getByRole('button', { name: /fresh/i }).click(); - await page.getByRole('button', { name: /save/i }).click(); - await page.getByRole('link', { name: 'Mascarpone' }).click(); - await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`); + await verifyCreateUser(page, { names: ['John Contact'] }); + await page.getByRole('link', { name: /edit/i }).click(); + await page.getByRole('button', { name: /fresh/i }).click(); + await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('link', { name: 'Mascarpone' }).click(); + await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`); }); test('sidebar is sorted alphabetically', async ({ page }) => { - await verifyCreateUser(page, { names: ['Zulu'] }); - await verifyCreateUser(page, { names: ['Alfa'] }); - await verifyCreateUser(page, { names: ['Golf'] }); + await verifyCreateUser(page, { names: ['Zulu'] }); + await verifyCreateUser(page, { names: ['Alfa'] }); + await verifyCreateUser(page, { names: ['Golf'] }); - await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); + await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); +}); + +test('upcoming and recent show at least one birthday a week away', async ({ page }) => { + const monthday = d => d.toISOString().split("T")[0].replace(/^\d{4}-/, ''); + const today = monthday(new Date()); + const yesterday = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() - 1); + return date; + })()); + const tomorrow = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() + 1); + return date; + })()); + const aMonthAgo = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() - 28); + return date; + })()); + const inAMonth = monthday((() => { + let date = new Date(); + date.setDate(date.getDate() + 28); + return date; + })()); + await verifyCreateUser(page, { names: ['Alfa'], birthday: today }); + await verifyCreateUser(page, { names: ['Beta'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Echo'], birthday: today }); + await verifyCreateUser(page, { names: ['Golf'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Lima'], birthday: tomorrow }); + await verifyCreateUser(page, { names: ['Mike'], birthday: yesterday }); + await verifyCreateUser(page, { names: ['Xray'], birthday: inAMonth }); + await verifyCreateUser(page, { names: ['Zulu'], birthday: aMonthAgo }); + + await page.goto('/'); + + await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4); + await expect(page.locator('#recent').getByRole('link')).toHaveCount(4); }); diff --git a/src/models/birthday.rs b/src/models/birthday.rs index f7c0256..2db0296 100644 --- a/src/models/birthday.rs +++ b/src/models/birthday.rs @@ -20,11 +20,10 @@ pub enum Birthday { impl Display for Birthday { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let str = match self { - Birthday::Date(date) => date.to_string(), - Birthday::Text(t) => t.value.clone(), - }; - write!(f, "{}", str) + match self { + Birthday::Date(date) => write!(f, "{}", date), + Birthday::Text(t) => write!(f, "{}", t.value), + } } } @@ -56,13 +55,6 @@ impl Birthday { } } } - - pub fn serialize(&self) -> String { - match &self { - Birthday::Text(text) => text.value.clone(), - Birthday::Date(date) => date.serialize(), - } - } } impl FromStr for Birthday { diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index fa1fc1f..d0052b8 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -43,15 +43,6 @@ impl YearOptionalDate { None } } - - pub fn serialize(&self) -> String { - format!( - "{}{:0>2}{:0>2}", - self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)), - self.month, - self.day - ) - } } impl Display for YearOptionalDate { @@ -110,6 +101,6 @@ where &self, buf: &mut ::ArgumentBuffer<'r>, ) -> Result> { - >::encode(self.serialize(), buf) + >::encode(self.to_string(), buf) } } diff --git a/src/web/home.rs b/src/web/home.rs index 231ee6a..81ba00e 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -57,7 +57,7 @@ fn birthdays_section( div id="birthdays" { h2 { "Birthdays" } #birthday-sections { - .datelist { + .datelist #upcoming { h3 { "upcoming" } @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { @@ -68,7 +68,7 @@ fn birthdays_section( } } } - .datelist { + .datelist #recent { h3 { "recent" } @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { a href=(format!("/contact/{}", contact.contact_id)) { From 4c710dcd201dcf37e089020f1e98c2b214a6f9c6 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 13:20:00 -0500 Subject: [PATCH 08/20] feat: sane date format --- migrations/demo.sql | 4 ++-- migrations/each_user/0014_birthday-format.sql | 7 +++++++ src/models/year_optional_date.rs | 13 +++++-------- src/web/contact/mod.rs | 6 +++--- 4 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 migrations/each_user/0014_birthday-format.sql diff --git a/migrations/demo.sql b/migrations/demo.sql index 8eb0d76..73b2d4b 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -1,5 +1,5 @@ insert into contacts(id, birthday, manually_freshened_at) values - (0, '--0415', '2000-01-01T12:00:00'); + (0, '04-15', '2000-01-01T12:00:00'); insert into names(contact_id, sort, name) values (0, 0, 'Alex Aaronson'), (0, 1, 'Alexi'), @@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values (1, 'ABC', 'abc'); insert into contacts(id, birthday) values - (2, '19951018'); + (2, '1995-10-18'); insert into names(contact_id, sort, name) values (2, 0, 'Charlie Certaindate'); insert into groups(contact_id, name, slug) values diff --git a/migrations/each_user/0014_birthday-format.sql b/migrations/each_user/0014_birthday-format.sql new file mode 100644 index 0000000..80b69cf --- /dev/null +++ b/migrations/each_user/0014_birthday-format.sql @@ -0,0 +1,7 @@ +update contacts + set birthday = substr(birthday, 3, 2) || '-' || substr(birthday, 5, 2) + where birthday GLOB '--[01][0-9][0-3][0-9]'; + +update contacts + set birthday = substr(birthday, 1, 4) || '-' || substr(birthday, 5, 2) || '-' || substr(birthday, 7, 2) + where birthday GLOB '[0-9][0-9][0-9][0-9][01][0-9][0-3][0-9]'; diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index d0052b8..a4cd381 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -57,21 +57,18 @@ impl Display for YearOptionalDate { impl FromStr for YearOptionalDate { type Err = anyhow::Error; fn from_str(str: &str) -> Result { - let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap(); + let date_re = Regex::new(r"^(?:([0-9]{4})-)?([0-9]{2})-([0-9]{2})$").unwrap(); if let Some(caps) = date_re.captures(str) { - let year_str = &caps[1]; + let year = caps + .get(1) + .map(|yyyy| i16::from_str(yyyy.as_str()).unwrap()); let month = i8::from_str(&caps[2]).unwrap(); let day = i8::from_str(&caps[3]).unwrap(); - let year = if year_str == "--" { - None - } else { - Some(i16::from_str(year_str).unwrap()) - }; return Ok(Self { year, month, day }); } Err(anyhow::Error::msg(format!( - "parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/", + "parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}-)?[0-9]{{2}}-[0-9]{{2}}/", str ))) } diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 7dc487b..b99abdb 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -278,10 +278,10 @@ mod get { input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity)); span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" } } - label { "birthday" } + label for="birthday" { "birthday" } div { - input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize())); - span .hint { code { "(yyyy|--)mmdd" } " or free text" } + input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}"))); + span .hint { code { "(yyyy-)?mm-dd" } " or free text" } } label for="manually_freshened_on" { "freshened" } div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" { From 0baf51646ed361eed03b6e4893ad5600e2dbcb43 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sun, 5 Apr 2026 13:20:22 -0500 Subject: [PATCH 09/20] feat: show birthdays until a week out --- src/web/home.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/web/home.rs b/src/web/home.rs index 81ba00e..49fbb4a 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -1,7 +1,7 @@ use axum::extract::State; use axum::response::IntoResponse; use cache_bust::asset; -use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone}; +use jiff::{Timestamp, ToSpan, Unit, Zoned, civil, tz::TimeZone}; use maud::{Markup, html}; use sqlx::sqlite::SqlitePool; @@ -53,13 +53,40 @@ fn birthdays_section( prev_birthdays: &Vec, upcoming_birthdays: &Vec, ) -> Result { + let now = Timestamp::now().to_zoned(TimeZone::UTC); + let in_a_week = upcoming_birthdays + .iter() + .position(|b| { + now.until(&b.next_birthday.to_zoned(TimeZone::UTC).unwrap()) + .unwrap() + .compare((&1_i32.week(), &now)) + .unwrap() + != std::cmp::Ordering::Less + }) + .unwrap_or(upcoming_birthdays.len()); + let upcoming = &upcoming_birthdays + [0..std::cmp::min(std::cmp::max(3, in_a_week + 1), upcoming_birthdays.len())]; + + let a_week_ago = prev_birthdays + .iter() + .position(|b| { + now.since(&b.prev_birthday.to_zoned(TimeZone::UTC).unwrap()) + .unwrap() + .compare((&1_i32.week(), &now)) + .unwrap() + != std::cmp::Ordering::Less + }) + .unwrap_or(upcoming_birthdays.len()); + let recent = + &prev_birthdays[0..std::cmp::min(std::cmp::max(3, a_week_ago + 1), prev_birthdays.len())]; + Ok(html! { div id="birthdays" { h2 { "Birthdays" } #birthday-sections { .datelist #upcoming { h3 { "upcoming" } - @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { + @for contact in upcoming { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } @@ -70,7 +97,7 @@ fn birthdays_section( } .datelist #recent { h3 { "recent" } - @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { + @for contact in recent { a href=(format!("/contact/{}", contact.contact_id)) { (contact.display) } From 62b0efac0465de4f7cca10c7f9f37f87ce0aa874 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 10:49:04 -0500 Subject: [PATCH 10/20] feat: scroll to current contact in sidebar by default --- e2e/pages/contact.spec.ts | 4 ---- e2e/pages/home.spec.ts | 18 ++++++++++++++++++ src/web/contact/mod.rs | 19 +++++++++++++++++-- src/web/mod.rs | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index 8b4318d..3d2e4c9 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -96,9 +96,5 @@ test('bullet points in free text display well', async ({ page }) => { }); -home: contact list scrolls in screen, not off screen -home: clicking off contact list closes it -home: contact list is sorted ignoring case -home: contact list should scroll to current contact in center of view journal: bullet points don't display */ diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 8ec39dd..2ab54f2 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -81,3 +81,21 @@ test('upcoming and recent show at least one birthday a week away', async ({ page await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4); await expect(page.locator('#recent').getByRole('link')).toHaveCount(4); }); + + +test('contact list scrolls (independently) to current contact in center of view', async ({ page }) => { + for (let count = 0; count < 30; count++) { + await verifyCreateUser(page, { names: [`Contact${count < 10 ? '0' + count : count}`] }); + } + + await page.goto('/contact/28'); + await expect(page.getByRole('navigation').getByRole('link', { name: /Contact28/ })).toBeVisible(); + expect(await page.locator('main').evaluate(e => e.scrollTop)).toEqual(0); + + await page.goto('/contact/16'); + await expect(page.locator('#nav-link-16')).toBeVisible(); + const linkPos: number = await page.locator('#nav-link-16').evaluate(e => e.getBoundingClientRect().y); + + // roughly centered is fine, not that fussy about headers and whatnot + expect(Math.abs(linkPos - (await page.evaluate('window.innerHeight/2') as number))).toBeLessThan(200); +}); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index b99abdb..6b90020 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -61,6 +61,21 @@ fn human_delta(span: &jiff::Span) -> String { mod get { use super::*; + fn scroll_to(id: DbId) -> String { + format!( + "\ + const top = document\ + .getElementById('nav-link-{}')\ + ?.getBoundingClientRect()\ + ?.top;\ + console.log({{ top }});\ + top && document\ + .getElementById('contacts-sidebar')\ + .scrollTo({{top: top+window.innerHeight/2,left:0,behavior:'instant'}});", + id + ) + } + pub async fn contact( auth_session: AuthSession, State(state): State, @@ -125,7 +140,7 @@ mod get { html! { a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } - div id="fields" { + div #fields x-init=(scroll_to(contact_id)) { label { @if contact.names.len() > 1 { "names" } @else { "name" }} div { @for name in &contact.names { @@ -249,7 +264,7 @@ mod get { div #error; } - #fields x-data=(json!({ "status": contact.status() })){ + #fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) { label { @if contact.names.len() > 1 { "names" } @else { "name" }} div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { template x-for="(name, idx) in names" { diff --git a/src/web/mod.rs b/src/web/mod.rs index a7f755d..3aa6b89 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -117,7 +117,7 @@ impl Layout { li { button hx-post="/contact/new" { "+ Add Contact" } } @for link in &self.contact_links { li { - a href=(format!("/contact/{}", link.contact_id)) { + a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) { (link.name) } } From 1206e211d52ed65553b9d77369b81b6b9212ccb5 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 10:49:23 -0500 Subject: [PATCH 11/20] feat: click off sidebar on small windows closes it --- e2e/pages/home.spec.ts | 12 ++++++++++++ src/web/mod.rs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 2ab54f2..d852a0e 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -99,3 +99,15 @@ test('contact list scrolls (independently) to current contact in center of view' // roughly centered is fine, not that fussy about headers and whatnot expect(Math.abs(linkPos - (await page.evaluate('window.innerHeight/2') as number))).toBeLessThan(200); }); + +test('clicking off contact list when expanded closes it', async ({ page }) => { + await page.setViewportSize({ + width: 640, + height: 1000, + }); + // TODO aria-label + await page.locator('#sidebar-show-hide').click(); + await expect(page.getByRole('button', { name: /add contact/i })).toBeVisible(); + await page.mouse.click(600, 500); + await expect(page.getByRole('button', { name: /add contact/i })).not.toBeVisible(); +}); diff --git a/src/web/mod.rs b/src/web/mod.rs index 3aa6b89..3a3a47c 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -112,7 +112,7 @@ impl Layout { a href="/logout" { "Logout" } } section #content { - nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" { + nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" { ul { li { button hx-post="/contact/new" { "+ Add Contact" } } @for link in &self.contact_links { From 559c1f17601fc595fe22117a288c8409cf6b3fcb Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 10:49:46 -0500 Subject: [PATCH 12/20] feat: sort contacts sidebar ignoring case --- e2e/pages/home.spec.ts | 9 +++++++++ src/web/mod.rs | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index d852a0e..db98bb1 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -111,3 +111,12 @@ test('clicking off contact list when expanded closes it', async ({ page }) => { await page.mouse.click(600, 500); await expect(page.getByRole('button', { name: /add contact/i })).not.toBeVisible(); }); + +test('contact list is sorted ignoring case', async ({ page }) => { + await verifyCreateUser(page, { names: ['Alfa'] }); + await verifyCreateUser(page, { names: ['bob'] }); + await verifyCreateUser(page, { names: ['Charlie'] }); + + await expect(page.locator('#contacts-sidebar')).toContainText(/alfa\s*bob\s*charlie/i); + +}); diff --git a/src/web/mod.rs b/src/web/mod.rs index 3a3a47c..c6b0462 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -50,7 +50,7 @@ impl FromRequestParts for Layout { left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 and c.active = true - order by name asc", + order by name collate nocase asc", ) .fetch_all(&state.db(&user).pool) .await?; @@ -63,7 +63,7 @@ impl FromRequestParts for Layout { left join names n on c.id = n.contact_id where n.sort is null or n.sort = 0 and c.active = false - order by name asc", + order by name collate nocase asc", ) .fetch_all(&state.db(&user).pool) .await?; From e74fe354d0141f8a32113c59309395510170d5aa Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 11:09:14 -0500 Subject: [PATCH 13/20] feat: report version information --- Cargo.lock | 185 +++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + build.rs | 11 +++ e2e/pages/home.spec.ts | 1 - src/main.rs | 55 +++++++----- 5 files changed, 223 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c03174..8443327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,39 @@ dependencies = [ "litrs", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "cc" version = "1.2.29" @@ -582,6 +615,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + [[package]] name = "debug-helper" version = "0.3.13" @@ -609,6 +677,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.106", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -1189,6 +1288,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -1490,6 +1595,7 @@ dependencies = [ "tracing", "tracing-subscriber", "vcard", + "vergen-gitcl", ] [[package]] @@ -1649,9 +1755,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1683,6 +1789,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -2213,6 +2328,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -2748,30 +2873,32 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3194,6 +3321,46 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "cargo_metadata", + "derive_builder", + "regex", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 5b03dc4..f2fdc22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,3 +44,4 @@ vcard = "0.4.13" [build-dependencies] cache_bust = "0.2.0" +vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] } diff --git a/build.rs b/build.rs index 7195630..2c984c9 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,5 @@ use cache_bust::CacheBust; +use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder}; fn main() { println!("cargo:rerun-if-changed=migrations"); @@ -9,4 +10,14 @@ fn main() { .build(); cache_bust.hash_dir().expect("Cache busting failed"); + + let build = BuildBuilder::all_build().expect("build information failed"); + let gitcl = GitclBuilder::all_git().expect("gitcl information failed"); + Emitter::default() + .add_instructions(&build) + .unwrap() + .add_instructions(&gitcl) + .unwrap() + .emit() + .unwrap(); } diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index db98bb1..cf9f50b 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -118,5 +118,4 @@ test('contact list is sorted ignoring case', async ({ page }) => { await verifyCreateUser(page, { names: ['Charlie'] }); await expect(page.locator('#contacts-sidebar')).toContainText(/alfa\s*bob\s*charlie/i); - }); diff --git a/src/main.rs b/src/main.rs index 9c86f81..6ebad84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, RwLock}; use tokio::net::TcpListener; use tokio::signal; use tokio::task::AbortHandle; -use tower_http::services::{ServeDir,ServeFile}; +use tower_http::services::{ServeDir, ServeFile}; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -124,6 +124,8 @@ enum Commands { ephemeral: bool, }, + /// print version information + Version, } async fn serve(port: &u32) -> Result<(), anyhow::Error> { @@ -181,7 +183,10 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .merge(auth::router()) .merge(ics::router()) .nest_service("/static", ServeDir::new("./hashed_static")) - .nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico")))) + .nest_service( + "/favicon.ico", + ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))), + ) .layer(auth_layer) .layer(tower_http::trace::TraceLayer::new_for_http()) .with_state(state); @@ -231,7 +236,10 @@ async fn main() -> Result<(), anyhow::Error> { println!("No update was made; probably something went wrong."); } } - Some(Commands::SetEphemeral { username, ephemeral }) => { + Some(Commands::SetEphemeral { + username, + ephemeral, + }) => { let users_db = { let db_options = SqliteConnectOptions::from_str("users.db")? .create_if_missing(true) @@ -242,23 +250,24 @@ async fn main() -> Result<(), anyhow::Error> { db }; - let eph: Option = sqlx::query_scalar( - "select ephemeral from users where username = ?" - ) - .bind(&username) - .fetch_optional(&users_db) - .await?; - if let Some(eph) = eph { - if eph == *ephemeral { - println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" }); - } else { - let update = sqlx::query( - "update users set ephemeral=$1 where username = $2", - ) - .bind(ephemeral) + let eph: Option = + sqlx::query_scalar("select ephemeral from users where username = ?") .bind(&username) - .execute(&users_db) + .fetch_optional(&users_db) .await?; + if let Some(eph) = eph { + if eph == *ephemeral { + println!( + "User {} is already {}.", + username, + if eph { "ephemeral" } else { "not ephemeral" } + ); + } else { + let update = sqlx::query("update users set ephemeral=$1 where username = $2") + .bind(ephemeral) + .bind(&username) + .execute(&users_db) + .await?; if update.rows_affected() > 0 { println!("Updated ephemerality for {}.", username); @@ -267,13 +276,19 @@ async fn main() -> Result<(), anyhow::Error> { } } } else { - println!("User {} does not exist. Create them first with set-password.", username); + println!( + "User {} does not exist. Create them first with set-password.", + username + ); } - } Some(Commands::Serve { port }) => { serve(port).await?; } + Some(Commands::Version) => { + println!("mascarpone v{}", env!("CARGO_PKG_VERSION")); + println!("from git commit {}", env!("VERGEN_GIT_SHA")); + } None => { serve(&3000).await?; } From e82b6167ef51d8a54fa3cea0e239628b6ab847bd Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 22:58:28 -0500 Subject: [PATCH 14/20] perf(test): test performance improvements --- cliff.toml | 94 +++++++++++++++++++++++++++++++++++++++++++++++ e2e/pages/util.ts | 5 ++- mise.toml | 1 + src/web/auth.rs | 12 ++++-- src/web/mod.rs | 12 ++++-- 5 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 cliff.toml diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..8228887 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,94 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + + +[changelog] +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %} +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true +# Render body even when there are no releases to process. +render_always = true +# An array of regex based postprocessors to modify the changelog. +postprocessors = [ + # Replace the placeholder with a URL. + #{ pattern = '', replace = "https://github.com/orhun/git-cliff" }, +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = true +# Require all commits to be conventional. +# Takes precedence over filter_unconventional. +require_conventional = false +# Split commits on newlines, treating each line as an individual commit. +split_commits = false +# An array of regex based parsers to modify commit messages prior to further processing. +commit_preprocessors = [ + # Replace issue numbers with link templates to be updated in `changelog.postprocessors`. + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit message using https://github.com/crate-ci/typos. + # If the spelling is incorrect, it will be fixed automatically. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +protect_breaking_commits = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, + { body = ".*security", group = "🛡️ Security" }, + { message = "^revert", group = "◀️ Revert" }, + { message = ".*", group = "💼 Other" }, +] +# Exclude commits that are not matched by any commit parser. +filter_commits = false +# Fail on a commit that is not matched by any commit parser. +fail_on_unmatched_commit = false +# An array of link parsers for extracting external references, and turning them into URLs, using regex. +link_parsers = [] +# Include only the tags that belong to the current branch. +use_branch_tags = false +# Order releases topologically instead of chronologically. +topo_order = false +# Order commits topologically instead of chronologically. +topo_order_commits = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" +# Process submodules commits +recurse_submodules = false diff --git a/e2e/pages/util.ts b/e2e/pages/util.ts index f73c74a..d23e326 100644 --- a/e2e/pages/util.ts +++ b/e2e/pages/util.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; export const login = async (page: Page) => { @@ -15,9 +16,9 @@ type UserFields = { }; export const verifyCreateUser = async (page: Page, fields: UserFields) => { await page.getByRole('button', { name: /add contact/i }).click(); + await page.waitForResponse('/contact/new'); - // TODO this is stupid but playwright kept filling while alpine was initializing - await page.waitForTimeout(200); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); const { names, ...simple } = fields; for (const name of (names ?? [])) { diff --git a/mise.toml b/mise.toml index 6846e14..fe79931 100644 --- a/mise.toml +++ b/mise.toml @@ -2,3 +2,4 @@ "rust-analyzer" = "latest" "jj" = "latest" node = "24" +git-cliff = "latest" diff --git a/src/web/auth.rs b/src/web/auth.rs index 42890cc..79eb733 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -81,9 +81,15 @@ mod get { link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); title { "Mascarpone CRM" } meta name="viewport" content="width=device-width"; - script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} - script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} - script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} + @if cfg!(debug_assertions) { + script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {} + script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {} + script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {} + } @else { + script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {} + script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {} + script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} + } link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css"))); title { "Mascarpone" } diff --git a/src/web/mod.rs b/src/web/mod.rs index c6b0462..0cf96dc 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -94,9 +94,15 @@ impl Layout { link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); meta name="viewport" content="width=device-width"; - script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {} - script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {} - script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} + @if cfg!(debug_assertions) { + script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {} + script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {} + script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {} + } @else { + script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {} + script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {} + script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {} + } @if let Some(hrefs) = css { @for href in hrefs { link rel="stylesheet" type="text/css" href=(format!("/static/{}", href)); From 41972ca1abe6d77cc658584a8ab93eb0f83bf80c Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Tue, 7 Apr 2026 22:59:02 -0500 Subject: [PATCH 15/20] perf: large cache time on immutable hashed statics --- Cargo.lock | 5 +++-- Cargo.toml | 3 ++- src/main.rs | 12 +++++++++++- static/alpinejs.min.js | 5 +++++ static/htmx-ext-response-targets.js | 1 + static/htmx.min.js | 1 + 6 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 static/alpinejs.min.js create mode 100644 static/htmx-ext-response-targets.js create mode 100644 static/htmx.min.js diff --git a/Cargo.lock b/Cargo.lock index 8443327..8d6aae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,6 +1589,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", + "tower", "tower-http", "tower-sessions", "tower-sessions-sqlx-store", @@ -2985,9 +2986,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index f2fdc22..3d73af5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,8 @@ sqlx = { version = "0.8", features = ["macros", "runtim thiserror = "2.0.17" time = "0.3.44" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } -tower-http = { version = "0.6.6", features = ["fs", "trace"] } +tower = "0.5.3" +tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] } tower-sessions = { version = "0.14.0", features = ["signed"] } tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } tracing = { version = "0.1.41", features = ["attributes"] } diff --git a/src/main.rs b/src/main.rs index 6ebad84..330c4a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,9 @@ use std::sync::{Arc, RwLock}; use tokio::net::TcpListener; use tokio::signal; use tokio::task::AbortHandle; +use tower::ServiceBuilder; use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions_sqlx_store::SqliteStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -182,7 +184,15 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> { .route_layer(login_required!(Backend, login_url = "/login")) .merge(auth::router()) .merge(ics::router()) - .nest_service("/static", ServeDir::new("./hashed_static")) + .nest_service( + "/static", + ServiceBuilder::new() + .layer(SetResponseHeaderLayer::overriding( + http::header::CACHE_CONTROL, + http::header::HeaderValue::from_static("public, max-age=31536000, immutable"), + )) + .service(ServeDir::new("./hashed_static")), + ) .nest_service( "/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))), diff --git a/static/alpinejs.min.js b/static/alpinejs.min.js new file mode 100644 index 0000000..0acdcef --- /dev/null +++ b/static/alpinejs.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{let s=t.apply(z([n,...e]),i);Ne(r,s)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=z([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>re(u,r,t));n.finished?(Ne(i,n.result,c,s,r),n.result=void 0):l.then(u=>{Ne(i,u,c,s,r)}).catch(u=>re(u,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `