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();