feat: can_stale and periodicity
This commit is contained in:
parent
3ffdf8f0d7
commit
b361c1ab58
4 changed files with 108 additions and 17 deletions
8
migrations/each_user/0013_contact-periodicity.sql
Normal file
8
migrations/each_user/0013_contact-periodicity.sql
Normal 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;
|
||||||
|
|
@ -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, (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue