Compare commits

..

No commits in common. "main" and "wip--tests" have entirely different histories.

35 changed files with 365 additions and 1150 deletions

2
.gitignore vendored
View file

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

View file

@ -1,42 +0,0 @@
## [0.2.0] - 2026-04-08
### Features
- Scroll to current contact in sidebar by default
- Clicking off sidebar on small windows closes it
- Sort contacts sidebar ignoring case
- Report version information
### Refactor
- Cloak input elements while alpine.js loads
### Performance
- *(test)* Test performance improvements
- Large cache time on immutable hashed statics
### Testing
- Deflake by waiting for Save response
- Open sidebar on mobile
### ⚙️ Miscellaneous Tasks
- Prepare for tagged releases
## [0.1.0] - 2026-04-05
# Features
* In-app contacts
* For each contact:
* Names
* Birthday
* 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
* Mark contacts as inactive or prevent stale checks

249
Cargo.lock generated
View file

@ -400,39 +400,6 @@ dependencies = [
"litrs",
]
[[package]]
name = "camino"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "cargo_metadata"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]]
name = "cc"
version = "1.2.29"
@ -615,41 +582,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.106",
]
[[package]]
name = "debug-helper"
version = "0.3.13"
@ -677,37 +609,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.106",
]
[[package]]
name = "deunicode"
version = "1.6.2"
@ -1288,12 +1189,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.4.0"
@ -1385,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"
@ -1560,7 +1414,7 @@ dependencies = [
[[package]]
name = "mascarpone"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
@ -1573,7 +1427,6 @@ dependencies = [
"http",
"icalendar",
"itertools 0.14.0",
"jiff",
"listenfd",
"markdown",
"maud",
@ -1589,14 +1442,12 @@ dependencies = [
"thiserror 2.0.17",
"time",
"tokio",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
"vcard",
"vergen-gitcl",
]
[[package]]
@ -1756,9 +1607,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
@ -1790,15 +1641,6 @@ dependencies = [
"libm",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
@ -2005,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"
@ -2329,16 +2156,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.228"
@ -2874,32 +2691,30 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.47"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.27"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
@ -2986,9 +2801,9 @@ dependencies = [
[[package]]
name = "tower"
version = "0.5.3"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
"futures-core",
"futures-util",
@ -3322,46 +3137,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vergen"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
dependencies = [
"anyhow",
"cargo_metadata",
"derive_builder",
"regex",
"rustversion",
"time",
"vergen-lib",
]
[[package]]
name = "vergen-gitcl"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
dependencies = [
"anyhow",
"derive_builder",
"rustversion",
"time",
"vergen",
"vergen-lib",
]
[[package]]
name = "vergen-lib"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
dependencies = [
"anyhow",
"derive_builder",
"rustversion",
]
[[package]]
name = "version_check"
version = "0.9.5"

View file

@ -1,6 +1,6 @@
[package]
name = "mascarpone"
version = "0.2.0"
version = "0.1.0"
edition = "2024"
[profile.release]
@ -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"] }
@ -35,8 +34,7 @@ sqlx = { version = "0.8", features = ["macros", "runtim
thiserror = "2.0.17"
time = "0.3.44"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
tower = "0.5.3"
tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] }
tower-http = { version = "0.6.6", features = ["fs", "trace"] }
tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = { version = "0.1.41", features = ["attributes"] }
@ -45,4 +43,3 @@ vcard = "0.4.13"
[build-dependencies]
cache_bust = "0.2.0"
vergen-gitcl = { version = "9.1.0", features = ["build", "cargo"] }

View file

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

View file

@ -1,11 +1,11 @@
#!/usr/bin/env bash
playwright:local() {
bash e2e/Taskfile playwright:local "$@"
bash e2e/Taskfile playwright:local
}
playwright:ui() {
bash e2e/Taskfile playwright:ui "$@"
bash e2e/Taskfile playwright:ui
}
refresh_sqlx_db() {
@ -44,16 +44,4 @@ dev() {
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve
}
release() {
set -euo pipefail
bash e2e/Taskfile playwright:ci
_cargo test
new_tag=$(git-cliff --unreleased --bumped-version)
git tag -m "$new_tag" "$new_tag"
cargo set-version "${new_tag#v}"
mv CHANGELOG.md CHANGELOG.old
cat <(git cliff) <(printf "\n") CHANGELOG.old > CHANGELOG.md
rm CHANGELOG.old
}
"$@"

View file

@ -1,5 +1,4 @@
use cache_bust::CacheBust;
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
fn main() {
println!("cargo:rerun-if-changed=migrations");
@ -10,14 +9,4 @@ fn main() {
.build();
cache_bust.hash_dir().expect("Cache busting failed");
let build = BuildBuilder::all_build().expect("build information failed");
let gitcl = GitclBuilder::all_git().expect("gitcl information failed");
Emitter::default()
.add_instructions(&build)
.unwrap()
.add_instructions(&gitcl)
.unwrap()
.emit()
.unwrap();
}

View file

@ -1,94 +0,0 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
# A Tera template to be rendered for each release in the changelog.
# See https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
# Render body even when there are no releases to process.
render_always = true
# An array of regex based postprocessors to modify the changelog.
postprocessors = [
# Replace the placeholder <REPO> with a URL.
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
]
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = true
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = true
# Require all commits to be conventional.
# Takes precedence over filter_unconventional.
require_conventional = false
# Split commits on newlines, treating each line as an individual commit.
split_commits = false
# An array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit message using https://github.com/crate-ci/typos.
# If the spelling is incorrect, it will be fixed automatically.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# Prevent commits that are breaking from being excluded by commit parsers.
protect_breaking_commits = false
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->Documentation" },
{ message = "^perf", group = "<!-- 4 -->Performance" },
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# Fail on a commit that is not matched by any commit parser.
fail_on_unmatched_commit = false
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
link_parsers = []
# Include only the tags that belong to the current branch.
use_branch_tags = false
# Order releases topologically instead of chronologically.
topo_order = false
# Order commits topologically instead of chronologically.
topo_order_commits = true
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"
# Process submodules commits
recurse_submodules = false

View file

View file

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

View file

@ -4,11 +4,12 @@ import { login, verifyCreateUser, todate } from './util';
test.beforeEach(async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['Test Testerson'] });
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
});
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 }) => {
@ -18,84 +19,44 @@ test('last-contact date on display resolves journal mentions and manual-freshen'
const entryBox = page.getByPlaceholder(/new entry/i);
await entryDate.fill("2025-05-05");
await entryBox.fill("[[Test Testerson]]");
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload();
await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
});
test.skip("groups wrap nicely", async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
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 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.
});
test('allow marking as inactive', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
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.waitForURL(/contact\/\d+$/);
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 }) => {
});
twst('page title has contact primary name', async ({ page }) => {
await expect(page.title()).toContain("Test Testerson");
});
/*
home: contact list scrolls in screen, not off screen
home: clicking off contact list closes it
home: contact list is sorted ignoring case
home: contact list should scroll to current contact in center of view
journal: bullet points don't display
*/

