Compare commits
1 commit
4f141b01c3
...
974bdcac34
| Author | SHA1 | Date | |
|---|---|---|---|
| 974bdcac34 |
24 changed files with 269 additions and 511 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
57
Cargo.lock
generated
|
|
@ -1280,47 +1280,6 @@ version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"jiff-tzdb-platform",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.106",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-tzdb"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-tzdb-platform"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-tzdb",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.81"
|
version = "0.3.81"
|
||||||
|
|
@ -1468,7 +1427,6 @@ dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"icalendar",
|
"icalendar",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"jiff",
|
|
||||||
"listenfd",
|
"listenfd",
|
||||||
"markdown",
|
"markdown",
|
||||||
"maud",
|
"maud",
|
||||||
|
|
@ -1889,21 +1847,6 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ clap = { version = "4.5.53", features = ["derive"] }
|
||||||
http = "1.3.1"
|
http = "1.3.1"
|
||||||
icalendar = "0.17.5"
|
icalendar = "0.17.5"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
jiff = { version = "0.2.23", features = ["serde"] }
|
|
||||||
listenfd = "1.0.2"
|
listenfd = "1.0.2"
|
||||||
markdown = "1.0.0"
|
markdown = "1.0.0"
|
||||||
maud = { version = "0.27.0", features = ["axum"] }
|
maud = { version = "0.27.0", features = ["axum"] }
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -11,56 +11,17 @@ 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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
4
Taskfile
4
Taskfile
|
|
@ -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() {
|
||||||
|
|
|
||||||
10
e2e/Taskfile
10
e2e/Taskfile
|
|
@ -10,12 +10,20 @@ 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() {
|
||||||
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() {
|
playwright:ci() {
|
||||||
|
|
|
||||||
|
|
@ -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.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 }) => {
|
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);
|
const groupBox = page.getByPlaceholder(/group name/i);
|
||||||
await groupBox.fill('this is a long group name');
|
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');
|
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
|
// 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.
|
// that the text is all on one line. Manual inspection looks good at time of writing.
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allow marking as inactive', async ({ page }) => {
|
test('allow marking as hidden', async ({ page }) => {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,26 @@ 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);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: '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
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,72 @@
|
||||||
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';
|
||||||
|
|
||||||
let addlConfig = {
|
let addlConfig = {
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let projects = [
|
let projects = [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'firefox',
|
name: 'firefox',
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices['Desktop Firefox'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: 'webkit',
|
||||||
use: { ...devices['Desktop Safari'] },
|
use: { ...devices['Desktop Safari'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
{
|
{
|
||||||
name: 'Mobile Chrome',
|
name: 'Mobile Chrome',
|
||||||
use: { ...devices['Pixel 5'] },
|
use: { ...devices['Pixel 5'] },
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'Mobile Safari',
|
name: 'Mobile Safari',
|
||||||
use: { ...devices['iPhone 12'] },
|
use: { ...devices['iPhone 12'] },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const pfil = process.env.PROJECT_FILTER;
|
const pfil = process.env.PROJECT_FILTER;
|
||||||
if (pfil) {
|
if (pfil) {
|
||||||
if (pfil.startsWith('!')) {
|
if (pfil.startsWith('!')) {
|
||||||
projects = projects.filter(p => p.name !== pfil.slice(1));
|
projects = projects.filter(p => p.name !== pfil.slice(1));
|
||||||
} else {
|
} else {
|
||||||
projects = projects.filter(p => p.name === pfil);
|
projects = projects.filter(p => p.name === pfil);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './pages',
|
testDir: './pages',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects,
|
projects,
|
||||||
...addlConfig,
|
...addlConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, active) values
|
insert into contacts(id, birthday) values
|
||||||
(1, 'April?', false);
|
(1, 'April?');
|
||||||
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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
|
use chrono::Local;
|
||||||
use sqlx::sqlite::SqliteRow;
|
use sqlx::sqlite::SqliteRow;
|
||||||
use sqlx::{FromRow, Row};
|
use sqlx::{FromRow, Row};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
@ -29,31 +29,25 @@ impl Display for Birthday {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Birthday {
|
impl Birthday {
|
||||||
pub fn next_occurrence(&self) -> Option<civil::Date> {
|
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
|
||||||
match &self {
|
match &self {
|
||||||
Birthday::Text(_) => None,
|
Birthday::Text(_) => None,
|
||||||
Birthday::Date(date) => date.next_month_day_occurrence(),
|
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn until_next(&self) -> Option<jiff::Span> {
|
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
|
||||||
self.next_occurrence()
|
self.next_occurrence()
|
||||||
.map(|when| when.since(Zoned::now().date()).ok())?
|
.map(|when| when.signed_duration_since(Local::now().date_naive()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// None if this is a text birthday or doesn't have a year
|
/// None if this is a text birthday or doesn't have a year
|
||||||
pub fn age(&self) -> Option<i32> {
|
pub fn age(&self) -> Option<u32> {
|
||||||
match &self {
|
match &self {
|
||||||
Birthday::Text(_) => None,
|
Birthday::Text(_) => None,
|
||||||
Birthday::Date(date) => {
|
Birthday::Date(date) => date
|
||||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
.to_date_naive()
|
||||||
date.to_civil_date().map(|birthdate| {
|
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
|
||||||
now.since(&birthdate.to_zoned(TimeZone::UTC).unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.total((Unit::Year, &now))
|
|
||||||
.unwrap() as i32
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use jiff::{Span, Timestamp, civil::Date};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
use sqlx::sqlite::SqlitePool;
|
use sqlx::sqlite::SqlitePool;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
|
@ -12,20 +12,14 @@ struct RawContact {
|
||||||
birthday: Option<String>,
|
birthday: Option<String>,
|
||||||
manually_freshened_at: Option<String>,
|
manually_freshened_at: Option<String>,
|
||||||
lives_with: String,
|
lives_with: String,
|
||||||
can_stale: bool,
|
|
||||||
active: bool,
|
|
||||||
periodicity: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Contact {
|
pub struct Contact {
|
||||||
pub id: DbId,
|
pub id: DbId,
|
||||||
pub birthday: Option<Birthday>,
|
pub birthday: Option<Birthday>,
|
||||||
pub manually_freshened_at: Option<Timestamp>,
|
pub manually_freshened_at: Option<DateTime<Utc>>,
|
||||||
pub lives_with: String,
|
pub lives_with: String,
|
||||||
pub can_stale: bool,
|
|
||||||
pub active: bool,
|
|
||||||
pub periodicity: Span,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<Contact> for RawContact {
|
impl Into<Contact> for RawContact {
|
||||||
|
|
@ -37,11 +31,9 @@ impl Into<Contact> for RawContact {
|
||||||
.and_then(|s| Birthday::from_str(s.as_ref()).ok()),
|
.and_then(|s| Birthday::from_str(s.as_ref()).ok()),
|
||||||
manually_freshened_at: self
|
manually_freshened_at: self
|
||||||
.manually_freshened_at
|
.manually_freshened_at
|
||||||
.and_then(|str| str.parse::<Timestamp>().ok()),
|
.and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok())
|
||||||
|
.map(|d| d.to_utc()),
|
||||||
lives_with: self.lives_with,
|
lives_with: self.lives_with,
|
||||||
can_stale: self.can_stale,
|
|
||||||
active: self.active,
|
|
||||||
periodicity: self.periodicity.parse().unwrap(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,10 +43,6 @@ struct RawHydratedContact {
|
||||||
birthday: Option<String>,
|
birthday: Option<String>,
|
||||||
manually_freshened_at: Option<String>,
|
manually_freshened_at: Option<String>,
|
||||||
lives_with: String,
|
lives_with: String,
|
||||||
can_stale: bool,
|
|
||||||
active: bool,
|
|
||||||
periodicity: String,
|
|
||||||
|
|
||||||
last_mention_date: Option<String>,
|
last_mention_date: Option<String>,
|
||||||
names: Option<String>,
|
names: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +50,7 @@ struct RawHydratedContact {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HydratedContact {
|
pub struct HydratedContact {
|
||||||
pub contact: Contact,
|
pub contact: Contact,
|
||||||
pub last_mention_date: Option<Date>,
|
pub last_mention_date: Option<NaiveDate>,
|
||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,9 +62,6 @@ impl Into<HydratedContact> for RawHydratedContact {
|
||||||
birthday: self.birthday,
|
birthday: self.birthday,
|
||||||
manually_freshened_at: self.manually_freshened_at,
|
manually_freshened_at: self.manually_freshened_at,
|
||||||
lives_with: self.lives_with,
|
lives_with: self.lives_with,
|
||||||
can_stale: self.can_stale,
|
|
||||||
active: self.active,
|
|
||||||
periodicity: self.periodicity,
|
|
||||||
}),
|
}),
|
||||||
names: self
|
names: self
|
||||||
.names
|
.names
|
||||||
|
|
@ -86,7 +71,7 @@ impl Into<HydratedContact> for RawHydratedContact {
|
||||||
.collect::<Vec<String>>(),
|
.collect::<Vec<String>>(),
|
||||||
last_mention_date: self
|
last_mention_date: self
|
||||||
.last_mention_date
|
.last_mention_date
|
||||||
.and_then(|str| str.parse::<Date>().ok()),
|
.and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,27 +92,11 @@ impl HydratedContact {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> &'static str {
|
|
||||||
if self.can_stale {
|
|
||||||
if self.active { "normal" } else { "inactive" }
|
|
||||||
} else {
|
|
||||||
"permanent"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
|
pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
|
||||||
// copy-paste the query from 'all', then add "where c.id = $2" to the last line
|
// copy-paste the query from 'all', then add "where c.id = $2" to the last line
|
||||||
let raw = sqlx::query_as!(
|
let raw = sqlx::query_as!(
|
||||||
RawHydratedContact,
|
RawHydratedContact,
|
||||||
r#"select
|
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
|
||||||
id,
|
|
||||||
birthday,
|
|
||||||
lives_with,
|
|
||||||
manually_freshened_at as "manually_freshened_at: String",
|
|
||||||
can_stale,
|
|
||||||
active,
|
|
||||||
periodicity,
|
|
||||||
(
|
|
||||||
select string_agg(name,x'1c' order by sort)
|
select string_agg(name,x'1c' order by sort)
|
||||||
from names where contact_id = c.id
|
from names where contact_id = c.id
|
||||||
) as names, (
|
) as names, (
|
||||||
|
|
@ -154,15 +123,7 @@ impl HydratedContact {
|
||||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
|
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
|
||||||
let contacts = sqlx::query_as!(
|
let contacts = sqlx::query_as!(
|
||||||
RawHydratedContact,
|
RawHydratedContact,
|
||||||
r#"select
|
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
|
||||||
id,
|
|
||||||
birthday,
|
|
||||||
lives_with,
|
|
||||||
manually_freshened_at as "manually_freshened_at: String",
|
|
||||||
can_stale,
|
|
||||||
active,
|
|
||||||
periodicity,
|
|
||||||
(
|
|
||||||
select string_agg(name,x'1c' order by sort)
|
select string_agg(name,x'1c' order by sort)
|
||||||
from names where contact_id = c.id
|
from names where contact_id = c.id
|
||||||
) as names, (
|
) as names, (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use jiff::civil::Date;
|
use chrono::NaiveDate;
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::sqlite::{SqlitePool, SqliteRow};
|
use sqlx::sqlite::{SqlitePool, SqliteRow};
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::switchboard::{MentionHost, MentionHostType};
|
||||||
pub struct JournalEntry {
|
pub struct JournalEntry {
|
||||||
pub id: DbId,
|
pub id: DbId,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
pub date: Date,
|
pub date: NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Into<MentionHost<'a>> for &'a JournalEntry {
|
impl<'a> Into<MentionHost<'a>> for &'a JournalEntry {
|
||||||
|
|
@ -69,7 +69,7 @@ impl FromRow<'_, SqliteRow> for JournalEntry {
|
||||||
let id: DbId = row.try_get("id")?;
|
let id: DbId = row.try_get("id")?;
|
||||||
let value: String = row.try_get("value")?;
|
let value: String = row.try_get("value")?;
|
||||||
let date_str: &str = row.try_get("date")?;
|
let date_str: &str = row.try_get("date")?;
|
||||||
let date: Date = date_str.parse().unwrap();
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
|
||||||
Ok(Self { id, value, date })
|
Ok(Self { id, value, date })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use jiff::{Timestamp, civil::Date, tz::TimeZone};
|
use chrono::{Datelike, Local, NaiveDate};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
|
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
@ -6,39 +6,38 @@ use std::str::FromStr;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct YearOptionalDate {
|
pub struct YearOptionalDate {
|
||||||
pub year: Option<i16>,
|
pub year: Option<i32>,
|
||||||
pub month: i8,
|
pub month: u32,
|
||||||
pub day: i8,
|
pub day: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YearOptionalDate {
|
impl YearOptionalDate {
|
||||||
pub fn prev_month_day_occurrence(&self) -> Option<Date> {
|
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
let now = Local::now();
|
||||||
let year = now.year();
|
let year = now.year();
|
||||||
Date::new(year, self.month, self.day).ok().and_then(|date| {
|
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||||
if date >= now.date() {
|
if let Some(real_date) = date {
|
||||||
Date::new(year - 1, self.month, self.day).ok()
|
if real_date >= now.date_naive() {
|
||||||
} else {
|
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
|
||||||
Some(date)
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
date
|
||||||
|
}
|
||||||
|
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
|
||||||
|
let now = Local::now();
|
||||||
|
let year = now.year();
|
||||||
|
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
|
||||||
|
if let Some(real_date) = date {
|
||||||
|
if real_date < now.date_naive() {
|
||||||
|
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
date
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_month_day_occurrence(&self) -> Option<Date> {
|
pub fn to_date_naive(&self) -> Option<NaiveDate> {
|
||||||
let now = Timestamp::now().to_zoned(TimeZone::UTC);
|
|
||||||
let year = now.year();
|
|
||||||
Date::new(year, self.month, self.day).ok().and_then(|date| {
|
|
||||||
if date < now.date() {
|
|
||||||
Date::new(year + 1, self.month, self.day).ok()
|
|
||||||
} else {
|
|
||||||
Some(date)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_civil_date(&self) -> Option<Date> {
|
|
||||||
if let Some(year) = self.year {
|
if let Some(year) = self.year {
|
||||||
Date::new(year, self.month, self.day).ok()
|
NaiveDate::from_ymd_opt(year, self.month, self.day)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -69,12 +68,12 @@ impl FromStr for YearOptionalDate {
|
||||||
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
|
let date_re = Regex::new(r"^([0-9]{4}|--)([0-9]{2})([0-9]{2})$").unwrap();
|
||||||
if let Some(caps) = date_re.captures(str) {
|
if let Some(caps) = date_re.captures(str) {
|
||||||
let year_str = &caps[1];
|
let year_str = &caps[1];
|
||||||
let month = i8::from_str(&caps[2]).unwrap();
|
let month = u32::from_str(&caps[2]).unwrap();
|
||||||
let day = i8::from_str(&caps[3]).unwrap();
|
let day = u32::from_str(&caps[3]).unwrap();
|
||||||
let year = if year_str == "--" {
|
let year = if year_str == "--" {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(i16::from_str(year_str).unwrap())
|
Some(i32::from_str(year_str).unwrap())
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(Self { year, month, day });
|
return Ok(Self { year, month, day });
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ impl MentionHost<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Switchboard {
|
impl Switchboard {
|
||||||
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String, String>, AppError> {
|
pub async fn gen_trie(pool: &SqlitePool) -> Result<radix_trie::Trie<String,String>, AppError> {
|
||||||
let mut trie = radix_trie::Trie::new();
|
let mut trie = radix_trie::Trie::new();
|
||||||
|
|
||||||
let mentionables = sqlx::query_as!(
|
let mentionables = sqlx::query_as!(
|
||||||
|
|
@ -109,6 +109,14 @@ impl Switchboard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remove(self: &mut Self, text: &String) {
|
||||||
|
self.trie.remove(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_mentionable(self: &mut Self, text: String, uri: String) {
|
||||||
|
self.trie.insert(text, uri);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_mentions<'a>(&self, host: impl Into<MentionHost<'a>>) -> HashSet<Mention> {
|
pub fn extract_mentions<'a>(&self, host: impl Into<MentionHost<'a>>) -> HashSet<Mention> {
|
||||||
let host: MentionHost = host.into();
|
let host: MentionHost = host.into();
|
||||||
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,19 @@ use axum::{
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use cache_bust::asset;
|
use cache_bust::asset;
|
||||||
use jiff::{Timestamp, Unit, tz::TimeZone};
|
use chrono::DateTime;
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use slug::slugify;
|
use slug::slugify;
|
||||||
use sqlx::QueryBuilder;
|
use sqlx::{QueryBuilder, Sqlite};
|
||||||
|
|
||||||
use super::Layout;
|
use super::Layout;
|
||||||
use super::home::journal_section;
|
use super::home::journal_section;
|
||||||
use crate::db::DbId;
|
use crate::db::DbId;
|
||||||
use crate::models::user::AuthSession;
|
use crate::models::user::AuthSession;
|
||||||
use crate::models::{HydratedContact, JournalEntry};
|
use crate::models::{HydratedContact, JournalEntry};
|
||||||
use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions};
|
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
|
||||||
use crate::{AppError, AppState};
|
use crate::{AppError, AppState};
|
||||||
|
|
||||||
pub mod fields;
|
pub mod fields;
|
||||||
|
|
@ -40,22 +40,22 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
|
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn human_delta(span: &jiff::Span) -> String {
|
fn human_delta(delta: &chrono::TimeDelta) -> String {
|
||||||
let todate = Timestamp::now().to_zoned(TimeZone::UTC).date();
|
if delta.num_days() == 0 {
|
||||||
let span = span
|
return "today".to_string();
|
||||||
.round(
|
|
||||||
jiff::SpanRound::new()
|
|
||||||
.largest(Unit::Year)
|
|
||||||
.smallest(Unit::Day)
|
|
||||||
.relative(todate),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if span.is_zero() {
|
|
||||||
"today".to_string()
|
|
||||||
} else {
|
|
||||||
format!("in {:#}", span)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut result = "in ".to_string();
|
||||||
|
let mut rem = delta.clone();
|
||||||
|
if rem.num_days().abs() >= 7 {
|
||||||
|
let weeks = rem.num_days() / 7;
|
||||||
|
rem -= chrono::TimeDelta::days(weeks * 7);
|
||||||
|
result.push_str(&format!("{}w ", weeks));
|
||||||
|
}
|
||||||
|
if rem.num_days().abs() > 0 {
|
||||||
|
result.push_str(&format!("{}d ", rem.num_days()));
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
mod get {
|
mod get {
|
||||||
|
|
@ -88,9 +88,7 @@ mod get {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let freshened = std::cmp::max(
|
let freshened = std::cmp::max(
|
||||||
contact
|
contact.manually_freshened_at.map(|when| when.date_naive()),
|
||||||
.manually_freshened_at
|
|
||||||
.map(|when| when.to_zoned(jiff::tz::TimeZone::UTC).date()),
|
|
||||||
entries.get(0).map(|entry| entry.date),
|
entries.get(0).map(|entry| entry.date),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -132,14 +130,6 @@ mod get {
|
||||||
div { (name) }
|
div { (name) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if contact.status() != "normal" {
|
|
||||||
label { "status" }
|
|
||||||
div { (contact.status()) }
|
|
||||||
}
|
|
||||||
@if contact.status() == "normal" && contact.periodicity.is_positive() {
|
|
||||||
label { "periodicity" }
|
|
||||||
div { (format!("{:#}", contact.periodicity)) }
|
|
||||||
}
|
|
||||||
@if let Some(bday) = &contact.birthday {
|
@if let Some(bday) = &contact.birthday {
|
||||||
label { "birthday" }
|
label { "birthday" }
|
||||||
div {
|
div {
|
||||||
|
|
@ -220,16 +210,10 @@ mod get {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let cid_url = format!("/contact/{}", contact.id);
|
let cid_url = format!("/contact/{}", contact.id);
|
||||||
let mfresh_on_str = contact
|
let mfresh_str = contact
|
||||||
.manually_freshened_at
|
.manually_freshened_at
|
||||||
.clone()
|
.clone()
|
||||||
.map_or("".to_string(), |m| {
|
.map_or("".to_string(), |m| m.to_rfc3339());
|
||||||
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)
|
||||||
|
|
@ -249,7 +233,7 @@ mod get {
|
||||||
div #error;
|
div #error;
|
||||||
}
|
}
|
||||||
|
|
||||||
#fields x-data=(json!({ "status": contact.status() })){
|
div #fields {
|
||||||
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
||||||
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
|
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
|
||||||
template x-for="(name, idx) in names" {
|
template x-for="(name, idx) in names" {
|
||||||
|
|
@ -265,40 +249,16 @@ 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 for="manually_freshened_on" { "freshened" }
|
label { "freshened" }
|
||||||
div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
|
div x-data=(json!({ "date": mfresh_str })) {
|
||||||
input
|
input type="hidden" name="manually_freshened_at" x-model="date";
|
||||||
type="hidden"
|
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
|
||||||
name="manually_freshened_at"
|
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
|
||||||
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": "" })) {
|
||||||
|
|
@ -364,8 +324,6 @@ mod put {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PutContact {
|
pub struct PutContact {
|
||||||
name: Option<Vec<String>>,
|
name: Option<Vec<String>>,
|
||||||
status: String,
|
|
||||||
periodicity: Option<String>,
|
|
||||||
birthday: String,
|
birthday: String,
|
||||||
manually_freshened_at: String,
|
manually_freshened_at: String,
|
||||||
lives_with: String,
|
lives_with: String,
|
||||||
|
|
@ -393,22 +351,17 @@ mod put {
|
||||||
Some(payload.birthday)
|
Some(payload.birthday)
|
||||||
};
|
};
|
||||||
|
|
||||||
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() {
|
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(
|
Some(
|
||||||
payload
|
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
|
||||||
.manually_freshened_at
|
|
||||||
.parse::<Timestamp>()
|
|
||||||
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
|
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
|
||||||
.to_string(),
|
.to_utc()
|
||||||
|
.to_rfc3339(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let active: bool = payload.status != "inactive";
|
|
||||||
let can_stale: bool = payload.status != "permanent";
|
|
||||||
let periodicity: String = payload.periodicity.unwrap_or("P0D".to_string());
|
|
||||||
|
|
||||||
let text_body = if payload.text_body.is_empty() {
|
let text_body = if payload.text_body.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -421,24 +374,21 @@ mod put {
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"update contacts set
|
"update contacts set
|
||||||
(
|
(birthday, manually_freshened_at, lives_with, text_body) =
|
||||||
birthday, manually_freshened_at, lives_with, text_body,
|
($1, $2, $3, $4)
|
||||||
active, can_stale, periodicity
|
where id = $5",
|
||||||
) =
|
|
||||||
(?, ?, ?, ?, ?, ?, ?)
|
|
||||||
where id = ?",
|
|
||||||
birthday,
|
birthday,
|
||||||
manually_freshened_at,
|
manually_freshened_at,
|
||||||
payload.lives_with,
|
payload.lives_with,
|
||||||
text_body,
|
text_body,
|
||||||
active,
|
|
||||||
can_stale,
|
|
||||||
periodicity,
|
|
||||||
contact_id
|
contact_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if old_contact.text_body != text_body {
|
||||||
|
}
|
||||||
|
|
||||||
// these blocks are not in functions because payload gets progressively
|
// these blocks are not in functions because payload gets progressively
|
||||||
// partially moved as we handle each field and i don't want to deal with it
|
// partially moved as we handle each field and i don't want to deal with it
|
||||||
|
|
||||||
|
|
@ -538,21 +488,25 @@ mod put {
|
||||||
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
|
||||||
|
|
||||||
if old_names != new_names {
|
if old_names != new_names {
|
||||||
sqlx::query!("delete from names where contact_id = $1", contact_id)
|
sqlx::query!(
|
||||||
.execute(pool)
|
"delete from names where contact_id = $1",
|
||||||
.await?;
|
contact_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !new_names.is_empty() {
|
if !new_names.is_empty() {
|
||||||
QueryBuilder::new("insert into names (contact_id, sort, name) ")
|
QueryBuilder::new(
|
||||||
.push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
"insert into names (contact_id, sort, name) "
|
||||||
b.push_bind(contact_id)
|
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
|
||||||
.push_bind(DbId::try_from(sort).unwrap())
|
b
|
||||||
.push_bind(name);
|
.push_bind(contact_id)
|
||||||
})
|
.push_bind(DbId::try_from(sort).unwrap())
|
||||||
.build()
|
.push_bind(name);
|
||||||
.persistent(false)
|
}).build()
|
||||||
.execute(pool)
|
.persistent(false)
|
||||||
.await?;
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -570,9 +524,12 @@ mod put {
|
||||||
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
|
||||||
|
|
||||||
if new_groups != old_groups {
|
if new_groups != old_groups {
|
||||||
sqlx::query!("delete from groups where contact_id = $1", contact_id)
|
sqlx::query!(
|
||||||
.execute(pool)
|
"delete from groups where contact_id = $1",
|
||||||
.await?;
|
contact_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if new_groups.len() > 0 {
|
if new_groups.len() > 0 {
|
||||||
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
|
||||||
|
|
@ -609,6 +566,7 @@ mod put {
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if regen_text_body {
|
if regen_text_body {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"delete from mentions where entity_id = $1 and entity_type = $2",
|
"delete from mentions where entity_id = $1 and entity_type = $2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use cache_bust::asset;
|
use cache_bust::asset;
|
||||||
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
|
use chrono::{Local, NaiveDate, TimeDelta};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use sqlx::sqlite::SqlitePool;
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ use crate::{AppError, AppState};
|
||||||
struct ContactFreshness {
|
struct ContactFreshness {
|
||||||
contact_id: DbId,
|
contact_id: DbId,
|
||||||
display: String,
|
display: String,
|
||||||
fresh_date: civil::Date,
|
fresh_date: NaiveDate,
|
||||||
fresh_str: String,
|
fresh_str: String,
|
||||||
elapsed_str: String,
|
elapsed_str: String,
|
||||||
}
|
}
|
||||||
|
|
@ -46,8 +46,8 @@ fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppErro
|
||||||
struct KnownBirthdayContact {
|
struct KnownBirthdayContact {
|
||||||
contact_id: i64,
|
contact_id: i64,
|
||||||
display: String,
|
display: String,
|
||||||
prev_birthday: civil::Date,
|
prev_birthday: NaiveDate,
|
||||||
next_birthday: civil::Date,
|
next_birthday: NaiveDate,
|
||||||
}
|
}
|
||||||
fn birthdays_section(
|
fn birthdays_section(
|
||||||
prev_birthdays: &Vec<KnownBirthdayContact>,
|
prev_birthdays: &Vec<KnownBirthdayContact>,
|
||||||
|
|
@ -57,25 +57,25 @@ fn birthdays_section(
|
||||||
div id="birthdays" {
|
div id="birthdays" {
|
||||||
h2 { "Birthdays" }
|
h2 { "Birthdays" }
|
||||||
#birthday-sections {
|
#birthday-sections {
|
||||||
.datelist {
|
.datelist #upcoming {
|
||||||
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.strftime("%m-%d"))
|
(contact.next_birthday.format("%m-%d"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.datelist {
|
.datelist #recent {
|
||||||
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.strftime("%m-%d"))
|
(contact.prev_birthday.format("%m-%d"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +103,7 @@ pub async fn journal_section(
|
||||||
added to the top of the list regardless of date; refresh the page to re-sort."
|
added to the top of the list regardless of date; refresh the page to re-sort."
|
||||||
}
|
}
|
||||||
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
|
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
|
||||||
input name="date" placeholder=(Zoned::now().date().to_string());
|
input name="date" placeholder=(Local::now().date_naive().to_string());
|
||||||
textarea name="value" placeholder="New entry..." autofocus {}
|
textarea name="value" placeholder="New entry..." autofocus {}
|
||||||
input type="submit" value="Add Entry";
|
input type="submit" value="Add Entry";
|
||||||
}
|
}
|
||||||
|
|
@ -134,60 +134,57 @@ pub mod get {
|
||||||
let mut freshens: Vec<ContactFreshness> = contacts
|
let mut freshens: Vec<ContactFreshness> = contacts
|
||||||
.clone()
|
.clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|contact| {
|
.map(|contact| {
|
||||||
if !contact.can_stale || !contact.active {
|
let zero = NaiveDate::from_epoch_days(0).unwrap();
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let zero = jiff::civil::Date::ZERO;
|
|
||||||
let fresh_date = std::cmp::max(
|
let fresh_date = std::cmp::max(
|
||||||
contact
|
contact
|
||||||
.manually_freshened_at
|
.manually_freshened_at
|
||||||
.map(|ts| ts.to_zoned(TimeZone::UTC).date())
|
.map(|x| x.date_naive())
|
||||||
.unwrap_or(zero),
|
.unwrap_or(zero),
|
||||||
contact.last_mention_date.unwrap_or(zero),
|
contact.last_mention_date.unwrap_or(zero),
|
||||||
);
|
);
|
||||||
if fresh_date == zero {
|
if fresh_date == zero {
|
||||||
Some(ContactFreshness {
|
ContactFreshness {
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
display: contact.display_name(),
|
display: contact.display_name(),
|
||||||
fresh_date,
|
fresh_date,
|
||||||
fresh_str: "never".to_string(),
|
fresh_str: "never".to_string(),
|
||||||
elapsed_str: "".to_string(),
|
elapsed_str: "".to_string(),
|
||||||
})
|
}
|
||||||
} else {
|
} else {
|
||||||
let utc = TimeZone::UTC;
|
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
|
||||||
let todate = Timestamp::now().to_zoned(utc.clone()).date();
|
let mut elapsed: Vec<String> = Vec::new();
|
||||||
let elapsed = todate
|
let y = duration.num_weeks() / 52;
|
||||||
.since(&fresh_date.to_zoned(utc).unwrap())
|
let count = |n: i64, noun: &str| {
|
||||||
.unwrap()
|
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
|
||||||
.round(
|
};
|
||||||
jiff::SpanRound::new()
|
if y > 0 {
|
||||||
.largest(Unit::Year)
|
elapsed.push(count(y, "year"));
|
||||||
.smallest(Unit::Day)
|
duration -= TimeDelta::weeks(y * 52);
|
||||||
.relative(todate),
|
}
|
||||||
)
|
let w = duration.num_weeks();
|
||||||
.unwrap();
|
if w > 0 {
|
||||||
|
elapsed.push(count(w, "week"));
|
||||||
if let Some(cmp) = elapsed.compare((contact.periodicity, todate)).ok() {
|
duration -= TimeDelta::weeks(w);
|
||||||
if cmp == std::cmp::Ordering::Less {
|
}
|
||||||
return None;
|
let d = duration.num_days();
|
||||||
}
|
if d > 0 {
|
||||||
|
elapsed.push(count(d, "day"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed_str = if elapsed.is_zero() {
|
let elapsed_str = if elapsed.is_empty() {
|
||||||
"today".to_string()
|
"today".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{:#}", elapsed)
|
elapsed.join(", ")
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(ContactFreshness {
|
ContactFreshness {
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
display: contact.display_name(),
|
display: contact.display_name(),
|
||||||
fresh_date,
|
fresh_date,
|
||||||
fresh_str: fresh_date.to_string(),
|
fresh_str: fresh_date.to_string(),
|
||||||
elapsed_str,
|
elapsed_str,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -200,8 +197,8 @@ pub mod get {
|
||||||
Some(KnownBirthdayContact {
|
Some(KnownBirthdayContact {
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
display: contact.display_name(),
|
display: contact.display_name(),
|
||||||
prev_birthday: date.prev_month_day_occurrence()?,
|
prev_birthday: date.prev_month_day_occurrence().unwrap(),
|
||||||
next_birthday: date.next_month_day_occurrence()?,
|
next_birthday: date.next_month_day_occurrence().unwrap(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@ mod get {
|
||||||
for contact in &contacts {
|
for contact in &contacts {
|
||||||
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
|
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
|
||||||
if let Some(date) = NaiveDate::from_ymd_opt(
|
if let Some(date) = NaiveDate::from_ymd_opt(
|
||||||
yo_date.year.unwrap_or(1900).into(),
|
yo_date.year.unwrap_or(1900),
|
||||||
yo_date.month.try_into().unwrap(),
|
yo_date.month,
|
||||||
yo_date.day.try_into().unwrap(),
|
yo_date.day,
|
||||||
) {
|
) {
|
||||||
calendar.push(
|
calendar.push(
|
||||||
Event::new()
|
Event::new()
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ use axum::{
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, patch, post},
|
routing::{delete, patch, post},
|
||||||
};
|
};
|
||||||
use jiff::{Zoned, civil::Date};
|
use chrono::{Datelike, Local, NaiveDate};
|
||||||
use maud::Markup;
|
use maud::Markup;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::models::JournalEntry;
|
use crate::models::JournalEntry;
|
||||||
use crate::models::user::AuthSession;
|
use crate::models::user::AuthSession;
|
||||||
use crate::switchboard::{MentionHostType, insert_mentions};
|
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
|
||||||
use crate::{AppError, AppState, DbId};
|
use crate::{AppError, AppState, DbId};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
|
|
@ -39,10 +39,10 @@ mod post {
|
||||||
let pool = &state.db(&user).pool;
|
let pool = &state.db(&user).pool;
|
||||||
let sw_lock = state.switchboard(&user);
|
let sw_lock = state.switchboard(&user);
|
||||||
|
|
||||||
let now = Zoned::now();
|
let now = Local::now().date_naive();
|
||||||
|
|
||||||
let date = if payload.date.is_empty() {
|
let date = if payload.date.is_empty() {
|
||||||
now.date()
|
now
|
||||||
} else {
|
} else {
|
||||||
let date_re =
|
let date_re =
|
||||||
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
|
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
|
||||||
|
|
@ -54,16 +54,17 @@ mod post {
|
||||||
// unwrapping these parses is safe since it's matching [0-9]{2,4}
|
// unwrapping these parses is safe since it's matching [0-9]{2,4}
|
||||||
let year = caps
|
let year = caps
|
||||||
.name("year")
|
.name("year")
|
||||||
.map(|m| m.as_str().parse::<i16>().unwrap())
|
.map(|m| m.as_str().parse::<i32>().unwrap())
|
||||||
.unwrap_or(now.year());
|
.unwrap_or(now.year());
|
||||||
let month = caps
|
let month = caps
|
||||||
.name("month")
|
.name("month")
|
||||||
.map(|m| m.as_str().parse::<i8>().unwrap())
|
.map(|m| m.as_str().parse::<u32>().unwrap())
|
||||||
.unwrap_or(now.month());
|
.unwrap_or(now.month());
|
||||||
let day = caps.name("day").unwrap().as_str().parse::<i8>().unwrap();
|
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
|
||||||
|
|
||||||
Date::new(year, month, day)
|
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
|
||||||
.map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))?
|
"invalid date: failed NaiveDate construction",
|
||||||
|
))?
|
||||||
};
|
};
|
||||||
|
|
||||||
// not a macro query, we want to use JournalEntry's custom FromRow
|
// not a macro query, we want to use JournalEntry's custom FromRow
|
||||||
|
|
@ -130,6 +131,7 @@ mod patch {
|
||||||
insert_mentions(&mentions, pool).await?;
|
insert_mentions(&mentions, pool).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Ok(new_entry.to_html(pool).await?)
|
Ok(new_entry.to_html(pool).await?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,20 +48,6 @@ 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)
|
||||||
|
|
@ -70,19 +55,13 @@ 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(
|
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||||
&self,
|
|
||||||
title: impl AsRef<str>,
|
|
||||||
css: Option<Vec<&str>>,
|
|
||||||
content: Markup,
|
|
||||||
) -> Markup {
|
|
||||||
html! {
|
html! {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
html {
|
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 {
|
main {
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -35,7 +35,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -45,8 +44,6 @@ 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;
|
||||||
|
|
@ -61,7 +58,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);
|
||||||
|
|
@ -82,17 +79,12 @@ 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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue