Compare commits

..

No commits in common. "b361c1ab5815bf612fa7fdd3e301145ef26959ca" and "79a054ab40387ea3d093845e67aba738bbd5e4a7" have entirely different histories.

13 changed files with 172 additions and 307 deletions

57
Cargo.lock generated
View file

@ -1280,47 +1280,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.81" version = "0.3.81"
@ -1468,7 +1427,6 @@ dependencies = [
"http", "http",
"icalendar", "icalendar",
"itertools 0.14.0", "itertools 0.14.0",
"jiff",
"listenfd", "listenfd",
"markdown", "markdown",
"maud", "maud",
@ -1889,21 +1847,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.3" version = "0.1.3"

View file

@ -19,7 +19,6 @@ clap = { version = "4.5.53", features = ["derive"] }
http = "1.3.1" http = "1.3.1"
icalendar = "0.17.5" icalendar = "0.17.5"
itertools = "0.14.0" itertools = "0.14.0"
jiff = { version = "0.2.23", features = ["serde"] }
listenfd = "1.0.2" listenfd = "1.0.2"
markdown = "1.0.0" markdown = "1.0.0"
maud = { version = "0.27.0", features = ["axum"] } maud = { version = "0.27.0", features = ["axum"] }

View file

@ -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. // that the text is all on one line. Manual inspection looks good at time of writing.
}); });
test('allow marking as inactive', async ({ page }) => { test('allow marking as hidden', async ({ page }) => {
}); });
@ -45,20 +45,15 @@ 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 }) => { 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: contact list scrolls in screen, not off screen
home: clicking off contact list closes it home: clicking off contact list closes it
home: contact list is sorted ignoring case home: contact list is sorted ignoring case

View file

@ -1,8 +0,0 @@
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, Unit, Zoned, civil, tz::TimeZone}; use chrono::Local;
use sqlx::sqlite::SqliteRow; use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row}; use sqlx::{FromRow, Row};
use std::fmt::Display; use std::fmt::Display;
@ -29,31 +29,25 @@ impl Display for Birthday {
} }
impl Birthday { impl Birthday {
pub fn next_occurrence(&self) -> Option<civil::Date> { pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
match &self { match &self {
Birthday::Text(_) => None, Birthday::Text(_) => None,
Birthday::Date(date) => date.next_month_day_occurrence(), Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
} }
} }
pub fn until_next(&self) -> Option<jiff::Span> { pub fn until_next(&self) -> Option<chrono::TimeDelta> {
self.next_occurrence() self.next_occurrence()
.map(|when| when.since(Zoned::now().date()).ok())? .map(|when| when.signed_duration_since(Local::now().date_naive()))
} }
/// None if this is a text birthday or doesn't have a year /// None if this is a text birthday or doesn't have a year
pub fn age(&self) -> Option<i32> { pub fn age(&self) -> Option<u32> {
match &self { match &self {
Birthday::Text(_) => None, Birthday::Text(_) => None,
Birthday::Date(date) => { Birthday::Date(date) => date
let now = Timestamp::now().to_zoned(TimeZone::UTC); .to_date_naive()
date.to_civil_date().map(|birthdate| { .map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
now.since(&birthdate.to_zoned(TimeZone::UTC).unwrap())
.unwrap()
.total((Unit::Year, &now))
.unwrap() as i32
})
}
} }
} }

View file

@ -1,4 +1,4 @@
use jiff::{Span, Timestamp, civil::Date}; use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use std::str::FromStr; use std::str::FromStr;
@ -12,20 +12,14 @@ 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)]
pub struct Contact { pub struct Contact {
pub id: DbId, pub id: DbId,
pub birthday: Option<Birthday>, pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<Timestamp>, pub manually_freshened_at: Option<DateTime<Utc>>,
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 {
@ -37,11 +31,9 @@ impl Into<Contact> for RawContact {
.and_then(|s| Birthday::from_str(s.as_ref()).ok()), .and_then(|s| Birthday::from_str(s.as_ref()).ok()),
manually_freshened_at: self manually_freshened_at: self
.manually_freshened_at .manually_freshened_at
.and_then(|str| str.parse::<Timestamp>().ok()), .and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok())
.map(|d| d.to_utc()),
lives_with: self.lives_with, lives_with: self.lives_with,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity.parse().unwrap(),
} }
} }
} }
@ -51,10 +43,6 @@ 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>,
} }
@ -62,7 +50,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<Date>, pub last_mention_date: Option<NaiveDate>,
pub names: Vec<String>, pub names: Vec<String>,
} }
@ -74,9 +62,6 @@ 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
@ -86,7 +71,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::<Date>().ok()), .and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
} }
} }
} }
@ -107,27 +92,11 @@ 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 r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
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, (
@ -154,15 +123,7 @@ 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 r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
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

@ -1,4 +1,4 @@
use jiff::civil::Date; use chrono::NaiveDate;
use maud::{Markup, html}; use maud::{Markup, html};
use serde_json::json; use serde_json::json;
use sqlx::sqlite::{SqlitePool, SqliteRow}; use sqlx::sqlite::{SqlitePool, SqliteRow};
@ -12,7 +12,7 @@ use crate::switchboard::{MentionHost, MentionHostType};
pub struct JournalEntry { pub struct JournalEntry {
pub id: DbId, pub id: DbId,
pub value: String, pub value: String,
pub date: Date, pub date: NaiveDate,
} }
impl<'a> Into<MentionHost<'a>> for &'a JournalEntry { 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 id: DbId = row.try_get("id")?;
let value: String = row.try_get("value")?; let value: String = row.try_get("value")?;
let date_str: &str = row.try_get("date")?; let date_str: &str = row.try_get("date")?;
let date: Date = date_str.parse().unwrap(); let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
Ok(Self { id, value, date }) Ok(Self { id, value, date })
} }
} }

