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 std::str::FromStr;
@ -12,6 +12,9 @@ struct RawContact {
birthday: Option<String>,
manually_freshened_at: Option<String>,
lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
}
#[derive(Clone, Debug)]
@ -20,6 +23,9 @@ pub struct Contact {
pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<Timestamp>,
pub lives_with: String,
pub can_stale: bool,
pub active: bool,
pub periodicity: Span,
}
impl Into<Contact> for RawContact {
@ -33,6 +39,9 @@ impl Into<Contact> for RawContact {
.manually_freshened_at
.and_then(|str| str.parse::<Timestamp>().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<String>,
manually_freshened_at: Option<String>,
lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
last_mention_date: Option<String>,
names: Option<String>,
}
@ -49,7 +62,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<civil::Date>,
pub last_mention_date: Option<Date>,
pub names: Vec<String>,
}
@ -61,6 +74,9 @@ impl Into<HydratedContact> 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<HydratedContact> for RawHydratedContact {
.collect::<Vec<String>>(),
last_mention_date: self
.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> {
// 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<Vec<Self>, 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, (

View file

@ -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<Vec<String>>,
status: String,
periodicity: Option<String>,
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)

View file

@ -134,7 +134,11 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = 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();