View file

@ -17,9 +17,6 @@ test('has no contacts', async ({ page }) => {
test('can add contacts', async ({ page }) => {
await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack Contact'] });
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
});
@ -44,92 +41,5 @@ test('sidebar is sorted alphabetically', async ({ page }) => {
await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['Golf'] });
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
});
test('upcoming and recent show 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 yesterday = monthday((() => {
let date = new Date();
date.setDate(date.getDate() - 1);
return date;
})());
const tomorrow = monthday((() => {
let date = new Date();
date.setDate(date.getDate() + 1);
return date;
})());
const aMonthAgo = monthday((() => {
let date = new Date();
date.setDate(date.getDate() - 28);
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: yesterday });
await verifyCreateUser(page, { names: ['Echo'], birthday: today });
await verifyCreateUser(page, { names: ['Golf'], birthday: yesterday });
await verifyCreateUser(page, { names: ['Lima'], birthday: tomorrow });
await verifyCreateUser(page, { names: ['Mike'], birthday: yesterday });
await verifyCreateUser(page, { names: ['Xray'], birthday: inAMonth });
await verifyCreateUser(page, { names: ['Zulu'], birthday: aMonthAgo });
await page.goto('/');
await expect(page.locator('#upcoming').getByRole('link')).toHaveCount(4);
await expect(page.locator('#recent').getByRole('link')).toHaveCount(4);
});
test('contact list scrolls (independently) to current contact in center of view', async ({ page }) => {
for (let count = 0; count < 30; count++) {
await verifyCreateUser(page, { names: [`Contact${count < 10 ? '0' + count : count}`] });
}
await page.goto('/contact/28');
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await expect(page.getByRole('navigation').getByRole('link', { name: /Contact28/ })).toBeVisible();
expect(await page.locator('main').evaluate(e => e.scrollTop)).toEqual(0);
await page.goto('/contact/16');
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await expect(page.locator('#nav-link-16')).toBeVisible();
const linkPos: number = await page.locator('#nav-link-16').evaluate(e => e.getBoundingClientRect().y);
// roughly centered is fine, not that fussy about headers and whatnot
expect(await page.getByRole('navigation').evaluate(e => e.scrollTop)).not.toEqual(0);
expect(Math.abs(linkPos - (await page.evaluate('window.innerHeight/2') as number))).toBeLessThan(300);
});
test('clicking off contact list when expanded closes it', async ({ page }) => {
await page.setViewportSize({
width: 640,
height: 1000,
});
// TODO aria-label
await page.locator('#sidebar-show-hide').click();
await expect(page.getByRole('button', { name: /add contact/i })).toBeVisible();
await page.mouse.click(600, 500);
await expect(page.getByRole('button', { name: /add contact/i })).not.toBeVisible();
});
test('contact list is sorted ignoring case', async ({ page }) => {
await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['bob'] });
await verifyCreateUser(page, { names: ['Charlie'] });
await expect(page.locator('#contacts-sidebar')).toContainText(/alfa\s*bob\s*charlie/i);
});

View file

