Compare commits
2 commits
79a054ab40
...
b361c1ab58
| Author | SHA1 | Date | |
|---|---|---|---|
| b361c1ab58 | |||
| 3ffdf8f0d7 |
13 changed files with 306 additions and 171 deletions
57
Cargo.lock
generated
57
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ test.skip("groups wrap nicely", async ({ page }) => {
|
|||
// 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
|
||||
|
|
|
|||
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 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<chrono::NaiveDate> {
|
||||
pub fn next_occurrence(&self) -> Option<civil::Date> {
|
||||
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<chrono::TimeDelta> {
|
||||
pub fn until_next(&self) -> Option<jiff::Span> {
|
||||
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<u32> {
|
||||
pub fn age(&self) -> Option<i32> {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use jiff::{Span, Timestamp, civil::Date};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::str::FromStr;
|
||||
|
||||
|
|
@ -12,14 +12,20 @@ struct RawContact {
|
|||
birthday: Option<String>,
|
||||
manually_freshened_at: Option<String>,
|
||||
lives_with: String,
|
||||
can_stale: bool,
|
||||
active: bool,
|
||||
periodicity: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Contact {
|
||||
pub id: DbId,
|
||||
pub birthday: Option<Birthday>,
|
||||
pub manually_freshened_at: Option<DateTime<Utc>>,
|
||||
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 {
|
||||
|
|
@ -31,9 +37,11 @@ impl Into<Contact> 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::<Timestamp>().ok()),
|
||||
lives_with: self.lives_with,
|
||||
can_stale: self.can_stale,
|
||||
active: self.active,
|
||||
periodicity: self.periodicity.parse().unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,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>,
|
||||
}
|
||||
|
|
@ -50,7 +62,7 @@ struct RawHydratedContact {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct HydratedContact {
|
||||
pub contact: Contact,
|
||||
pub last_mention_date: Option<NaiveDate>,
|
||||
pub last_mention_date: Option<Date>,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -62,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
|
||||
|
|
@ -71,7 +86,7 @@ impl Into<HydratedContact> for RawHydratedContact {
|
|||
.collect::<Vec<String>>(),
|
||||
last_mention_date: self
|
||||
.last_mention_date
|
||||
.and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
|
||||
.and_then(|str| str.parse::<Date>().ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,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, (
|
||||
|
|
@ -123,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, (
|
||||
|
|
|
|||
|
|
@ -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<MentionHost<'a>> 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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i32>,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
pub year: Option<i16>,
|
||||
pub month: i8,
|
||||
pub day: i8,
|
||||
}
|
||||
|
||||
impl YearOptionalDate {
|
||||
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||
let now = Local::now();
|
||||
pub fn prev_month_day_occurrence(&self) -> Option<Date> {
|
||||
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<NaiveDate> {
|
||||
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<NaiveDate> {
|
||||
pub fn next_month_day_occurrence(&self) -> Option<Date> {
|
||||
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<Date> {
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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<MentionHost<'a>>) -> HashSet<Mention> {
|
||||
let host: MentionHost = host.into();
|
||||
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
||||
|
|
|
|||
|
|
@ -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<AppState> {
|
|||
.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),
|
||||
);
|
||||
|
||||
|
|
@ -130,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 {
|
||||
|
|
@ -213,7 +223,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)
|
||||
|
|
@ -233,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" {
|
||||
|
|
@ -249,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()));
|
||||
|
|
@ -324,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,
|
||||
|
|
@ -351,17 +376,22 @@ mod put {
|
|||
Some(payload.birthday)
|
||||
};
|
||||
|
||||
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
|
||||
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
|
||||
payload
|
||||
.manually_freshened_at
|
||||
.parse::<Timestamp>()
|
||||
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
|
||||
.to_utc()
|
||||
.to_rfc3339(),
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
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 {
|
||||
|
|
@ -374,21 +404,24 @@ 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)
|
||||
.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,22 +521,18 @@ mod put {
|
|||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
if old_names != new_names {
|
||||
sqlx::query!(
|
||||
"delete from names where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
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)
|
||||
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()
|
||||
})
|
||||
.build()
|
||||
.persistent(false)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
|
@ -524,10 +553,7 @@ mod put {
|
|||
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
||||
|
||||
if new_groups != old_groups {
|
||||
sqlx::query!(
|
||||
"delete from groups where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
sqlx::query!("delete from groups where contact_id = $1", contact_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
|
@ -566,7 +592,6 @@ mod put {
|
|||
.await?;
|
||||
}
|
||||
|
||||
|
||||
if regen_text_body {
|
||||
sqlx::query!(
|
||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
||||
|
|
|
|||
|
|
@ -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<ContactFreshness>) -> Result<Markup, AppErro
|
|||
struct KnownBirthdayContact {
|
||||
contact_id: i64,
|
||||
display: String,
|
||||
prev_birthday: NaiveDate,
|
||||
next_birthday: NaiveDate,
|
||||
prev_birthday: civil::Date,
|
||||
next_birthday: civil::Date,
|
||||
}
|
||||
fn birthdays_section(
|
||||
prev_birthdays: &Vec<KnownBirthdayContact>,
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -134,57 +134,60 @@ pub mod get {
|
|||
let mut freshens: Vec<ContactFreshness> = contacts
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|contact| {
|
||||
let zero = NaiveDate::from_epoch_days(0).unwrap();
|
||||
.filter_map(|contact| {
|
||||
if !contact.can_stale || !contact.active {
|
||||
return None;
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
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 mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
|
||||
let mut elapsed: Vec<String> = 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 utc = TimeZone::UTC;
|
||||
let todate = Timestamp::now().to_zoned(utc.clone()).date();
|
||||
let elapsed = todate
|
||||
.since(&fresh_date.to_zoned(utc).unwrap())
|
||||
.unwrap()
|
||||
.round(
|
||||
jiff::SpanRound::new()
|
||||
.largest(Unit::Year)
|
||||
.smallest(Unit::Day)
|
||||
.relative(todate),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Some(cmp) = elapsed.compare((contact.periodicity, todate)).ok() {
|
||||
if cmp == std::cmp::Ordering::Less {
|
||||
return None;
|
||||
}
|
||||
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 elapsed_str = if elapsed.is_empty() {
|
||||
let elapsed_str = if elapsed.is_zero() {
|
||||
"today".to_string()
|
||||
} else {
|
||||
elapsed.join(", ")
|
||||
format!("{:#}", elapsed)
|
||||
};
|
||||
|
||||
ContactFreshness {
|
||||
Some(ContactFreshness {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
fresh_date,
|
||||
fresh_str: fresh_date.to_string(),
|
||||
elapsed_str,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -197,8 +200,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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<AppState> {
|
||||
|
|
@ -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"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[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::<i32>().unwrap())
|
||||
.map(|m| m.as_str().parse::<i16>().unwrap())
|
||||
.unwrap_or(now.year());
|
||||
let month = caps
|
||||
.name("month")
|
||||
.map(|m| m.as_str().parse::<u32>().unwrap())
|
||||
.map(|m| m.as_str().parse::<i8>().unwrap())
|
||||
.unwrap_or(now.month());
|
||||
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
|
||||
let day = caps.name("day").unwrap().as_str().parse::<i8>().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?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue