Compare commits

..

6 commits

24 changed files with 510 additions and 268 deletions

2
.gitignore vendored
View file

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

57
Cargo.lock generated
View file

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

@ -11,17 +11,56 @@ I think of when I see "CRM".
* Last-contact-time mapping * Last-contact-time mapping
* Address as single field (plus code? lat/long? go crazy!) * Address as single field (plus code? lat/long? go crazy!)
* Free-text-entry field * Free-text-entry field
* Desired contact periodicity
* Journal with Obsidian-like `[[link]]` syntax * Journal with Obsidian-like `[[link]]` syntax
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* ical server for birthday reminders * 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 ## Planned features
* Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server * Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server
* Act as CardDAV server for other clients * Act as CardDAV server for other clients
* For each contact: * For each contact:
* Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar * Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar
* Relationship mapping * Relationship mapping
* Desired contact periodicity
* Additional arbitrary fields (no special handling) * 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 * "Named in journal but has no contact entry" detection
* Email birthday reminders over SMTP * 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 #!/usr/bin/env bash
playwright:local() { playwright:local() {
bash e2e/Taskfile playwright:local bash e2e/Taskfile playwright:local "$@"
} }
playwright:ui() { playwright:ui() {
bash e2e/Taskfile playwright:ui bash e2e/Taskfile playwright:ui "$@"
} }
refresh_sqlx_db() { refresh_sqlx_db() {

0
dbs/.gitkeep Normal file
View file

View file

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

View file

@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => {
test('manual-freshen date is editable', async ({ page }) => { test('manual-freshen date is editable', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); await expect(page.locator('input[name="manually_freshened_on"]')).toBeVisible();
}); });
test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => { test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => {
@ -37,23 +37,65 @@ 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 hidden', async ({ page }) => { 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');
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 }) => { 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('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: 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

@ -43,26 +43,3 @@ test('sidebar is sorted alphabetically', async ({ page }) => {
await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/); 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,7 +43,6 @@ 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: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
console.log(await journal.innerHTML());
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name // delete an existing name

View file

@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import 'custom-expects'; import './custom-expects';
// purposefully not using ??: we want to replace empty empty string with default // purposefully not using ??: we want to replace empty empty string with default
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; 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 insert into groups(contact_id, name, slug) values
(0, 'ABC', 'abc'); (0, 'ABC', 'abc');
insert into contacts(id, birthday) values insert into contacts(id, birthday, active) values
(1, 'April?'); (1, 'April?', false);
insert into names(contact_id, sort, name) values insert into names(contact_id, sort, name) values
(1, 0, 'Bazel Bagend'), (1, 0, 'Bazel Bagend'),
(1, 1, 'Bazel'); (1, 1, 'Bazel');

View 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;

View file

@ -1,4 +1,4 @@
use chrono::Local; use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
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,25 +29,31 @@ impl Display for Birthday {
} }
impl Birthday { impl Birthday {
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> { pub fn next_occurrence(&self) -> Option<civil::Date> {
match &self { match &self {
Birthday::Text(_) => None, 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() 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 /// 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 { match &self {
Birthday::Text(_) => None, Birthday::Text(_) => None,
Birthday::Date(date) => date Birthday::Date(date) => {
.to_date_naive() let now = Timestamp::now().to_zoned(TimeZone::UTC);
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?, 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::{Span, Timestamp, civil::Date};
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use std::str::FromStr; use std::str::FromStr;
@ -12,14 +12,20 @@ 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<DateTime<Utc>>, pub manually_freshened_at: Option<Timestamp>,
pub lives_with: String, pub lives_with: String,
pub can_stale: bool,
pub active: bool,
pub periodicity: Span,
} }
impl Into<Contact> for RawContact { impl Into<Contact> for RawContact {
@ -31,9 +37,11 @@ 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| DateTime::parse_from_str(str.as_ref(), "%+").ok()) .and_then(|str| str.parse::<Timestamp>().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(),
} }
} }
} }
@ -43,6 +51,10 @@ struct RawHydratedContact {
birthday: Option<String>, birthday: Option<String>,
manually_freshened_at: Option<String>, manually_freshened_at: Option<String>,
lives_with: String, lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
last_mention_date: Option<String>, last_mention_date: Option<String>,
names: Option<String>, names: Option<String>,
} }
@ -50,7 +62,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HydratedContact { pub struct HydratedContact {
pub contact: Contact, pub contact: Contact,
pub last_mention_date: Option<NaiveDate>, pub last_mention_date: Option<Date>,
pub names: Vec<String>, pub names: Vec<String>,
} }
@ -62,6 +74,9 @@ impl Into<HydratedContact> for RawHydratedContact {
birthday: self.birthday, birthday: self.birthday,
manually_freshened_at: self.manually_freshened_at, manually_freshened_at: self.manually_freshened_at,
lives_with: self.lives_with, lives_with: self.lives_with,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity,
}), }),
names: self names: self
.names .names
@ -71,7 +86,7 @@ impl Into<HydratedContact> for RawHydratedContact {
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
last_mention_date: self last_mention_date: self
.last_mention_date .last_mention_date
.and_then(|str| 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> { pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
// copy-paste the query from 'all', then add "where c.id = $2" to the last line // copy-paste the query from 'all', then add "where c.id = $2" to the last line
let raw = sqlx::query_as!( let raw = sqlx::query_as!(
RawHydratedContact, RawHydratedContact,
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( r#"select
id,
birthday,
lives_with,
manually_freshened_at as "manually_freshened_at: String",
can_stale,
active,
periodicity,
(
select string_agg(name,x'1c' order by sort) select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id from names where contact_id = c.id
) as names, ( ) as names, (
@ -123,7 +154,15 @@ impl HydratedContact {
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> { pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
let contacts = sqlx::query_as!( let contacts = sqlx::query_as!(
RawHydratedContact, RawHydratedContact,
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( r#"select
id,
birthday,
lives_with,
manually_freshened_at as "manually_freshened_at: String",
can_stale,
active,
periodicity,
(
select string_agg(name,x'1c' order by sort) select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id from names where contact_id = c.id
) as names, ( ) as names, (

View file

@ -1,4 +1,4 @@
use chrono::NaiveDate; use jiff::civil::Date;
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: NaiveDate, pub date: Date,
} }
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 = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(); let date: Date = date_str.parse().unwrap();
Ok(Self { id, value, date }) 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 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,38 +6,39 @@ use std::str::FromStr;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct YearOptionalDate { pub struct YearOptionalDate {
pub year: Option<i32>, pub year: Option<i16>,
pub month: u32, pub month: i8,
pub day: u32, pub day: i8,
} }
impl YearOptionalDate { impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> { pub fn prev_month_day_occurrence(&self) -> Option<Date> {
let now = Local::now(); let now = Timestamp::now().to_zoned(TimeZone::UTC);
let year = now.year(); let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day); Date::new(year, self.month, self.day).ok().and_then(|date| {
if let Some(real_date) = date { if date >= now.date() {
if real_date >= now.date_naive() { Date::new(year - 1, self.month, self.day).ok()
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day); } 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 { if let Some(year) = self.year {
NaiveDate::from_ymd_opt(year, self.month, self.day) Date::new(year, self.month, self.day).ok()
} else { } else {
None 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(); 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 = u32::from_str(&caps[2]).unwrap(); let month = i8::from_str(&caps[2]).unwrap();
let day = u32::from_str(&caps[3]).unwrap(); let day = i8::from_str(&caps[3]).unwrap();
let year = if year_str == "--" { let year = if year_str == "--" {
None None
} else { } else {
Some(i32::from_str(year_str).unwrap()) Some(i16::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,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> { 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 chrono::DateTime; use jiff::{Timestamp, Unit, tz::TimeZone};
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, Sqlite}; use sqlx::QueryBuilder;
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, insert_mentions, Switchboard}; use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions};
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(delta: &chrono::TimeDelta) -> String { fn human_delta(span: &jiff::Span) -> String {
if delta.num_days() == 0 { let todate = Timestamp::now().to_zoned(TimeZone::UTC).date();
return "today".to_string(); let span = span
} .round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
let mut result = "in ".to_string(); if span.is_zero() {
let mut rem = delta.clone(); "today".to_string()
if rem.num_days().abs() >= 7 { } else {
let weeks = rem.num_days() / 7; format!("in {:#}", span)
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,7 +88,9 @@ mod get {
.await?; .await?;
let freshened = std::cmp::max( 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), entries.get(0).map(|entry| entry.date),
); );
@ -130,6 +132,14 @@ mod get {
div { (name) } div { (name) }
} }
} }
@if contact.status() != "normal" {
label { "status" }
div { (contact.status()) }
}
@if contact.status() == "normal" && contact.periodicity.is_positive() {
label { "periodicity" }
div { (format!("{:#}", contact.periodicity)) }
}
@if let Some(bday) = &contact.birthday { @if let Some(bday) = &contact.birthday {
label { "birthday" } label { "birthday" }
div { div {
@ -210,10 +220,16 @@ mod get {
.await?; .await?;
let cid_url = format!("/contact/{}", contact.id); let cid_url = format!("/contact/{}", contact.id);
let mfresh_str = contact let mfresh_on_str = contact
.manually_freshened_at .manually_freshened_at
.clone() .clone()
.map_or("".to_string(), |m| m.to_rfc3339()); .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());
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)
@ -233,7 +249,7 @@ mod get {
div #error; div #error;
} }
div #fields { #fields x-data=(json!({ "status": contact.status() })){
label { @if contact.names.len() > 1 { "names" } @else { "name" }} label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) { div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" { template x-for="(name, idx) in names" {
@ -249,16 +265,40 @@ 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 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" } 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()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" } span .hint { code { "(yyyy|--)mmdd" } " or free text" }
} }
label { "freshened" } label for="manually_freshened_on" { "freshened" }
div x-data=(json!({ "date": mfresh_str })) { 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="date"; input
span x-text="date.length ? date.split('T')[0] : '(never)'" {} type="hidden"
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()"; 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 { "phone" } label { "phone" }
#phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) { #phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) {
@ -324,6 +364,8 @@ mod put {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct PutContact { pub struct PutContact {
name: Option<Vec<String>>, name: Option<Vec<String>>,
status: String,
periodicity: Option<String>,
birthday: String, birthday: String,
manually_freshened_at: String, manually_freshened_at: String,
lives_with: String, lives_with: String,
@ -351,17 +393,22 @@ mod put {
Some(payload.birthday) 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 None
} else { } else {
Some( 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"))? .map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_utc() .to_string(),
.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 {
@ -374,21 +421,24 @@ mod put {
sqlx::query!( sqlx::query!(
"update contacts set "update contacts set
(birthday, manually_freshened_at, lives_with, text_body) = (
($1, $2, $3, $4) birthday, manually_freshened_at, lives_with, text_body,
where id = $5", active, can_stale, periodicity
) =
(?, ?, ?, ?, ?, ?, ?)
where id = ?",
birthday, birthday,
manually_freshened_at, manually_freshened_at,
payload.lives_with, payload.lives_with,
text_body, text_body,
active,
can_stale,
periodicity,
contact_id contact_id
) )
.execute(pool) .execute(pool)
.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
@ -488,22 +538,18 @@ 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!( sqlx::query!("delete from names where contact_id = $1", contact_id)
"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( QueryBuilder::new("insert into names (contact_id, sort, name) ")
"insert into names (contact_id, sort, name) " .push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { b.push_bind(contact_id)
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?;
@ -524,10 +570,7 @@ 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!( sqlx::query!("delete from groups where contact_id = $1", contact_id)
"delete from groups where contact_id = $1",
contact_id
)
.execute(pool) .execute(pool)
.await?; .await?;
@ -566,7 +609,6 @@ 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 chrono::{Local, NaiveDate, TimeDelta}; use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
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: NaiveDate, fresh_date: civil::Date,
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: NaiveDate, prev_birthday: civil::Date,
next_birthday: NaiveDate, next_birthday: civil::Date,
} }
fn birthdays_section( fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>, prev_birthdays: &Vec<KnownBirthdayContact>,
@ -57,25 +57,25 @@ fn birthdays_section(
div id="birthdays" { div id="birthdays" {
h2 { "Birthdays" } h2 { "Birthdays" }
#birthday-sections { #birthday-sections {
.datelist #upcoming { .datelist {
h3 { "upcoming" } h3 { "upcoming" }
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] { @for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) { a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display) (contact.display)
} }
span { span {
(contact.next_birthday.format("%m-%d")) (contact.next_birthday.strftime("%m-%d"))
} }
} }
} }
.datelist #recent { .datelist {
h3 { "recent" } h3 { "recent" }
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] { @for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
a href=(format!("/contact/{}", contact.contact_id)) { a href=(format!("/contact/{}", contact.contact_id)) {
(contact.display) (contact.display)
} }
span { 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." 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=(Local::now().date_naive().to_string()); input name="date" placeholder=(Zoned::now().date().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,57 +134,60 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = contacts let mut freshens: Vec<ContactFreshness> = contacts
.clone() .clone()
.into_iter() .into_iter()
.map(|contact| { .filter_map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap(); if !contact.can_stale || !contact.active {
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(|x| x.date_naive()) .map(|ts| ts.to_zoned(TimeZone::UTC).date())
.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 {
ContactFreshness { Some(ContactFreshness {
contact_id: contact.id, contact_id: contact.id,
display: contact.display_name(), display: contact.display_name(),
fresh_date, fresh_date,
fresh_str: "never".to_string(), fresh_str: "never".to_string(),
elapsed_str: "".to_string(), elapsed_str: "".to_string(),
} })
} else { } else {
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date); let utc = TimeZone::UTC;
let mut elapsed: Vec<String> = Vec::new(); let todate = Timestamp::now().to_zoned(utc.clone()).date();
let y = duration.num_weeks() / 52; let elapsed = todate
let count = |n: i64, noun: &str| { .since(&fresh_date.to_zoned(utc).unwrap())
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" }) .unwrap()
}; .round(
if y > 0 { jiff::SpanRound::new()
elapsed.push(count(y, "year")); .largest(Unit::Year)
duration -= TimeDelta::weeks(y * 52); .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() "today".to_string()
} else { } else {
elapsed.join(", ") format!("{:#}", elapsed)
}; };
ContactFreshness { Some(ContactFreshness {
contact_id: contact.id, contact_id: contact.id,
display: contact.display_name(), display: contact.display_name(),
fresh_date, fresh_date,
fresh_str: fresh_date.to_string(), fresh_str: fresh_date.to_string(),
elapsed_str, elapsed_str,
} })
} }
}) })
.collect(); .collect();
@ -197,8 +200,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().unwrap(), prev_birthday: date.prev_month_day_occurrence()?,
next_birthday: date.next_month_day_occurrence().unwrap(), next_birthday: date.next_month_day_occurrence()?,
}) })
} 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), yo_date.year.unwrap_or(1900).into(),
yo_date.month, yo_date.month.try_into().unwrap(),
yo_date.day, yo_date.day.try_into().unwrap(),
) { ) {
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 chrono::{Datelike, Local, NaiveDate}; use jiff::{Zoned, civil::Date};
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::{MentionHost, MentionHostType, insert_mentions}; use crate::switchboard::{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 = Local::now().date_naive(); let now = Zoned::now();
let date = if payload.date.is_empty() { let date = if payload.date.is_empty() {
now now.date()
} 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,17 +54,16 @@ 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::<i32>().unwrap()) .map(|m| m.as_str().parse::<i16>().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::<u32>().unwrap()) .map(|m| m.as_str().parse::<i8>().unwrap())
.unwrap_or(now.month()); .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( Date::new(year, month, day)
"invalid date: failed NaiveDate construction", .map_err(|_| anyhow::Error::msg("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
@ -131,7 +130,6 @@ 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?)
} }
} }

View file

@ -25,6 +25,7 @@ struct ContactLink {
#[derive(Debug)] #[derive(Debug)]
pub struct Layout { pub struct Layout {
contact_links: Vec<ContactLink>, contact_links: Vec<ContactLink>,
inactive_contact_links: Vec<ContactLink>,
user: User, user: User,
} }
@ -48,6 +49,20 @@ impl FromRequestParts<AppState> for Layout {
from contacts c from contacts c
left join names n on c.id = n.contact_id left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0 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", order by name asc",
) )
.fetch_all(&state.db(&user).pool) .fetch_all(&state.db(&user).pool)
@ -55,13 +70,19 @@ impl FromRequestParts<AppState> for Layout {
Ok(Layout { Ok(Layout {
contact_links, contact_links,
inactive_contact_links,
user, user,
}) })
} }
} }
impl Layout { 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! { html! {
(DOCTYPE) (DOCTYPE)
html { html {
@ -101,6 +122,23 @@ 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 { 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 { body {
width: 100%; width: 100%;
min-height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -35,6 +35,7 @@ section#content {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
height: 100%;
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
position: relative; position: relative;
} }
@ -44,6 +45,8 @@ section#content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-right: 1em; padding-right: 1em;
height: 100%;
overflow-y: auto;
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
position: absolute; position: absolute;
float: left; float: left;
@ -58,7 +61,7 @@ section#content {
} }
} }
ul { & > ul {
flex: 1; flex: 1;
width: fit-content; width: fit-content;
background-color: var(--main-bg-color); background-color: var(--main-bg-color);
@ -79,12 +82,17 @@ section#content {
border-bottom: none; border-bottom: none;
} }
} }
li.inactive {
font-size: small;
}
} }
main { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow-y: auto;
} }
.icon { .icon {