@ -6,9 +6,7 @@ test('can add journal entries', async ({ page }) => {
const entryBox = page.getByPlaceholder(/new entry/i);
await entryBox.fill('banana banana banana');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(entryBox).toBeEmpty();
await expect(page.getByText('banana banana banana')).toBeVisible();
@ -20,9 +18,7 @@ test('journal entries autolink', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible();
});
@ -33,9 +29,7 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await verifyCreateUser(page, { names: ['Jack Contact'] });
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
const nav = page.getByRole('navigation');
const journal = page.locator('#journal');
@ -43,21 +37,16 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// add a new name
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
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
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click();
@ -66,9 +55,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// put it back, then...
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
@ -78,9 +64,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// ...add a name that makes it no longer n=1
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'Jack Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC');
@ -89,9 +72,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// delete a name that makes it now n=1
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).click();
@ -106,9 +86,7 @@ test('can edit existing journal entries on home page', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana");
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload();
@ -131,9 +109,7 @@ test('can have multiple links', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1);

View file

@ -1,12 +1,10 @@
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
export const login = async (page: Page) => {
await page.goto('/login');
await page.goto('/');
await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click();
await page.waitForURL('/');
};
export const todate = () => new Date().toISOString().split('T')[0];
@ -16,11 +14,10 @@ type UserFields = {
birthday?: string,
};
export const verifyCreateUser = async (page: Page, fields: UserFields) => {
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await page.getByRole('button', { name: /add contact/i }).click();
await page.waitForURL(/contact\/\d+\/edit$/);
// TODO this is stupid but playwright kept filling while alpine was initializing
await page.waitForTimeout(200);
const { names, ...simple } = fields;
for (const name of (names ?? [])) {
@ -33,6 +30,5 @@ export const verifyCreateUser = async (page: Page, fields: UserFields) => {
}
await page.getByRole('button', { name: /save/i }).click();
await page.waitForURL(/contact\/\d+$/);
};

View file

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

View file

@ -1,5 +1,5 @@
insert into contacts(id, birthday, manually_freshened_at) values
(0, '04-15', '2000-01-01T12:00:00');
(0, '--0415', '2000-01-01T12:00:00');
insert into names(contact_id, sort, name) values
(0, 0, 'Alex Aaronson'),
(0, 1, 'Alexi'),
@ -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');
@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values
(1, 'ABC', 'abc');
insert into contacts(id, birthday) values
(2, '1995-10-18');
(2, '19951018');
insert into names(contact_id, sort, name) values
(2, 0, 'Charlie Certaindate');
insert into groups(contact_id, name, slug) values

View file

@ -1,8 +0,0 @@
alter table contacts add column
can_stale boolean not null default true;
alter table contacts add column
periodicity text not null default 'P0D';
alter table contacts add column
active boolean not null default true;

View file

@ -1,7 +0,0 @@
update contacts
set birthday = substr(birthday, 3, 2) || '-' || substr(birthday, 5, 2)
where birthday GLOB '--[01][0-9][0-3][0-9]';
update contacts
set birthday = substr(birthday, 1, 4) || '-' || substr(birthday, 5, 2) || '-' || substr(birthday, 7, 2)
where birthday GLOB '[0-9][0-9][0-9][0-9][01][0-9][0-3][0-9]';

View file

@ -2,4 +2,3 @@
"rust-analyzer" = "latest"
"jj" = "latest"
node = "24"
git-cliff = "latest"

View file

@ -12,9 +12,7 @@ use std::sync::{Arc, RwLock};
use tokio::net::TcpListener;
use tokio::signal;
use tokio::task::AbortHandle;
use tower::ServiceBuilder;
use tower_http::services::{ServeDir,ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -126,8 +124,6 @@ enum Commands {
ephemeral: bool,
},
/// print version information
Version,
}
async fn serve(port: &u32) -> Result<(), anyhow::Error> {
@ -184,19 +180,8 @@ async fn serve(port: &u32) -> Result<(), anyhow::Error> {
.route_layer(login_required!(Backend, login_url = "/login"))
.merge(auth::router())
.merge(ics::router())
.nest_service(
"/static",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
http::header::CACHE_CONTROL,
http::header::HeaderValue::from_static("public, max-age=31536000, immutable"),
))
.service(ServeDir::new("./hashed_static")),
)
.nest_service(
"/favicon.ico",
ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))),
)
.nest_service("/static", ServeDir::new("./hashed_static"))
.nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
.layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state);
@ -246,10 +231,7 @@ async fn main() -> Result<(), anyhow::Error> {
println!("No update was made; probably something went wrong.");
}
}
Some(Commands::SetEphemeral {
username,
ephemeral,
}) => {
Some(Commands::SetEphemeral { username, ephemeral }) => {
let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true)
@ -260,20 +242,19 @@ async fn main() -> Result<(), anyhow::Error> {
db
};
let eph: Option<bool> =
sqlx::query_scalar("select ephemeral from users where username = ?")
let eph: Option<bool> = sqlx::query_scalar(
"select ephemeral from users where username = ?"
)
.bind(&username)
.fetch_optional(&users_db)
.await?;
if let Some(eph) = eph {
if eph == *ephemeral {
println!(
"User {} is already {}.",
username,
if eph { "ephemeral" } else { "not ephemeral" }
);
println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" });
} else {
let update = sqlx::query("update users set ephemeral=$1 where username = $2")
let update = sqlx::query(
"update users set ephemeral=$1 where username = $2",
)
.bind(ephemeral)
.bind(&username)
.execute(&users_db)
@ -286,19 +267,13 @@ async fn main() -> Result<(), anyhow::Error> {
}
}
} else {
println!(
"User {} does not exist. Create them first with set-password.",
username
);
println!("User {} does not exist. Create them first with set-password.", username);
}
}
Some(Commands::Serve { port }) => {
serve(port).await?;
}
Some(Commands::Version) => {
println!("mascarpone v{}", env!("CARGO_PKG_VERSION"));
println!("from git commit {}", env!("VERGEN_GIT_SHA"));
}
None => {
serve(&3000).await?;
}

