refactor: switch from chrono to jiff

This commit is contained in:
Robert Perce 2026-04-03 11:54:36 -05:00
parent 79a054ab40
commit 3ffdf8f0d7
12 changed files with 205 additions and 161 deletions

57
Cargo.lock generated
View file

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

View file

@ -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"] }

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.
});
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

View file

@ -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
})
}
}
}

View file

@ -1,4 +1,4 @@
use chrono::{DateTime, NaiveDate, Utc};
use jiff::{Timestamp, civil};
use sqlx::sqlite::SqlitePool;
use std::str::FromStr;
@ -18,7 +18,7 @@ struct RawContact {
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,
}
@ -31,8 +31,7 @@ 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,
}
}
@ -50,7 +49,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<NaiveDate>,
pub last_mention_date: Option<civil::Date>,
pub names: Vec<String>,
}
@ -71,7 +70,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::<civil::Date>().ok()),
}
}
}

View file

@ -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 })
}
}

View file

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

View file

@ -74,7 +74,7 @@ impl MentionHost<'_> {
}
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 mentionables = sqlx::query_as!(
@ -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();

View file

@ -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),
);
@ -213,7 +215,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)
@ -351,14 +353,15 @@ 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(),
)
};
@ -386,9 +389,6 @@ mod put {
.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 +488,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 +520,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 +559,6 @@ mod put {
.await?;
}
if regen_text_body {
sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = $2",

View file

@ -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";
}
@ -135,11 +135,11 @@ pub mod get {
.clone()
.into_iter()
.map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap();
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),
);
@ -152,30 +152,23 @@ pub mod get {
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 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 utc = TimeZone::UTC;
let todate = Timestamp::now().to_zoned(utc.clone()).date();
let duration = todate
.since(&fresh_date.to_zoned(utc).unwrap())
.unwrap()
.round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
let elapsed_str = if elapsed.is_empty() {
let elapsed_str = if duration.is_zero() {
"today".to_string()
} else {
elapsed.join(", ")
format!("{:#}", duration)
};
ContactFreshness {
@ -197,8 +190,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

View file

@ -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()

View file

@ -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?)
}
}