Compare commits

..

1 commit

Author SHA1 Message Date
974bdcac34 spec: show birthdays until a week out 2026-04-05 12:05:13 -05:00
24 changed files with 269 additions and 511 deletions

2
.gitignore vendored
View file

@ -3,7 +3,7 @@ e2e/node_modules
e2e/playwright-report
e2e/test-results
/some_user.db
/dbs/*
/dbs
/hashed_static
/users.db
/.sqlx

57
Cargo.lock generated
View file

@ -1280,47 +1280,6 @@ 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"
@ -1468,7 +1427,6 @@ dependencies = [
"http",
"icalendar",
"itertools 0.14.0",
"jiff",
"listenfd",
"markdown",
"maud",
@ -1889,21 +1847,6 @@ 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,7 +19,6 @@ 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

@ -11,56 +11,17 @@ I think of when I see "CRM".
* Last-contact-time mapping
* Address as single field (plus code? lat/long? go crazy!)
* Free-text-entry field
* Desired contact periodicity
* Journal with Obsidian-like `[[link]]` syntax
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* ical server for birthday reminders
## Explore
My instance is at https://crm.rperce.net. Username "demo" and password "demo" let
you log into an ephemeral demo user if you want to poke around.
If you want an account, contact me directly or use the "self-hosting" instructions below.
## Planned features
* Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server
* Act as CardDAV server for other clients
* For each contact:
* Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar
* Relationship mapping
* Desired contact periodicity
* Additional arbitrary fields (no special handling)
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* "Named in journal but has no contact entry" detection
* Email birthday reminders over SMTP
---
## Development / self-hosting
1. Clone the repo.
2. Build for your system with `./Taskfile _cargo build --release`.
3. Deploy the binary from `./target/release/mascarpone` to wherever you want that's in PATH
(or use it from here if you want)
4. In the working directory that you want the server to save its databases in,
1. Create a user for yourself with `mascarpone set-password YOUR_USERNAME`. This will create a `users.db` file.
2. Run `mkdir dbs`.
3. Copy the `hashed_static` directory from the code repository.
5. Run `mascarpone serve [port]` from that working directory. The default port is 3000.
If you need to be able to bind to a host other than `0.0.0.0`, contact me directly.
### Example systemd service file
```
[Unit]
Description=Mascarpone CRM
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/local/mascarpone/
ExecStart=/usr/bin/mascarpone serve
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```

View file

@ -1,11 +1,11 @@
#!/usr/bin/env bash
playwright:local() {
bash e2e/Taskfile playwright:local "$@"
bash e2e/Taskfile playwright:local
}
playwright:ui() {
bash e2e/Taskfile playwright:ui "$@"
bash e2e/Taskfile playwright:ui
}
refresh_sqlx_db() {

View file

View file

@ -10,12 +10,20 @@ playwright:local() {
exec docker run \
--interactive --tty --rm --ipc=host --net=host \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
--env ASTRO_TELEMETRY_DISABLED=1 \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
bash -c "cd /e2e && env PROJECT_FILTER=firefox ./Taskfile _test $*"
}
playwright:ui() {
playwright:local --ui-host=0.0.0.0
xhost +local:docker
exec docker run \
--interactive --tty --rm --ipc=host --net=host\
--env DISPLAY="$DISPLAY" \
--volume /tmp/.X11-unix:/tmp/.X11-unix \
--volume "$SCRIPT_DIR":/e2e:rw --env BASE_URL="$BASE_URL" \
"mcr.microsoft.com/playwright:$(_playwright_version)" \
/bin/bash -c "cd /e2e && ./Taskfile _test --ui $*"
}
playwright:ci() {

View file

@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => {
test('manual-freshen date is editable', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
await expect(page.locator('input[name="manually_freshened_on"]')).toBeVisible();
await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible();
});
test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => {
@ -37,65 +37,23 @@ 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 inactive', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
test('allow marking as hidden', async ({ page }) => {
await page.getByLabel('status').selectOption('Inactive');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.locator('#contacts-sidebar').getByText("Test Testerson")).not.toBeVisible();
});
test('allow exempting from stale', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#freshness')).toContainText('Test Testersonnever');
await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByLabel('status').selectOption('Cannot go stale');
await page.getByRole('button', { name: /save/i }).click();
await page.goto('/');
await expect(page.locator('#freshness')).not.toContainText('Test Testersonnever');
});
test('stale list considers periodicity', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
const last_week = (() => {
let last_week = new Date();
last_week.setDate(last_week.getDate() - 7);
return last_week.toISOString().split("T")[0];
})();
await page.getByLabel('freshened').fill(last_week);
await page.getByRole('button', { name: /save/i }).click();
await expect(page.locator('#journal')).toBeVisible();
await expect(page.locator('#fields')).toContainText(`freshened${last_week}`);
await page.goto('/');
await expect(page.locator('#freshness')).toContainText(`Test Testerson${last_week}7d`);
await page.locator('#freshness').getByRole('link', { name: /testerson/i }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByLabel('minimum stale time').fill('2 weeks');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.locator('#journal')).toBeVisible();
await page.goto('/');
await expect(page.locator('#freshness')).not.toContainText(`Test Testerson`);
});
test('page title has contact primary name', async ({ page }) => {
// wait for page load to finish
await expect(page.locator('#journal')).toBeVisible();
expect(await page.title()).toContain("Test Testerson");
});
/*
test('bullet points in free text display well', async ({ page }) => {
});
test('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

@ -43,3 +43,26 @@ test('sidebar is sorted alphabetically', async ({ page }) => {
await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
});
test('always shows at least one birthday a week away', async ({ page }) => {
const monthday = d => d.toISOString().split("T")[0].replace(/^\d{4}/, '-');
const today = monthday(new Date());
const tomorrow = monthday((() => {
let date = new Date();
date.setDate(date.getDate() + 1);
return date;
})());
const inAMonth = monthday((() => {
let date = new Date();
date.setDate(date.getDate() + 28);
return date;
})());
await verifyCreateUser(page, { names: ['Alfa'], birthday: today });
await verifyCreateUser(page, { names: ['Beta'], birthday: tomorrow });
await verifyCreateUser(page, { names: ['Echo'], birthday: today });
await verifyCreateUser(page, { names: ['Golf'], birthday: tomorrow });
await verifyCreateUser(page, { names: ['Zulu'], birthday: inAMonth });
await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(5);
});

View file

@ -43,6 +43,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await page.getByRole('button', { name: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click();
console.log(await journal.innerHTML());
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name

View file

@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import './custom-expects';
import 'custom-expects';
// purposefully not using ??: we want to replace empty empty string with default
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';

View file

@ -7,8 +7,8 @@ insert into names(contact_id, sort, name) values
insert into groups(contact_id, name, slug) values
(0, 'ABC', 'abc');
insert into contacts(id, birthday, active) values
(1, 'April?', false);
insert into contacts(id, birthday) values
(1, 'April?');
insert into names(contact_id, sort, name) values
(1, 0, 'Bazel Bagend'),
(1, 1, 'Bazel');

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::{FromRow, Row};
use std::fmt::Display;
@ -29,31 +29,25 @@ impl Display for Birthday {
}
impl Birthday {
pub fn next_occurrence(&self) -> Option<civil::Date> {
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
match &self {
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()
.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
pub fn age(&self) -> Option<i32> {
pub fn age(&self) -> Option<u32> {
match &self {
Birthday::Text(_) => None,
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
})
}
Birthday::Date(date) => date
.to_date_naive()
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
}
}

View file

@ -1,4 +1,4 @@
use jiff::{Span, Timestamp, civil::Date};
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqlitePool;
use std::str::FromStr;
@ -12,20 +12,14 @@ 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<Timestamp>,
pub manually_freshened_at: Option<DateTime<Utc>>,
pub lives_with: String,
pub can_stale: bool,
pub active: bool,
pub periodicity: Span,
}
impl Into<Contact> for RawContact {
@ -37,11 +31,9 @@ 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| 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,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity.parse().unwrap(),
}
}
}
@ -51,10 +43,6 @@ 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>,
}
@ -62,7 +50,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<Date>,
pub last_mention_date: Option<NaiveDate>,
pub names: Vec<String>,
}
@ -74,9 +62,6 @@ 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
@ -86,7 +71,7 @@ impl Into<HydratedContact> for RawHydratedContact {
.collect::<Vec<String>>(),
last_mention_date: self
.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> {
// 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",
can_stale,
active,
periodicity,
(
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (
@ -154,15 +123,7 @@ 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",
can_stale,
active,
periodicity,
(
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (

View file

@ -1,4 +1,4 @@
use jiff::civil::Date;
use chrono::NaiveDate;
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: Date,
pub date: NaiveDate,
}
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: Date = date_str.parse().unwrap();
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
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 sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
use std::fmt::Display;
@ -6,39 +6,38 @@ use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct YearOptionalDate {
pub year: Option<i16>,
pub month: i8,
pub day: i8,
pub year: Option<i32>,
pub month: u32,
pub day: u32,
}
impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<Date> {
let now = Timestamp::now().to_zoned(TimeZone::UTC);
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
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)
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<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> {
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> {
pub fn to_date_naive(&self) -> Option<NaiveDate> {
if let Some(year) = self.year {
Date::new(year, self.month, self.day).ok()
NaiveDate::from_ymd_opt(year, self.month, self.day)
} else {
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();
if let Some(caps) = date_re.captures(str) {
let year_str = &caps[1];
let month = i8::from_str(&caps[2]).unwrap();
let day = i8::from_str(&caps[3]).unwrap();
let month = u32::from_str(&caps[2]).unwrap();
let day = u32::from_str(&caps[3]).unwrap();
let year = if year_str == "--" {
None
} else {
Some(i16::from_str(year_str).unwrap())
Some(i32::from_str(year_str).unwrap())
};
return Ok(Self { year, month, day });

View file

@ -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> {
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 jiff::{Timestamp, Unit, tz::TimeZone};
use chrono::DateTime;
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use slug::slugify;
use sqlx::QueryBuilder;
use sqlx::{QueryBuilder, Sqlite};
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, Switchboard, insert_mentions};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
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(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();
if span.is_zero() {
"today".to_string()
} else {
format!("in {:#}", span)
fn human_delta(delta: &chrono::TimeDelta) -> String {
if delta.num_days() == 0 {
return "today".to_string();
}
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 {
@ -88,9 +88,7 @@ mod get {
.await?;
let freshened = std::cmp::max(
contact
.manually_freshened_at
.map(|when| when.to_zoned(jiff::tz::TimeZone::UTC).date()),
contact.manually_freshened_at.map(|when| when.date_naive()),
entries.get(0).map(|entry| entry.date),
);
@ -132,14 +130,6 @@ 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 {
@ -220,16 +210,10 @@ mod get {
.await?;
let cid_url = format!("/contact/{}", contact.id);
let mfresh_on_str = contact
let mfresh_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| {
m.to_zoned(TimeZone::UTC).date().to_string()
});
let mfresh_at_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| m.to_zoned(TimeZone::UTC).to_string());
.map_or("".to_string(), |m| m.to_rfc3339());
let text_body: String =
sqlx::query!("select text_body from contacts where id = $1", contact_id)
@ -249,7 +233,7 @@ mod get {
div #error;
}
#fields x-data=(json!({ "status": contact.status() })){
div #fields {
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" {
@ -265,40 +249,16 @@ mod get {
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
}
}
label for="status" { "status" }
div {
select #status name="status" x-model=("status") {
option value="normal" { "Normal" }
option value="permanent" { "Cannot go stale" }
option value="inactive" { "Inactive" }
}
}
label x-show="status === 'normal'" for="periodicity" { "minimum stale time" }
div x-show="status === 'normal'"{
input name="periodicity" id="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()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
}
label for="manually_freshened_on" { "freshened" }
div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
input
type="hidden"
name="manually_freshened_at"
x-model="stamp";
input
type="date"
name="manually_freshened_on"
id="manually_freshened_on"
x-model="date"
x-bind:max="today()"
x-on:input="stamp = new Date(date).toISOString()";
input type="button" value="Mark fresh now" x-on:click="date = today(); stamp = new Date().toISOString()";
span .hint x-text="`max ${today()}`";
label { "freshened" }
div x-data=(json!({ "date": mfresh_str })) {
input type="hidden" name="manually_freshened_at" x-model="date";
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
}
label { "phone" }
#phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) {
@ -364,8 +324,6 @@ 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,
@ -393,22 +351,17 @@ mod put {
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
} else {
Some(
payload
.manually_freshened_at
.parse::<Timestamp>()
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.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() {
None
} else {
@ -421,24 +374,21 @@ mod put {
sqlx::query!(
"update contacts set
(
birthday, manually_freshened_at, lives_with, text_body,
active, can_stale, periodicity
) =
(?, ?, ?, ?, ?, ?, ?)
where id = ?",
(birthday, manually_freshened_at, lives_with, text_body) =
($1, $2, $3, $4)
where id = $5",
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
@ -538,18 +488,22 @@ 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?;
@ -570,7 +524,10 @@ 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?;
@ -609,6 +566,7 @@ 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 jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
use chrono::{Local, NaiveDate, TimeDelta};
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: civil::Date,
fresh_date: NaiveDate,
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: civil::Date,
next_birthday: civil::Date,
prev_birthday: NaiveDate,
next_birthday: NaiveDate,
}
fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>,
@ -57,25 +57,25 @@ fn birthdays_section(
div id="birthdays" {
h2 { "Birthdays" }
#birthday-sections {
.datelist {
.datelist #upcoming {
h3 { "upcoming" }
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
span {
(contact.next_birthday.strftime("%m-%d"))
(contact.next_birthday.format("%m-%d"))
}
}
}
.datelist {
.datelist #recent {
h3 { "recent" }
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display)
}
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."
}
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 {}
input type="submit" value="Add Entry";
}
@ -134,60 +134,57 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = contacts
.clone()
.into_iter()
.filter_map(|contact| {
if !contact.can_stale || !contact.active {
return None;
}
let zero = jiff::civil::Date::ZERO;
.map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap();
let fresh_date = std::cmp::max(
contact
.manually_freshened_at
.map(|ts| ts.to_zoned(TimeZone::UTC).date())
.map(|x| x.date_naive())
.unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero),
);
if fresh_date == zero {
Some(ContactFreshness {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: "never".to_string(),
elapsed_str: "".to_string(),
})
}
} else {
let utc = TimeZone::UTC;
let todate = Timestamp::now().to_zoned(utc.clone()).date();
let 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 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 elapsed_str = if elapsed.is_zero() {
let elapsed_str = if elapsed.is_empty() {
"today".to_string()
} else {
format!("{:#}", elapsed)
elapsed.join(", ")
};
Some(ContactFreshness {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: fresh_date.to_string(),
elapsed_str,
})
}
}
})
.collect();
@ -200,8 +197,8 @@ pub mod get {
Some(KnownBirthdayContact {
contact_id: contact.id,
display: contact.display_name(),
prev_birthday: date.prev_month_day_occurrence()?,
next_birthday: date.next_month_day_occurrence()?,
prev_birthday: date.prev_month_day_occurrence().unwrap(),
next_birthday: date.next_month_day_occurrence().unwrap(),
})
} 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).into(),
yo_date.month.try_into().unwrap(),
yo_date.day.try_into().unwrap(),
yo_date.year.unwrap_or(1900),
yo_date.month,
yo_date.day,
) {
calendar.push(
Event::new()

View file

@ -4,14 +4,14 @@ use axum::{
response::IntoResponse,
routing::{delete, patch, post},
};
use jiff::{Zoned, civil::Date};
use chrono::{Datelike, Local, NaiveDate};
use maud::Markup;
use regex::Regex;
use serde::Deserialize;
use crate::models::JournalEntry;
use crate::models::user::AuthSession;
use crate::switchboard::{MentionHostType, insert_mentions};
use crate::switchboard::{MentionHost, 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 = Zoned::now();
let now = Local::now().date_naive();
let date = if payload.date.is_empty() {
now.date()
now
} else {
let date_re =
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}
let year = caps
.name("year")
.map(|m| m.as_str().parse::<i16>().unwrap())
.map(|m| m.as_str().parse::<i32>().unwrap())
.unwrap_or(now.year());
let month = caps
.name("month")
.map(|m| m.as_str().parse::<i8>().unwrap())
.map(|m| m.as_str().parse::<u32>().unwrap())
.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)
.map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))?
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
"invalid date: failed NaiveDate construction",
))?
};
// not a macro query, we want to use JournalEntry's custom FromRow
@ -130,6 +131,7 @@ mod patch {
insert_mentions(&mentions, pool).await?;
}
Ok(new_entry.to_html(pool).await?)
}
}

View file

@ -25,7 +25,6 @@ struct ContactLink {
#[derive(Debug)]
pub struct Layout {
contact_links: Vec<ContactLink>,
inactive_contact_links: Vec<ContactLink>,
user: User,
}
@ -49,20 +48,6 @@ impl FromRequestParts<AppState> for Layout {
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = true
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
let inactive_contact_links = sqlx::query_as!(
ContactLink,
"select c.id as contact_id,
coalesce(n.name, '(unnamed)') as name
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = false
order by name asc",
)
.fetch_all(&state.db(&user).pool)
@ -70,19 +55,13 @@ impl FromRequestParts<AppState> for Layout {
Ok(Layout {
contact_links,
inactive_contact_links,
user,
})
}
}
impl Layout {
pub fn render(
&self,
title: impl AsRef<str>,
css: Option<Vec<&str>>,
content: Markup,
) -> Markup {
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
@ -122,23 +101,6 @@ impl Layout {
}
}
}
@if !self.inactive_contact_links.is_empty() {
li .inactive {
details {
summary { "Inactive contacts" }
ul {
@for link in &self.inactive_contact_links {
li {
a href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
}
}
}
}
}
}
}
main {

View file

@ -8,7 +8,7 @@ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abb
body {
width: 100%;
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
@ -35,7 +35,6 @@ section#content {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
@media only screen and (max-width: 650px) {
position: relative;
}
@ -45,8 +44,6 @@ section#content {
display: flex;
flex-direction: column;
padding-right: 1em;
height: 100%;
overflow-y: auto;
@media only screen and (max-width: 650px) {
position: absolute;
float: left;
@ -61,7 +58,7 @@ section#content {
}
}
& > ul {
ul {
flex: 1;
width: fit-content;
background-color: var(--main-bg-color);
@ -82,17 +79,12 @@ section#content {
border-bottom: none;
}
}
li.inactive {
font-size: small;
}
}
main {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
}
.icon {