View file

@ -1,4 +1,4 @@
use jiff::{Timestamp, Unit, Zoned, civil, tz::TimeZone};
use chrono::Local;
use sqlx::sqlite::SqliteRow;
use sqlx::{FromRow, Row};
use std::fmt::Display;
@ -20,40 +20,42 @@ pub enum Birthday {
impl Display for Birthday {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Birthday::Date(date) => write!(f, "{}", date),
Birthday::Text(t) => write!(f, "{}", t.value),
}
let str = match self {
Birthday::Date(date) => date.to_string(),
Birthday::Text(t) => t.value.clone(),
};
write!(f, "{}", str)
}
}
impl Birthday {
pub fn next_occurrence(&self) -> Option<civil::Date> {
pub fn next_occurrence(&self) -> Option<chrono::NaiveDate> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => date.next_month_day_occurrence(),
Birthday::Date(date) => Some(date.next_month_day_occurrence()?),
}
}
pub fn until_next(&self) -> Option<jiff::Span> {
pub fn until_next(&self) -> Option<chrono::TimeDelta> {
self.next_occurrence()
.map(|when| when.since(Zoned::now().date()).ok())?
.map(|when| when.signed_duration_since(Local::now().date_naive()))
}
/// None if this is a text birthday or doesn't have a year
pub fn age(&self) -> Option<i32> {
pub fn age(&self) -> Option<u32> {
match &self {
Birthday::Text(_) => None,
Birthday::Date(date) => {
let now = Timestamp::now().to_zoned(TimeZone::UTC);
date.to_civil_date().map(|birthdate| {
now.since(&birthdate.to_zoned(TimeZone::UTC).unwrap())
.unwrap()
.total((Unit::Year, &now))
.unwrap() as i32
})
Birthday::Date(date) => date
.to_date_naive()
.map(|birthdate| Local::now().date_naive().years_since(birthdate))?,
}
}
pub fn serialize(&self) -> String {
match &self {
Birthday::Text(text) => text.value.clone(),
Birthday::Date(date) => date.serialize(),
}
}
}

View file

@ -1,4 +1,4 @@
use jiff::{Span, Timestamp, civil::Date};
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::sqlite::SqlitePool;
use std::str::FromStr;
@ -12,20 +12,14 @@ struct RawContact {
birthday: Option<String>,
manually_freshened_at: Option<String>,
lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
}
#[derive(Clone, Debug)]
pub struct Contact {
pub id: DbId,
pub birthday: Option<Birthday>,
pub manually_freshened_at: Option<Timestamp>,
pub manually_freshened_at: Option<DateTime<Utc>>,
pub lives_with: String,
pub can_stale: bool,
pub active: bool,
pub periodicity: Span,
}
impl Into<Contact> for RawContact {
@ -37,11 +31,9 @@ impl Into<Contact> for RawContact {
.and_then(|s| Birthday::from_str(s.as_ref()).ok()),
manually_freshened_at: self
.manually_freshened_at
.and_then(|str| str.parse::<Timestamp>().ok()),
.and_then(|str| DateTime::parse_from_str(str.as_ref(), "%+").ok())
.map(|d| d.to_utc()),
lives_with: self.lives_with,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity.parse().unwrap(),
}
}
}
@ -51,10 +43,6 @@ struct RawHydratedContact {
birthday: Option<String>,
manually_freshened_at: Option<String>,
lives_with: String,
can_stale: bool,
active: bool,
periodicity: String,
last_mention_date: Option<String>,
names: Option<String>,
}
@ -62,7 +50,7 @@ struct RawHydratedContact {
#[derive(Clone, Debug)]
pub struct HydratedContact {
pub contact: Contact,
pub last_mention_date: Option<Date>,
pub last_mention_date: Option<NaiveDate>,
pub names: Vec<String>,
}
@ -74,9 +62,6 @@ impl Into<HydratedContact> for RawHydratedContact {
birthday: self.birthday,
manually_freshened_at: self.manually_freshened_at,
lives_with: self.lives_with,
can_stale: self.can_stale,
active: self.active,
periodicity: self.periodicity,
}),
names: self
.names
@ -86,7 +71,7 @@ impl Into<HydratedContact> for RawHydratedContact {
.collect::<Vec<String>>(),
last_mention_date: self
.last_mention_date
.and_then(|str| str.parse::<Date>().ok()),
.and_then(|str| NaiveDate::from_str(str.as_ref()).ok()),
}
}
}
@ -107,27 +92,11 @@ impl HydratedContact {
}
}
pub fn status(&self) -> &'static str {
if self.can_stale {
if self.active { "normal" } else { "inactive" }
} else {
"permanent"
}
}
pub async fn load(id: DbId, pool: &SqlitePool) -> Result<Self, AppError> {
// copy-paste the query from 'all', then add "where c.id = $2" to the last line
let raw = sqlx::query_as!(
RawHydratedContact,
r#"select
id,
birthday,
lives_with,
manually_freshened_at as "manually_freshened_at: String",
can_stale,
active,
periodicity,
(
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (
@ -154,15 +123,7 @@ impl HydratedContact {
pub async fn all(pool: &SqlitePool) -> Result<Vec<Self>, AppError> {
let contacts = sqlx::query_as!(
RawHydratedContact,
r#"select
id,
birthday,
lives_with,
manually_freshened_at as "manually_freshened_at: String",
can_stale,
active,
periodicity,
(
r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", (
select string_agg(name,x'1c' order by sort)
from names where contact_id = c.id
) as names, (

View file

@ -1,4 +1,4 @@
use jiff::civil::Date;
use chrono::NaiveDate;
use maud::{Markup, html};
use serde_json::json;
use sqlx::sqlite::{SqlitePool, SqliteRow};
@ -12,7 +12,7 @@ use crate::switchboard::{MentionHost, MentionHostType};
pub struct JournalEntry {
pub id: DbId,
pub value: String,
pub date: Date,
pub date: NaiveDate,
}
impl<'a> Into<MentionHost<'a>> for &'a JournalEntry {
@ -69,7 +69,7 @@ impl FromRow<'_, SqliteRow> for JournalEntry {
let id: DbId = row.try_get("id")?;
let value: String = row.try_get("value")?;
let date_str: &str = row.try_get("date")?;
let date: Date = date_str.parse().unwrap();
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap();
Ok(Self { id, value, date })
}
}

View file

@ -1,4 +1,4 @@
use jiff::{Timestamp, civil::Date, tz::TimeZone};
use chrono::{Datelike, Local, NaiveDate};
use regex::Regex;
use sqlx::{Database, Decode, Encode, Sqlite, encode::IsNull};
use std::fmt::Display;
@ -6,43 +6,51 @@ use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct YearOptionalDate {
pub year: Option<i16>,
pub month: i8,
pub day: i8,
pub year: Option<i32>,
pub month: u32,
pub day: u32,
}
impl YearOptionalDate {
pub fn prev_month_day_occurrence(&self) -> Option<Date> {
let now = Timestamp::now().to_zoned(TimeZone::UTC);
pub fn prev_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
Date::new(year, self.month, self.day).ok().and_then(|date| {
if date >= now.date() {
Date::new(year - 1, self.month, self.day).ok()
} else {
Some(date)
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date >= now.date_naive() {
date = NaiveDate::from_ymd_opt(year - 1, self.month, self.day);
}
})
}
date
}
pub fn next_month_day_occurrence(&self) -> Option<NaiveDate> {
let now = Local::now();
let year = now.year();
let mut date = NaiveDate::from_ymd_opt(year, self.month, self.day);
if let Some(real_date) = date {
if real_date < now.date_naive() {
date = NaiveDate::from_ymd_opt(year + 1, self.month, self.day);
}
}
date
}
pub fn next_month_day_occurrence(&self) -> Option<Date> {
let now = Timestamp::now().to_zoned(TimeZone::UTC);
let year = now.year();
Date::new(year, self.month, self.day).ok().and_then(|date| {
if date < now.date() {
Date::new(year + 1, self.month, self.day).ok()
} else {
Some(date)
}
})
}
pub fn to_civil_date(&self) -> Option<Date> {
pub fn to_date_naive(&self) -> Option<NaiveDate> {
if let Some(year) = self.year {
Date::new(year, self.month, self.day).ok()
NaiveDate::from_ymd_opt(year, self.month, self.day)
} else {
None
}
}
pub fn serialize(&self) -> String {
format!(
"{}{:0>2}{:0>2}",
self.year.map_or("--".to_string(), |y| format!("{:0>4}", y)),
self.month,
self.day
)
}
}
impl Display for YearOptionalDate {
@ -57,18 +65,21 @@ impl Display for YearOptionalDate {
impl FromStr for YearOptionalDate {
type Err = anyhow::Error;
fn from_str(str: &str) -> Result<Self, Self::Err> {
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) {
let year = caps
.get(1)
.map(|yyyy| i16::from_str(yyyy.as_str()).unwrap());
let month = i8::from_str(&caps[2]).unwrap();
let day = i8::from_str(&caps[3]).unwrap();
let year_str = &caps[1];
let month = u32::from_str(&caps[2]).unwrap();
let day = u32::from_str(&caps[3]).unwrap();
let year = if year_str == "--" {
None
} else {
Some(i32::from_str(year_str).unwrap())
};
return Ok(Self { year, month, day });
}
Err(anyhow::Error::msg(format!(
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}-)?[0-9]{{2}}-[0-9]{{2}}/",
"parsing failure in YearOptionalDate: '{}' does not match regex /([0-9]{{4}}|--)[0-9]{{4}}/",
str
)))
}
@ -98,6 +109,6 @@ where
&self,
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
<String as Encode<'r, Sqlite>>::encode(self.to_string(), buf)
<String as Encode<'r, Sqlite>>::encode(self.serialize(), buf)
}
}

View file

@ -109,6 +109,14 @@ impl Switchboard {
}
}
pub fn remove(self: &mut Self, text: &String) {
self.trie.remove(text);
}
pub fn add_mentionable(self: &mut Self, text: String, uri: String) {
self.trie.insert(text, uri);
}
pub fn extract_mentions<'a>(&self, host: impl Into<MentionHost<'a>>) -> HashSet<Mention> {
let host: MentionHost = host.into();
let name_re = Regex::new(r"\[\[(.+?)\]\]").unwrap();

View file

@ -81,28 +81,22 @@ mod get {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Mascarpone CRM" }
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
title { "Mascarpone" }
}
body hx-ext="response-targets" {
h1 { "Mascarpone" }
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '', htmx: false }" "x-on:htmx:load.document"="htmx = true" {
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" {
label for="username" { "Username" }
input name="username" #username autofocus x-model="user" x-cloak;
input name="username" #username autofocus x-model="user";
label for="password" { "Password" }
input name="password" #password type="password" x-model="pass" x-cloak;
input name="password" #password type="password" x-model="pass";
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length && htmx)" hx-disabled-elt;
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)";
#error {}
}
}

