feat: can_stale and periodicity

This commit is contained in:
Robert Perce 2026-04-03 13:47:23 -05:00
parent 3ffdf8f0d7
commit b361c1ab58
4 changed files with 108 additions and 17 deletions

View file

@ -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;

View file

@ -1,4 +1,4 @@
use jiff::{Timestamp, civil}; use jiff::{Span, Timestamp, civil::Date};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use std::str::FromStr; use std::str::FromStr;
@ -12,6 +12,9 @@ struct RawContact {
birthday: Option<String>, birthday: Option<String>,
manually_freshened_at: Option<String>, manually_freshened_at: Option<String>,
lives_with: String, lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -20,6 +23,9 @@ pub struct Contact {
pub birthday: Option<Birthday>, pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<Timestamp>, pub manually_freshened_at: Option<Timestamp>,
pub lives_with: String, pub lives_with: String,
pub can_stale: bool,
pub active: bool,
pub periodicity: Span,
} }
impl Into<Contact> for RawContact { impl Into<Contact> for RawContact {
@ -33,6 +39,9 @@ impl Into<Contact> for RawContact {
.manually_freshened_at .manually_freshened_at
.and_then(|str| str.parse::<Timestamp>().ok()), .and_then(|str| str.parse::<Timestamp>().ok()),
lives_with: self.lives_with, 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<String>, birthday: Option<String>,
manually_freshened_at: Option<String>, manually_freshened_at: Option<String>,
lives_with: String, lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
last_mention_date: Option<String>, last_mention_date: Option<String>,
names: Option<String>, names: Option<String>,
} }
@ -49,7 +62,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HydratedContact { pub struct HydratedContact {
pub contact: Contact, pub contact: Contact,
pub last_mention_date: Option<civil::Date>, pub last_mention_date: Option<Date>,
pub names: Vec<String>, pub names: Vec<String>,
} }
@ -61,6 +74,9 @@ impl Into<HydratedContact> for RawHydratedContact {
birthday: self.birthday, birthday: self.birthday,
manually_freshened_at: self.manually_freshened_at, manually_freshened_at: self.manually_freshened_at,
lives_with: self.lives_with, lives_with: self.lives_with,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity,
}), }),
names: self names: self
.names .names
@ -70,7 +86,7 @@ impl Into<HydratedContact> for RawHydratedContact {
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
last_mention_date: self last_mention_date: self
.last_mention_date .last_mention_date
.and_then(|str| str.parse::<civil::Date>().ok()), .and_then(|str| str.parse::<Date>().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<Self, AppError> { pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
// copy-paste the query from 'all', then add "where c.id = $2" to the last line // copy-paste the query from 'all', then add "where c.id = $2" to the last line
let raw = sqlx::query_as!( let raw = sqlx::query_as!(
RawHydratedContact, 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) select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id from names where contact_id = c.id
) as names, ( ) as names, (
@ -122,7 +154,15 @@ impl HydratedContact {
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> { pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
let contacts = sqlx::query_as!( let contacts = sqlx::query_as!(
RawHydratedContact, 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) select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id from names where contact_id = c.id
) as names, ( ) as names, (

View file

@ -132,6 +132,14 @@ mod get {
div { (name) } 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 { @if let Some(bday) = &contact.birthday {
label { "birthday" } label { "birthday" }
div { div {
@ -235,7 +243,7 @@ mod get {
div #error; div #error;
} }
div #fields { #fields x-data=(json!({ "status": contact.status() })){
label { @if contact.names.len() > 1 { "names" } @else { "name" }} label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in 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 = ''"; 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" } label { "birthday" }
div { div {
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize())); input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
@ -326,6 +347,8 @@ mod put {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PutContact { pub struct PutContact {
name: Option<Vec<String>>, name: Option<Vec<String>>,
status: String,
periodicity: Option<String>,
birthday: String, birthday: String,
manually_freshened_at: String, manually_freshened_at: String,
lives_with: 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() { let text_body = if payload.text_body.is_empty() {
None None
} else { } else {
@ -377,13 +404,19 @@ mod put {
sqlx::query!( sqlx::query!(
"update contacts set "update contacts set
(birthday, manually_freshened_at, lives_with, text_body) = (
($1, $2, $3, $4) birthday, manually_freshened_at, lives_with, text_body,
where id = $5", active, can_stale, periodicity
) =
(?, ?, ?, ?, ?, ?, ?)
where id = ?",
birthday, birthday,
manually_freshened_at, manually_freshened_at,
payload.lives_with, payload.lives_with,
text_body, text_body,
active,
can_stale,
periodicity,
contact_id contact_id
) )
.execute(pool) .execute(pool)

View file

@ -134,7 +134,11 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = contacts let mut freshens: Vec<ContactFreshness> = contacts
.clone() .clone()
.into_iter() .into_iter()
.map(|contact| { .filter_map(|contact| {
if !contact.can_stale || !contact.active {
return None;
}
let zero = jiff::civil::Date::ZERO; let zero = jiff::civil::Date::ZERO;
let fresh_date = std::cmp::max( let fresh_date = std::cmp::max(
contact contact
@ -144,17 +148,17 @@ pub mod get {
contact.last_mention_date.unwrap_or(zero), contact.last_mention_date.unwrap_or(zero),
); );
if fresh_date == zero { if fresh_date == zero {
ContactFreshness { Some(ContactFreshness {
contact_id: contact.id, contact_id: contact.id,
display: contact.display_name(), display: contact.display_name(),
fresh_date, fresh_date,
fresh_str: "never".to_string(), fresh_str: "never".to_string(),
elapsed_str: "".to_string(), elapsed_str: "".to_string(),
} })
} else { } else {
let utc = TimeZone::UTC; let utc = TimeZone::UTC;
let todate = Timestamp::now().to_zoned(utc.clone()).date(); let todate = Timestamp::now().to_zoned(utc.clone()).date();
let duration = todate let elapsed = todate
.since(&fresh_date.to_zoned(utc).unwrap()) .since(&fresh_date.to_zoned(utc).unwrap())
.unwrap() .unwrap()
.round( .round(
@ -165,19 +169,25 @@ pub mod get {
) )
.unwrap(); .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() "today".to_string()
} else { } else {
format!("{:#}", duration) format!("{:#}", elapsed)
}; };
ContactFreshness { Some(ContactFreshness {
contact_id: contact.id, contact_id: contact.id,
display: contact.display_name(), display: contact.display_name(),
fresh_date, fresh_date,
fresh_str: fresh_date.to_string(), fresh_str: fresh_date.to_string(),
elapsed_str, elapsed_str,
} })
} }
}) })
.collect(); .collect();