diff --git a/.gitignore b/.gitignore index 3869d49..774c5d2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ e2e/node_modules e2e/playwright-report e2e/test-results /some_user.db -/dbs/* +/dbs /hashed_static /users.db /.sqlx diff --git a/Cargo.lock b/Cargo.lock index 3c03174..b300c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5b03dc4..d246956 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index fad7cb6..90fb9b5 100644 --- a/README.md +++ b/README.md @@ -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 -``` diff --git a/Taskfile b/Taskfile index 9fa64cf..9eeaa50 100755 --- a/Taskfile +++ b/Taskfile @@ -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() { diff --git a/dbs/.gitkeep b/dbs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/Taskfile b/e2e/Taskfile index ee88f38..8cb16ea 100755 --- a/e2e/Taskfile +++ b/e2e/Taskfile @@ -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() { diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts index 8b4318d..f097089 100644 --- a/e2e/pages/contact.spec.ts +++ b/e2e/pages/contact.spec.ts @@ -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 }) => { @@ -30,72 +30,30 @@ test.skip("groups wrap nicely", async ({ page }) => { const groupBox = page.getByPlaceholder(/group name/i); await groupBox.fill('this is a long group name'); - await page.getByRole('button', { name: /save/i }).click(); + await page.getByRole('button', { name: /save/i }).click(); await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); - // TODO: this drives to the right location but i can't figure out how to assert - // that the text is all on one line. Manual inspection looks good at time of writing. + // TODO: this drives to the right location but i can't figure out how to assert + // 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 diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 13b9fa1..61ee12a 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -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); + +}); diff --git a/e2e/pages/journal.spec.ts b/e2e/pages/journal.spec.ts index fe9ecb9..f30efa3 100644 --- a/e2e/pages/journal.spec.ts +++ b/e2e/pages/journal.spec.ts @@ -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 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f1b1fab..1065269 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,72 +1,72 @@ 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'; let addlConfig = { - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 0, }; let projects = [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, - }, + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, - { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, - }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, ]; const pfil = process.env.PROJECT_FILTER; if (pfil) { - if (pfil.startsWith('!')) { - projects = projects.filter(p => p.name !== pfil.slice(1)); - } else { - projects = projects.filter(p => p.name === pfil); - } + if (pfil.startsWith('!')) { + projects = projects.filter(p => p.name !== pfil.slice(1)); + } else { + projects = projects.filter(p => p.name === pfil); + } } /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './pages', - fullyParallel: true, - workers: 1, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: Boolean(process.env.CI), - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: BASE_URL, + testDir: './pages', + fullyParallel: true, + workers: 1, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: Boolean(process.env.CI), + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: BASE_URL, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, - /* Configure projects for major browsers */ - projects, - ...addlConfig, + /* Configure projects for major browsers */ + projects, + ...addlConfig, }); diff --git a/migrations/demo.sql b/migrations/demo.sql index 8eb0d76..17bda4c 100644 --- a/migrations/demo.sql +++ b/migrations/demo.sql @@ -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'); diff --git a/migrations/each_user/0013_contact-periodicity.sql b/migrations/each_user/0013_contact-periodicity.sql deleted file mode 100644 index 05e65ad..0000000 --- a/migrations/each_user/0013_contact-periodicity.sql +++ /dev/null @@ -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; diff --git a/src/models/birthday.rs b/src/models/birthday.rs index f7c0256..603191a 100644 --- a/src/models/birthday.rs +++ b/src/models/birthday.rs @@ -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 { + pub fn next_occurrence(&self) -> Option { 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 { + pub fn until_next(&self) -> Option { 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 { + pub fn age(&self) -> Option { 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))?, } } diff --git a/src/models/contact.rs b/src/models/contact.rs index 165c43b..bad0ff4 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -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, manually_freshened_at: Option, lives_with: String, - can_stale: bool, - active: bool, - periodicity: String, } #[derive(Clone, Debug)] pub struct Contact { pub id: DbId, pub birthday: Option, - pub manually_freshened_at: Option, + pub manually_freshened_at: Option>, pub lives_with: String, - pub can_stale: bool, - pub active: bool, - pub periodicity: Span, } impl Into for RawContact { @@ -37,11 +31,9 @@ impl Into for RawContact { .and_then(|s| Birthday::from_str(s.as_ref()).ok()), manually_freshened_at: self .manually_freshened_at - .and_then(|str| str.parse::().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, manually_freshened_at: Option, lives_with: String, - can_stale: bool, - active: bool, - periodicity: String, - last_mention_date: Option, names: Option, } @@ -62,7 +50,7 @@ struct RawHydratedContact { #[derive(Clone, Debug)] pub struct HydratedContact { pub contact: Contact, - pub last_mention_date: Option, + pub last_mention_date: Option, pub names: Vec, } @@ -74,9 +62,6 @@ impl Into 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 for RawHydratedContact { .collect::>(), last_mention_date: self .last_mention_date - .and_then(|str| str.parse::().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 { // 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, 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, ( diff --git a/src/models/journal.rs b/src/models/journal.rs index fdc9940..4a90d86 100644 --- a/src/models/journal.rs +++ b/src/models/journal.rs @@ -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> 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 }) } } diff --git a/src/models/year_optional_date.rs b/src/models/year_optional_date.rs index fa1fc1f..196360d 100644 --- a/src/models/year_optional_date.rs +++ b/src/models/year_optional_date.rs @@ -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, - pub month: i8, - pub day: i8, + pub year: Option, + pub month: u32, + pub day: u32, } impl YearOptionalDate { - pub fn prev_month_day_occurrence(&self) -> Option { - let now = Timestamp::now().to_zoned(TimeZone::UTC); + pub fn prev_month_day_occurrence(&self) -> Option { + 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 { + 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 { - 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 { + pub fn to_date_naive(&self) -> Option { 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 }); diff --git a/src/switchboard.rs b/src/switchboard.rs index 6635700..0b7d3f8 100644 --- a/src/switchboard.rs +++ b/src/switchboard.rs @@ -74,7 +74,7 @@ impl MentionHost<'_> { } impl Switchboard { - pub async fn gen_trie(pool: &SqlitePool) -> Result, AppError> { + pub async fn gen_trie(pool: &SqlitePool) -> Result, AppError> { let mut trie = radix_trie::Trie::new(); let mentionables = sqlx::query_as!( @@ -109,6 +109,14 @@ impl Switchboard { } } + pub fn remove(self: &mut Self, text: &String) { + self.trie.remove(text); + } + + pub fn add_mentionable(self: &mut Self, text: String, uri: String) { + self.trie.insert(text, uri); + } + pub fn extract_mentions<'a>(&self, host: impl Into>) -> HashSet { let host: MentionHost = host.into(); let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap(); diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 7dc487b..35f66e0 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -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 { .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) @@ -240,7 +224,7 @@ mod get { Ok(layout.render( format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))), - Some(vec![asset!("contact.css")]), + Some(vec![asset!("contact.css")]), html! { form hx-ext="response-targets" { div { @@ -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>, - status: String, - periodicity: Option, birthday: String, manually_freshened_at: String, lives_with: String, @@ -393,22 +351,17 @@ mod put { Some(payload.birthday) }; - let manually_freshened_at: Option = 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::() + 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,21 +488,25 @@ mod put { let old_names: Vec = old_names.into_iter().map(|(s,)| s).collect(); if old_names != new_names { - sqlx::query!("delete from names where contact_id = $1", contact_id) - .execute(pool) - .await?; + 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) - .push_bind(DbId::try_from(sort).unwrap()) - .push_bind(name); - }) - .build() - .persistent(false) - .execute(pool) - .await?; + 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() + .persistent(false) + .execute(pool) + .await?; } } @@ -570,9 +524,12 @@ mod put { let old_groups: Vec = old_groups.into_iter().map(|(s,)| s).collect(); if new_groups != old_groups { - sqlx::query!("delete from groups where contact_id = $1", contact_id) - .execute(pool) - .await?; + sqlx::query!( + "delete from groups where contact_id = $1", + contact_id + ) + .execute(pool) + .await?; if new_groups.len() > 0 { QueryBuilder::new("insert into groups (contact_id, name, slug) ") @@ -609,6 +566,7 @@ mod put { .await?; } + if regen_text_body { sqlx::query!( "delete from mentions where entity_id = $1 and entity_type = $2", diff --git a/src/web/home.rs b/src/web/home.rs index 231ee6a..3a9ad50 100644 --- a/src/web/home.rs +++ b/src/web/home.rs @@ -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) -> Result, @@ -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 = 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 = 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 diff --git a/src/web/ics.rs b/src/web/ics.rs index 162b3f9..b7780ec 100644 --- a/src/web/ics.rs +++ b/src/web/ics.rs @@ -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() diff --git a/src/web/journal.rs b/src/web/journal.rs index 2ff6ddc..b3bb30e 100644 --- a/src/web/journal.rs +++ b/src/web/journal.rs @@ -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 { @@ -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"^(?:(?[0-9]{4})-)?(?:(?[0-9]{2})-)?(?[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::().unwrap()) + .map(|m| m.as_str().parse::().unwrap()) .unwrap_or(now.year()); let month = caps .name("month") - .map(|m| m.as_str().parse::().unwrap()) + .map(|m| m.as_str().parse::().unwrap()) .unwrap_or(now.month()); - let day = caps.name("day").unwrap().as_str().parse::().unwrap(); + let day = caps.name("day").unwrap().as_str().parse::().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?) } } diff --git a/src/web/mod.rs b/src/web/mod.rs index a7f755d..6a6e141 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -25,7 +25,6 @@ struct ContactLink { #[derive(Debug)] pub struct Layout { contact_links: Vec, - inactive_contact_links: Vec, user: User, } @@ -49,20 +48,6 @@ impl FromRequestParts 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 for Layout { Ok(Layout { contact_links, - inactive_contact_links, user, }) } } impl Layout { - pub fn render( - &self, - title: impl AsRef, - css: Option>, - content: Markup, - ) -> Markup { + pub fn render(&self, title: impl AsRef, css: Option>, 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 { diff --git a/static/index.css b/static/index.css index 5889146..03bf9da 100644 --- a/static/index.css +++ b/static/index.css @@ -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 {