View file

@ -1,4 +1,4 @@
use jiff::{Timestamp, civil::Date, tz::TimeZone}; use chrono::{Datelike, Local, NaiveDate};
use regex::Regex; use regex::Regex;
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull}; use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
use std::fmt::Display; use std::fmt::Display;
@ -6,39 +6,38 @@ use std::str::FromStr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct YearOptionalDate { pub struct YearOptionalDate {
pub year: Option<i16>, pub year: Option<i32>,
pub month: i8, pub month: u32,
pub day: i8, pub day: u32,
} }
impl YearOptionalDate { impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<Date> { pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Timestamp::now().to_zoned(TimeZone::UTC); let now = Local::now();
let year = now.year(); let year = now.year();
Date::new(year, self.month, self.day).ok().and_then(|date| { let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if date >= now.date() { if let Some(real_date) = date {
Date::new(year - 1, self.month, self.day).ok() if real_date >= now.date_naive() {
} else { date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
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 next_month_day_occurrence(&self) -> Option<Date> { pub fn to_date_naive(&self) -> Option<NaiveDate> {
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 { if let Some(year) = self.year {
Date::new(year, self.month, self.day).ok() NaiveDate::from_ymd_opt(year, self.month, self.day)
} else { } else {
None None
} }
@ -69,12 +68,12 @@ impl FromStr for YearOptionalDate {
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap(); let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
if let Some(caps) = date_re.captures(str) { if let Some(caps) = date_re.captures(str) {
let year_str = &caps[1]; let year_str = &caps[1];
let month = i8::from_str(&caps[2]).unwrap(); let month = u32::from_str(&caps[2]).unwrap();
let day = i8::from_str(&caps[3]).unwrap(); let day = u32::from_str(&caps[3]).unwrap();
let year = if year_str == "--" { let year = if year_str == "--" {
None None
} else { } else {
Some(i16::from_str(year_str).unwrap()) Some(i32::from_str(year_str).unwrap())
}; };
return Ok(Self { year, month, day }); return Ok(Self { year, month, day });

View file

@ -74,7 +74,7 @@ impl MentionHost<'_> {
} }
impl Switchboard { impl Switchboard {
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String, String>, AppError> { pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
let mut trie = radix_trie::Trie::new(); let mut trie = radix_trie::Trie::new();
let mentionables = sqlx::query_as!( let mentionables = sqlx::query_as!(
@ -109,6 +109,14 @@ 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> { pub fn extract_mentions<'a>(&self, host: impl Into<MentionHost<'a>>) -> HashSet<Mention> {
let host: MentionHost = host.into(); let host: MentionHost = host.into();
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();

View file

@ -7,19 +7,19 @@ use axum::{
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use cache_bust::asset; use cache_bust::asset;
use jiff::{Timestamp, Unit, tz::TimeZone}; use chrono::DateTime;
use maud::{Markup, html}; use maud::{Markup, html};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use slug::slugify; use slug::slugify;
use sqlx::QueryBuilder; use sqlx::{QueryBuilder, Sqlite};
use super::Layout; use super::Layout;
use super::home::journal_section; use super::home::journal_section;
use crate::db::DbId; use crate::db::DbId;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry}; use crate::models::{HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions}; use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
use crate::{AppError, AppState}; use crate::{AppError, AppState};
pub mod fields; pub mod fields;
@ -40,22 +40,22 @@ pub fn router() -> Router<AppState> {
.route("/contact/{contact_id}/edit", get(self::get::contact_edit)) .route("/contact/{contact_id}/edit", get(self::get::contact_edit))
} }
fn human_delta(span: &jiff::Span) -> String { fn human_delta(delta: &chrono::TimeDelta) -> String {
let todate = Timestamp::now().to_zoned(TimeZone::UTC).date(); if delta.num_days() == 0 {
let span = span return "today".to_string();
.round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
if span.is_zero() {
"today".to_string()
} else {
format!("in {:#}", span)
} }
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 rem.num_days().abs() > 0 {
result.push_str(&format!("{}d ", rem.num_days()));
}
result.trim().to_string()
} }
mod get { mod get {
@ -88,9 +88,7 @@ mod get {
.await?; .await?;
let freshened = std::cmp::max( let freshened = std::cmp::max(
contact contact.manually_freshened_at.map(|when| when.date_naive()),
.manually_freshened_at
.map(|when| when.to_zoned(jiff::tz::TimeZone::UTC).date()),
entries.get(0).map(|entry| entry.date), entries.get(0).map(|entry| entry.date),
); );
@ -132,14 +130,6 @@ 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 {
@ -223,7 +213,7 @@ mod get {
let mfresh_str = contact let mfresh_str = contact
.manually_freshened_at .manually_freshened_at
.clone() .clone()
.map_or("".to_string(), |m| m.to_string()); .map_or("".to_string(), |m| m.to_rfc3339());
let text_body: String = let text_body: String =
sqlx::query!("select text_body from contacts where id = $1", contact_id) sqlx::query!("select text_body from contacts where id = $1", contact_id)
@ -243,7 +233,7 @@ mod get {
div #error; div #error;
} }
#fields x-data=(json!({ "status": contact.status() })){ div #fields {
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" {
@ -259,19 +249,6 @@ 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()));
@ -347,8 +324,6 @@ 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,
@ -376,22 +351,17 @@ mod put {
Some(payload.birthday) Some(payload.birthday)
}; };
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() { let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
None None
} else { } else {
Some( Some(
payload DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.manually_freshened_at
.parse::<Timestamp>()
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))? .map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_string(), .to_utc()
.to_rfc3339(),
) )
}; };
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 {
@ -404,24 +374,21 @@ mod put {
sqlx::query!( sqlx::query!(
"update contacts set "update contacts set
( (birthday, manually_freshened_at, lives_with, text_body) =
birthday, manually_freshened_at, lives_with, text_body, ($1, $2, $3, $4)
active, can_stale, periodicity where id = $5",
) =
(?, ?, ?, ?, ?, ?, ?)
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)
.await?; .await?;
if old_contact.text_body != text_body {
}
// these blocks are not in functions because payload gets progressively // 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 // partially moved as we handle each field and i don't want to deal with it
@ -521,18 +488,22 @@ mod put {
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect(); let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
if old_names != new_names { 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) .execute(pool)
.await?; .await?;
if !new_names.is_empty() { if !new_names.is_empty() {
QueryBuilder::new("insert into names (contact_id, sort, name) ") QueryBuilder::new(
.push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { "insert into names (contact_id, sort, name) "
b.push_bind(contact_id) ).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
b
.push_bind(contact_id)
.push_bind(DbId::try_from(sort).unwrap()) .push_bind(DbId::try_from(sort).unwrap())
.push_bind(name); .push_bind(name);
}) }).build()
.build()
.persistent(false) .persistent(false)
.execute(pool) .execute(pool)
.await?; .await?;
@ -553,7 +524,10 @@ mod put {
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect(); let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
if new_groups != old_groups { 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) .execute(pool)
.await?; .await?;
@ -592,6 +566,7 @@ mod put {
.await?; .await?;
} }
if regen_text_body { if regen_text_body {
sqlx::query!( sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = $2", "delete from mentions where entity_id = $1 and entity_type = $2",

View file

@ -1,7 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use cache_bust::asset; use cache_bust::asset;
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone}; use chrono::{Local, NaiveDate, TimeDelta};
use maud::{Markup, html}; use maud::{Markup, html};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
@ -15,7 +15,7 @@ use crate::{AppError, AppState};
struct ContactFreshness { struct ContactFreshness {
contact_id: DbId, contact_id: DbId,
display: String, display: String,
fresh_date: civil::Date, fresh_date: NaiveDate,
fresh_str: String, fresh_str: String,
elapsed_str: String, elapsed_str: String,
} }
@ -46,8 +46,8 @@ fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppErro
struct KnownBirthdayContact { struct KnownBirthdayContact {
contact_id: i64, contact_id: i64,
display: String, display: String,
prev_birthday: civil::Date, prev_birthday: NaiveDate,
next_birthday: civil::Date, next_birthday: NaiveDate,
} }
fn birthdays_section( fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>, prev_birthdays: &Vec<KnownBirthdayContact>,
@ -64,7 +64,7 @@ fn birthdays_section(
(contact.display) (contact.display)
} }
span { span {
(contact.next_birthday.strftime("%m-%d")) (contact.next_birthday.format("%m-%d"))
} }
} }
} }
@ -75,7 +75,7 @@ fn birthdays_section(
(contact.display) (contact.display)
} }
span { span {
(contact.prev_birthday.strftime("%m-%d")) (contact.prev_birthday.format("%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." 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()" { 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=(Zoned::now().date().to_string()); input name="date" placeholder=(Local::now().date_naive().to_string());
textarea name="value" placeholder="New entry..." autofocus {} textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry"; input type="submit" value="Add Entry";
} }
@ -134,60 +134,57 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = contacts let mut freshens: Vec<ContactFreshness> = contacts
.clone() .clone()
.into_iter() .into_iter()
.filter_map(|contact| { .map(|contact| {
if !contact.can_stale || !contact.active { let zero = NaiveDate::from_epoch_days(0).unwrap();
return None;
}
let zero = jiff::civil::Date::ZERO;
let fresh_date = std::cmp::max( let fresh_date = std::cmp::max(
contact contact
.manually_freshened_at .manually_freshened_at
.map(|ts| ts.to_zoned(TimeZone::UTC).date()) .map(|x| x.date_naive())
.unwrap_or(zero), .unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero), contact.last_mention_date.unwrap_or(zero),
); );
if fresh_date == zero { if fresh_date == zero {
Some(ContactFreshness { 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 mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
let todate = Timestamp::now().to_zoned(utc.clone()).date(); let mut elapsed: Vec<String> = Vec::new();
let elapsed = todate let y = duration.num_weeks() / 52;
.since(&fresh_date.to_zoned(utc).unwrap()) let count = |n: i64, noun: &str| {
.unwrap() format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
.round( };
jiff::SpanRound::new() if y > 0 {
.largest(Unit::Year) elapsed.push(count(y, "year"));
.smallest(Unit::Day) duration -= TimeDelta::weeks(y * 52);
.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_zero() { let elapsed_str = if elapsed.is_empty() {
"today".to_string() "today".to_string()
} else { } else {
format!("{:#}", elapsed) elapsed.join(", ")
}; };
Some(ContactFreshness { 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();
@ -200,8 +197,8 @@ pub mod get {
Some(KnownBirthdayContact { Some(KnownBirthdayContact {
contact_id: contact.id, contact_id: contact.id,
display: contact.display_name(), display: contact.display_name(),
prev_birthday: date.prev_month_day_occurrence()?, prev_birthday: date.prev_month_day_occurrence().unwrap(),
next_birthday: date.next_month_day_occurrence()?, next_birthday: date.next_month_day_occurrence().unwrap(),
}) })
} else { } else {
None None

View file

@ -61,9 +61,9 @@ mod get {
for contact in &contacts { for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday { if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt( if let Some(date) = NaiveDate::from_ymd_opt(
yo_date.year.unwrap_or(1900).into(), yo_date.year.unwrap_or(1900),
yo_date.month.try_into().unwrap(), yo_date.month,
yo_date.day.try_into().unwrap(), yo_date.day,
) { ) {
calendar.push( calendar.push(
Event::new() Event::new()

View file

@ -4,14 +4,14 @@ use axum::{
response::IntoResponse, response::IntoResponse,
routing::{delete, patch, post}, routing::{delete, patch, post},
}; };
use jiff::{Zoned, civil::Date}; use chrono::{Datelike, Local, NaiveDate};
use maud::Markup; use maud::Markup;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use crate::models::JournalEntry; use crate::models::JournalEntry;
use crate::models::user::AuthSession; use crate::models::user::AuthSession;
use crate::switchboard::{MentionHostType, insert_mentions}; use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
use crate::{AppError, AppState, DbId}; use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -39,10 +39,10 @@ mod post {
let pool = &state.db(&user).pool; let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user); let sw_lock = state.switchboard(&user);
let now = Zoned::now(); let now = Local::now().date_naive();
let date = if payload.date.is_empty() { let date = if payload.date.is_empty() {
now.date() now
} else { } else {
let date_re = let date_re =
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$") Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
@ -54,16 +54,17 @@ mod post {
// unwrapping these parses is safe since it's matching [0-9]{2,4} // unwrapping these parses is safe since it's matching [0-9]{2,4}
let year = caps let year = caps
.name("year") .name("year")
.map(|m| m.as_str().parse::<i16>().unwrap()) .map(|m| m.as_str().parse::<i32>().unwrap())
.unwrap_or(now.year()); .unwrap_or(now.year());
let month = caps let month = caps
.name("month") .name("month")
.map(|m| m.as_str().parse::<i8>().unwrap()) .map(|m| m.as_str().parse::<u32>().unwrap())
.unwrap_or(now.month()); .unwrap_or(now.month());
let day = caps.name("day").unwrap().as_str().parse::<i8>().unwrap(); let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
Date::new(year, month, day) NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
.map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))? "invalid date: failed NaiveDate construction",
))?
}; };
// not a macro query, we want to use JournalEntry's custom FromRow // not a macro query, we want to use JournalEntry's custom FromRow
@ -130,6 +131,7 @@ mod patch {
insert_mentions(&mentions, pool).await?; insert_mentions(&mentions, pool).await?;
} }
Ok(new_entry.to_html(pool).await?) Ok(new_entry.to_html(pool).await?)
} }
} }