View file

@ -7,19 +7,19 @@ use axum::{
};
use axum_extra::extract::Form;
use cache_bust::asset;
use jiff::{Timestamp, Unit, tz::TimeZone};
use chrono::DateTime;
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use slug::slugify;
use sqlx::QueryBuilder;
use sqlx::{QueryBuilder, Sqlite};
use super::Layout;
use super::home::journal_section;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType, Switchboard, insert_mentions};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions, Switchboard};
use crate::{AppError, AppState};
pub mod fields;
@ -40,41 +40,27 @@ pub fn router() -> Router<AppState> {
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
}
fn human_delta(span: &jiff::Span) -> String {
let todate = Timestamp::now().to_zoned(TimeZone::UTC).date();
let span = span
.round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
if span.is_zero() {
"today".to_string()
} else {
format!("in {:#}", span)
fn human_delta(delta: &chrono::TimeDelta) -> String {
if delta.num_days() == 0 {
return "today".to_string();
}
let mut result = "in ".to_string();
let mut rem = delta.clone();
if rem.num_days().abs() >= 7 {
let weeks = rem.num_days() / 7;
rem -= chrono::TimeDelta::days(weeks * 7);
result.push_str(&format!("{}w ", weeks));
}
if rem.num_days().abs() > 0 {
result.push_str(&format!("{}d ", rem.num_days()));
}
result.trim().to_string()
}
mod get {
use super::*;
fn scroll_to(id: DbId) -> String {
format!(
"\
const top=document\
.getElementById('nav-link-{}')\
?.getBoundingClientRect()\
?.top;\
top&&document\
.getElementById('contacts-sidebar')\
.scrollTo({{top:top-window.innerHeight/2,left:0,behavior:'instant'}});",
id
)
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
@ -102,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),
);
@ -139,21 +123,13 @@ mod get {
html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div #fields x-init=(scroll_to(contact_id)) {
div id="fields" {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div {
@for name in &contact.names {
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 {
@ -234,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)
@ -263,7 +233,7 @@ mod get {
div #error;
}
#fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) x-cloak {
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" {
@ -279,40 +249,16 @@ mod get {
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
}
}
label for="status" { "status" }
label { "birthday" }
div {
select #status name="status" x-model=("status") {
option value="normal" { "Normal" }
option value="permanent" { "Cannot go stale" }
option value="inactive" { "Inactive" }
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
}
}
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 for="birthday" { "birthday" }
div {
input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}")));
span .hint { code { "(yyyy-)?mm-dd" } " 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": "" })) {
@ -378,8 +324,6 @@ mod put {
#[derive(Deserialize)]
pub struct PutContact {
name: Option<Vec<String>>,
status: String,
periodicity: Option<String>,
birthday: String,
manually_freshened_at: String,
lives_with: String,
@ -407,22 +351,17 @@ mod put {
Some(payload.birthday)
};
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() {
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
None
} else {
Some(
payload
.manually_freshened_at
.parse::<Timestamp>()
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_string(),
.to_utc()
.to_rfc3339(),
)
};
let active: bool = payload.status != "inactive";
let can_stale: bool = payload.status != "permanent";
let periodicity: String = payload.periodicity.unwrap_or("P0D".to_string());
let text_body = if payload.text_body.is_empty() {
None
} else {
@ -435,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
@ -552,18 +488,22 @@ mod put {
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
if old_names != new_names {
sqlx::query!("delete from names where contact_id = $1", contact_id)
sqlx::query!(
"delete from names where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
if !new_names.is_empty() {
QueryBuilder::new("insert into names (contact_id, sort, name) ")
.push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
b.push_bind(contact_id)
QueryBuilder::new(
"insert into names (contact_id, sort, name) "
).push_values(new_names.iter().enumerate(), |mut b, (sort, name)| {
b
.push_bind(contact_id)
.push_bind(DbId::try_from(sort).unwrap())
.push_bind(name);
})
.build()
}).build()
.persistent(false)
.execute(pool)
.await?;
@ -584,7 +524,10 @@ mod put {
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
if new_groups != old_groups {
sqlx::query!("delete from groups where contact_id = $1", contact_id)
sqlx::query!(
"delete from groups where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
@ -623,6 +566,7 @@ mod put {
.await?;
}
if regen_text_body {
sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = $2",

View file

@ -1,7 +1,7 @@
use axum::extract::State;
use axum::response::IntoResponse;
use cache_bust::asset;
use jiff::{Timestamp, ToSpan, 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,63 +46,36 @@ fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppErro
struct KnownBirthdayContact {
contact_id: i64,
display: String,
prev_birthday: civil::Date,
next_birthday: civil::Date,
prev_birthday: NaiveDate,
next_birthday: NaiveDate,
}
fn birthdays_section(
prev_birthdays: &Vec<KnownBirthdayContact>,
upcoming_birthdays: &Vec<KnownBirthdayContact>,
) -> Result<Markup, AppError> {
let now = Timestamp::now().to_zoned(TimeZone::UTC);
let in_a_week = upcoming_birthdays
.iter()
.position(|b| {
now.until(&b.next_birthday.to_zoned(TimeZone::UTC).unwrap())
.unwrap()
.compare((&1_i32.week(), &now))
.unwrap()
!= std::cmp::Ordering::Less
})
.unwrap_or(upcoming_birthdays.len());
let upcoming = &upcoming_birthdays
[0..std::cmp::min(std::cmp::max(3, in_a_week + 1), upcoming_birthdays.len())];
let a_week_ago = prev_birthdays
.iter()
.position(|b| {
now.since(&b.prev_birthday.to_zoned(TimeZone::UTC).unwrap())
.unwrap()
.compare((&1_i32.week(), &now))
.unwrap()
!= std::cmp::Ordering::Less
})
.unwrap_or(upcoming_birthdays.len());
let recent =
&prev_birthdays[0..std::cmp::min(std::cmp::max(3, a_week_ago + 1), prev_birthdays.len())];
Ok(html! {
div id="birthdays" {
h2 { "Birthdays" }
#birthday-sections {
.datelist #upcoming {
.datelist {
h3 { "upcoming" }
@for contact in 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 #recent {
.datelist {
h3 { "recent" }
@for contact in 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"))
}
}
}
@ -129,8 +102,8 @@ pub async fn journal_section(
are now, or leave everything blank to default to 'today'. Entries will be
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()" x-cloak {
input name="date" placeholder=(Zoned::now().date().to_string());
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
input name="date" placeholder=(Local::now().date_naive().to_string());
textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry";
}
@ -161,60 +134,57 @@ pub mod get {
let mut freshens: Vec<ContactFreshness> = contacts
.clone()
.into_iter()
.filter_map(|contact| {
if !contact.can_stale || !contact.active {
return None;
}
let zero = jiff::civil::Date::ZERO;
.map(|contact| {
let zero = NaiveDate::from_epoch_days(0).unwrap();
let fresh_date = std::cmp::max(
contact
.manually_freshened_at
.map(|ts| ts.to_zoned(TimeZone::UTC).date())
.map(|x| x.date_naive())
.unwrap_or(zero),
contact.last_mention_date.unwrap_or(zero),
);
if fresh_date == zero {
Some(ContactFreshness {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: "never".to_string(),
elapsed_str: "".to_string(),
})
}
} else {
let utc = TimeZone::UTC;
let todate = Timestamp::now().to_zoned(utc.clone()).date();
let elapsed = todate
.since(&fresh_date.to_zoned(utc).unwrap())
.unwrap()
.round(
jiff::SpanRound::new()
.largest(Unit::Year)
.smallest(Unit::Day)
.relative(todate),
)
.unwrap();
if let Some(cmp) = elapsed.compare((contact.periodicity, todate)).ok() {
if cmp == std::cmp::Ordering::Less {
return None;
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
let mut elapsed: Vec<String> = Vec::new();
let y = duration.num_weeks() / 52;
let count = |n: i64, noun: &str| {
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
};
if y > 0 {
elapsed.push(count(y, "year"));
duration -= TimeDelta::weeks(y * 52);
}
let w = duration.num_weeks();
if w > 0 {
elapsed.push(count(w, "week"));
duration -= TimeDelta::weeks(w);
}
let d = duration.num_days();
if d > 0 {
elapsed.push(count(d, "day"));
}
let elapsed_str = if elapsed.is_zero() {
let elapsed_str = if elapsed.is_empty() {
"today".to_string()
} else {
format!("{:#}", elapsed)
elapsed.join(", ")
};
Some(ContactFreshness {
ContactFreshness {
contact_id: contact.id,
display: contact.display_name(),
fresh_date,
fresh_str: fresh_date.to_string(),
elapsed_str,
})
}
}
})
.collect();
@ -227,8 +197,8 @@ pub mod get {
Some(KnownBirthdayContact {
contact_id: contact.id,
display: contact.display_name(),
prev_birthday: date.prev_month_day_occurrence()?,
next_birthday: date.next_month_day_occurrence()?,
prev_birthday: date.prev_month_day_occurrence().unwrap(),
next_birthday: date.next_month_day_occurrence().unwrap(),
})
} else {
None

View file

@ -61,9 +61,9 @@ mod get {
for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt(
yo_date.year.unwrap_or(1900).into(),
yo_date.month.try_into().unwrap(),
yo_date.day.try_into().unwrap(),
yo_date.year.unwrap_or(1900),
yo_date.month,
yo_date.day,
) {
calendar.push(
Event::new()

View file

@ -4,14 +4,14 @@ use axum::{
response::IntoResponse,
routing::{delete, patch, post},
};
use jiff::{Zoned, civil::Date};
use chrono::{Datelike, Local, NaiveDate};
use maud::Markup;
use regex::Regex;
use serde::Deserialize;
use crate::models::JournalEntry;
use crate::models::user::AuthSession;
use crate::switchboard::{MentionHostType, insert_mentions};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
use crate::{AppError, AppState, DbId};
pub fn router() -> Router<AppState> {
@ -39,10 +39,10 @@ mod post {
let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
let now = Zoned::now();
let now = Local::now().date_naive();
let date = if payload.date.is_empty() {
now.date()
now
} else {
let date_re =
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
@ -54,16 +54,17 @@ mod post {
// unwrapping these parses is safe since it's matching [0-9]{2,4}
let year = caps
.name("year")
.map(|m| m.as_str().parse::<i16>().unwrap())
.map(|m| m.as_str().parse::<i32>().unwrap())
.unwrap_or(now.year());
let month = caps
.name("month")
.map(|m| m.as_str().parse::<i8>().unwrap())
.map(|m| m.as_str().parse::<u32>().unwrap())
.unwrap_or(now.month());
let day = caps.name("day").unwrap().as_str().parse::<i8>().unwrap();
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
Date::new(year, month, day)
.map_err(|_| anyhow::Error::msg("invalid date: failed NaiveDate construction"))?
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
"invalid date: failed NaiveDate construction",
))?
};
// not a macro query, we want to use JournalEntry's custom FromRow
@ -130,6 +131,7 @@ mod patch {
insert_mentions(&mentions, pool).await?;
}
Ok(new_entry.to_html(pool).await?)
}
}

View file

@ -25,7 +25,6 @@ struct ContactLink {
#[derive(Debug)]
pub struct Layout {
contact_links: Vec<ContactLink>,
inactive_contact_links: Vec<ContactLink>,
user: User,
}
@ -49,40 +48,20 @@ impl FromRequestParts<AppState> for Layout {
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = true
order by name collate nocase 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 collate nocase asc",
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
Ok(Layout {
contact_links,
inactive_contact_links,
user,
})
}
}
impl Layout {
pub fn render(
&self,
title: impl AsRef<str>,
css: Option<Vec<&str>>,
content: Markup,
) -> Markup {
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup {
html! {
(DOCTYPE)
html {
@ -94,15 +73,9 @@ impl Layout {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@ -118,23 +91,10 @@ impl Layout {
a href="/logout" { "Logout" }
}
section #content {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
ul {
li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links {
li {
a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
}
@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)
@ -143,14 +103,11 @@ impl Layout {
}
}
}
}
}
}
}
main {
(content)
}
}
template #alpine-loaded x-cloak {}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
(function(){var f;var o="hx-target-";function g(e,r){return e.substring(0,r.length)===r}function s(e,r){if(!e||!r)return null;var t=r.toString();var s=[t,t.substring(0,2)+"*",t.substring(0,2)+"x",t.substring(0,1)+"*",t.substring(0,1)+"x",t.substring(0,1)+"**",t.substring(0,1)+"xx","*","x","***","xxx"];if(g(t,"4")||g(t,"5")){s.push("error")}for(var n=0;n<s.length;n++){var i=o+s[n];var a=f.getClosestAttributeValue(e,i);if(a){if(a==="this"){return f.findThisElement(e,i)}else{return f.querySelectorExt(e,a)}}}return null}function n(e){if(e.detail.isError){if(htmx.config.responseTargetUnsetsError){e.detail.isError=false}}else if(htmx.config.responseTargetSetsError){e.detail.isError=true}}htmx.defineExtension("response-targets",{init:function(e){f=e;if(htmx.config.responseTargetUnsetsError===undefined){htmx.config.responseTargetUnsetsError=true}if(htmx.config.responseTargetSetsError===undefined){htmx.config.responseTargetSetsError=false}if(htmx.config.responseTargetPrefersExisting===undefined){htmx.config.responseTargetPrefersExisting=false}if(htmx.config.responseTargetPrefersRetargetHeader===undefined){htmx.config.responseTargetPrefersRetargetHeader=true}},onEvent:function(e,r){if(e==="htmx:beforeSwap"&&r.detail.xhr&&r.detail.xhr.status!==200){if(r.detail.target){if(htmx.config.responseTargetPrefersExisting){r.detail.shouldSwap=true;n(r);return true}if(htmx.config.responseTargetPrefersRetargetHeader&&r.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)){r.detail.shouldSwap=true;n(r);return true}}if(!r.detail.requestConfig){return true}var t=s(r.detail.requestConfig.elt,r.detail.xhr.status);if(t){n(r);r.detail.shouldSwap=true;r.detail.target=t}return true}}})})();

1
static/htmx.min.js vendored

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,7 @@ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abb
body {
width: 100%;
height: 100vh;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
@ -35,7 +35,6 @@ section#content {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
@media only screen and (max-width: 650px) {
position: relative;
}
@ -45,8 +44,6 @@ section#content {
display: flex;
flex-direction: column;
padding-right: 1em;
height: 100%;
overflow-y: auto;
@media only screen and (max-width: 650px) {
position: absolute;
float: left;
@ -57,12 +54,11 @@ section#content {
height: 100%;
&.hide {
left: -200%;
visibility: hidden;
display: none;
}
}
& > ul {
ul {
flex: 1;
width: fit-content;
background-color: var(--main-bg-color);
@ -83,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 {
@ -107,5 +98,3 @@ a, a:visited {
color: var(--link-color);
text-decoration: underline dotted;
}
[x-cloak] { display: none !important; }