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/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

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", "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]] [[package]]
name = "cc" name = "cc"
version = "1.2.29" version = "1.2.29"
@ -615,41 +582,6 @@ dependencies = [
"typenum", "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]] [[package]]
name = "debug-helper" name = "debug-helper"
version = "0.3.13" version = "0.3.13"
@ -677,37 +609,6 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "deunicode" name = "deunicode"
version = "1.6.2" version = "1.6.2"
@ -1288,12 +1189,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.4.0" version = "0.4.0"
@ -1385,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"
@ -1560,7 +1414,7 @@ dependencies = [
[[package]] [[package]]
name = "mascarpone" name = "mascarpone"
version = "0.2.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@ -1573,7 +1427,6 @@ dependencies = [
"http", "http",
"icalendar", "icalendar",
"itertools 0.14.0", "itertools 0.14.0",
"jiff",
"listenfd", "listenfd",
"markdown", "markdown",
"maud", "maud",
@ -1589,14 +1442,12 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"time", "time",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tower-sessions", "tower-sessions",
"tower-sessions-sqlx-store", "tower-sessions-sqlx-store",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"vcard", "vcard",
"vergen-gitcl",
] ]
[[package]] [[package]]
@ -1756,9 +1607,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
@ -1790,15 +1641,6 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.37.3" version = "0.37.3"
@ -2005,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"
@ -2329,16 +2156,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -2874,32 +2691,30 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
"libc",
"num-conv", "num-conv",
"num_threads",
"powerfmt", "powerfmt",
"serde_core", "serde",
"time-core", "time-core",
"time-macros", "time-macros",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.8" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.27" version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@ -2986,9 +2801,9 @@ dependencies = [
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
@ -3322,46 +3137,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 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]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "mascarpone" name = "mascarpone"
version = "0.2.0" version = "0.1.0"
edition = "2024" edition = "2024"
[profile.release] [profile.release]
@ -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"] }
@ -35,8 +34,7 @@ sqlx = { version = "0.8", features = ["macros", "runtim
thiserror = "2.0.17" thiserror = "2.0.17"
time = "0.3.44" time = "0.3.44"
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] }
tower = "0.5.3" tower-http = { version = "0.6.6", features = ["fs", "trace"] }
tower-http = { version = "0.6.6", features = ["fs", "set-header", "trace"] }
tower-sessions = { version = "0.14.0", features = ["signed"] } tower-sessions = { version = "0.14.0", features = ["signed"] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] } tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = { version = "0.1.41", features = ["attributes"] } tracing = { version = "0.1.41", features = ["attributes"] }
@ -45,4 +43,3 @@ vcard = "0.4.13"
[build-dependencies] [build-dependencies]
cache_bust = "0.2.0" 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 * Last-contact-time mapping
* Address as single field (plus code? lat/long? go crazy!) * Address as single field (plus code? lat/long? go crazy!)
* Free-text-entry field * Free-text-entry field
* Desired contact periodicity
* Journal with Obsidian-like `[[link]]` syntax * Journal with Obsidian-like `[[link]]` syntax
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* ical server for birthday reminders * ical server for birthday reminders
## Explore
My instance is at https://crm.rperce.net. Username "demo" and password "demo" let
you log into an ephemeral demo user if you want to poke around.
If you want an account, contact me directly or use the "self-hosting" instructions below.
## Planned features ## Planned features
* Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server * Report birthdays and manage add'l fields for contacts stored on a remote CardDAV server
* Act as CardDAV server for other clients * Act as CardDAV server for other clients
* For each contact: * For each contact:
* Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar * Arbitrary add'l yearly dates (e.g. anniversaries) that show on calendar
* Relationship mapping * Relationship mapping
* Desired contact periodicity
* Additional arbitrary fields (no special handling) * Additional arbitrary fields (no special handling)
* Contact groups (e.g. "Met with `[[Brunch Bunch]]` at the diner")
* "Named in journal but has no contact entry" detection * "Named in journal but has no contact entry" detection
* Email birthday reminders over SMTP * Email birthday reminders over SMTP
---
## Development / self-hosting
1. Clone the repo.
2. Build for your system with `./Taskfile _cargo build --release`.
3. Deploy the binary from `./target/release/mascarpone` to wherever you want that's in PATH
(or use it from here if you want)
4. In the working directory that you want the server to save its databases in,
1. Create a user for yourself with `mascarpone set-password YOUR_USERNAME`. This will create a `users.db` file.
2. Run `mkdir dbs`.
3. Copy the `hashed_static` directory from the code repository.
5. Run `mascarpone serve [port]` from that working directory. The default port is 3000.
If you need to be able to bind to a host other than `0.0.0.0`, contact me directly.
### Example systemd service file
```
[Unit]
Description=Mascarpone CRM
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/local/mascarpone/
ExecStart=/usr/bin/mascarpone serve
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```

View file

@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
playwright:local() { playwright:local() {
bash e2e/Taskfile playwright:local "$@" bash e2e/Taskfile playwright:local
} }
playwright:ui() { playwright:ui() {
bash e2e/Taskfile playwright:ui "$@" bash e2e/Taskfile playwright:ui
} }
refresh_sqlx_db() { refresh_sqlx_db() {
@ -44,16 +44,4 @@ dev() {
find src static migrations | entr -ccdr ./Taskfile _cargo run -- serve 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 cache_bust::CacheBust;
use vergen_gitcl::{BuildBuilder, Emitter, GitclBuilder};
fn main() { fn main() {
println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=migrations");
@ -10,14 +9,4 @@ fn main() {
.build(); .build();
cache_bust.hash_dir().expect("Cache busting failed"); 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 \ 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() {

View file

@ -4,11 +4,12 @@ import { login, verifyCreateUser, todate } from './util';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await verifyCreateUser(page, { names: ['Test Testerson'] }); await verifyCreateUser(page, { names: ['Test Testerson'] });
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
}); });
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 }) => {
@ -18,84 +19,44 @@ test('last-contact date on display resolves journal mentions and manual-freshen'
const entryBox = page.getByPlaceholder(/new entry/i); const entryBox = page.getByPlaceholder(/new entry/i);
await entryDate.fill("2025-05-05"); await entryDate.fill("2025-05-05");
await entryBox.fill("[[Test Testerson]]"); await entryBox.fill("[[Test Testerson]]");
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload(); await page.reload();
await expect(page.locator('#fields')).toContainText("freshened2025-05-05"); await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
}); });
test.skip("groups wrap nicely", async ({ page }) => { test.skip("groups wrap nicely", async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click(); 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); 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');
// 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 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.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 }) => { 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 journal: bullet points don't display
*/ */

View file

@ -2,134 +2,44 @@ import { test, expect } from '@playwright/test';
import { login, verifyCreateUser, todate } from './util'; import { login, verifyCreateUser, todate } from './util';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
}); });
test('can log out', async ({ page }) => { test('can log out', async ({ page }) => {
await page.getByText("Logout").click(); await page.getByText("Logout").click();
await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible();
}); });
test('has no contacts', async ({ page }) => { test('has no contacts', async ({ page }) => {
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0);
}); });
test('can add contacts', async ({ page }) => { test('can add contacts', async ({ page }) => {
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await verifyCreateUser(page, { names: ['Jack Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] });
if (await page.locator('#sidebar-show-hide').isVisible()) { await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
await page.locator('#sidebar-show-hide').click();
}
await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2);
}); });
test('shows "never" for unfreshened contacts', async ({ page }) => { test('shows "never" for unfreshened contacts', async ({ page }) => {
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await expect(page.locator('#freshness')).toContainText('John Contactnever'); await expect(page.locator('#freshness')).toContainText('John Contactnever');
}); });
test('shows the date for fresh contacts', async ({ page }) => { test('shows the date for fresh contacts', async ({ page }) => {
await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['John Contact'] });
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: /fresh/i }).click(); await page.getByRole('button', { name: /fresh/i }).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();
await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`); await expect(page.locator('#freshness')).toContainText(`John Contact${todate()}`);
}); });
test('sidebar is sorted alphabetically', async ({ page }) => { test('sidebar is sorted alphabetically', async ({ page }) => {
await verifyCreateUser(page, { names: ['Zulu'] }); await verifyCreateUser(page, { names: ['Zulu'] });
await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Alfa'] });
await verifyCreateUser(page, { names: ['Golf'] }); await verifyCreateUser(page, { names: ['Golf'] });
if (await page.locator('#sidebar-show-hide').isVisible()) { await expect(page.getByRole('navigation')).toHaveText(/Alfa\s*Golf\s*Zulu/);
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); const entryBox = page.getByPlaceholder(/new entry/i);
await entryBox.fill('banana banana banana'); await entryBox.fill('banana banana banana');
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click(); await page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(entryBox).toBeEmpty(); await expect(entryBox).toBeEmpty();
await expect(page.getByText('banana banana banana')).toBeVisible(); 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.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[John Contact]]'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
await expect(page.locator('#journal').getByRole('link', { name: 'John Contact' })).toBeVisible(); 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 verifyCreateUser(page, { names: ['Jack Contact'] });
await page.getByPlaceholder(/new entry/i).fill('met with [[JC]]'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
const nav = page.getByRole('navigation'); const nav = page.getByRole('navigation');
const journal = page.locator('#journal'); 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); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// add a new name // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); await page.getByRole('textbox', { name: 'New name' }).fill('JC');
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
if (await page.locator('#sidebar-show-hide').isVisible()) {
await page.locator('#sidebar-show-hide').click();
}
await nav.getByRole("link", { name: 'John Contact' }).click(); await nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).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); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// put it back, then... // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); 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); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// ...add a name that makes it no longer n=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 nav.getByRole("link", { name: 'Jack Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('textbox', { name: 'New name' }).fill('JC'); 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); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(0);
// delete a name that makes it now n=1 // 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 nav.getByRole("link", { name: 'John Contact' }).click();
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await page.getByRole('button', { name: '×', disabled: false }).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.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill("[[John Contact]]'s banana banana banana"); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload(); await page.reload();
@ -131,9 +109,7 @@ test('can have multiple links', async ({ page }) => {
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
await page.getByPlaceholder(/new entry/i).fill('met with [[alice]] and [[bob]] and their kids'); 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 page.getByRole('button', { name: /add entry/i }).click();
await load;
const journal = page.locator('#journal'); const journal = page.locator('#journal');
await expect.soft(journal.getByRole('link', { name: 'alice' })).toHaveCount(1); 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'; import type { Page } from '@playwright/test';
export const login = async (page: Page) => { export const login = async (page: Page) => {
await page.goto('/login'); await page.goto('/');
await page.getByLabel("Username").fill("test"); await page.getByLabel("Username").fill("test");
await page.getByLabel("Password").fill("test"); await page.getByLabel("Password").fill("test");
await page.getByRole("button", { name: /login/i }).click(); await page.getByRole("button", { name: /login/i }).click();
await page.waitForURL('/');
}; };
export const todate = () => new Date().toISOString().split('T')[0]; export const todate = () => new Date().toISOString().split('T')[0];
@ -16,11 +14,10 @@ type UserFields = {
birthday?: string, birthday?: string,
}; };
export const verifyCreateUser = async (page: Page, fields: UserFields) => { 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.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; const { names, ...simple } = fields;
for (const name of (names ?? [])) { 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.getByRole('button', { name: /save/i }).click();
await page.waitForURL(/contact\/\d+$/);
}; };

View file

@ -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,
}); });

View file

@ -1,5 +1,5 @@
insert into contacts(id, birthday, manually_freshened_at) values 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 insert into names(contact_id, sort, name) values
(0, 0, 'Alex Aaronson'), (0, 0, 'Alex Aaronson'),
(0, 1, 'Alexi'), (0, 1, 'Alexi'),
@ -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');
@ -16,7 +16,7 @@ insert into groups(contact_id, name, slug) values
(1, 'ABC', 'abc'); (1, 'ABC', 'abc');
insert into contacts(id, birthday) values insert into contacts(id, birthday) values
(2, '1995-10-18'); (2, '19951018');
insert into names(contact_id, sort, name) values insert into names(contact_id, sort, name) values
(2, 0, 'Charlie Certaindate'); (2, 0, 'Charlie Certaindate');
insert into groups(contact_id, name, slug) values 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" "rust-analyzer" = "latest"
"jj" = "latest" "jj" = "latest"
node = "24" node = "24"
git-cliff = "latest"

View file

@ -12,9 +12,7 @@ use std::sync::{Arc, RwLock};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::signal; use tokio::signal;
use tokio::task::AbortHandle; use tokio::task::AbortHandle;
use tower::ServiceBuilder; use tower_http::services::{ServeDir,ServeFile};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key}; use tower_sessions::{ExpiredDeletion, Expiry, SessionManagerLayer, cookie::Key};
use tower_sessions_sqlx_store::SqliteStore; use tower_sessions_sqlx_store::SqliteStore;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -126,8 +124,6 @@ enum Commands {
ephemeral: bool, ephemeral: bool,
}, },
/// print version information
Version,
} }
async fn serve(port: &u32) -> Result<(), anyhow::Error> { 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")) .route_layer(login_required!(Backend, login_url = "/login"))
.merge(auth::router()) .merge(auth::router())
.merge(ics::router()) .merge(ics::router())
.nest_service( .nest_service("/static", ServeDir::new("./hashed_static"))
"/static", .nest_service("/favicon.ico", ServeFile::new(format!("./hashed_static/{}", asset!("favicon.ico"))))
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"))),
)
.layer(auth_layer) .layer(auth_layer)
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state); .with_state(state);
@ -246,10 +231,7 @@ async fn main() -> Result<(), anyhow::Error> {
println!("No update was made; probably something went wrong."); println!("No update was made; probably something went wrong.");
} }
} }
Some(Commands::SetEphemeral { Some(Commands::SetEphemeral { username, ephemeral }) => {
username,
ephemeral,
}) => {
let users_db = { let users_db = {
let db_options = SqliteConnectOptions::from_str("users.db")? let db_options = SqliteConnectOptions::from_str("users.db")?
.create_if_missing(true) .create_if_missing(true)
@ -260,24 +242,23 @@ async fn main() -> Result<(), anyhow::Error> {
db db
}; };
let eph: Option<bool> = let eph: Option<bool> = sqlx::query_scalar(
sqlx::query_scalar("select ephemeral from users where username = ?") "select ephemeral from users where username = ?"
.bind(&username) )
.fetch_optional(&users_db) .bind(&username)
.await?; .fetch_optional(&users_db)
.await?;
if let Some(eph) = eph { if let Some(eph) = eph {
if eph == *ephemeral { if eph == *ephemeral {
println!( println!("User {} is already {}.", username, if eph { "ephemeral" } else { "not ephemeral" });
"User {} is already {}.",
username,
if eph { "ephemeral" } else { "not ephemeral" }
);
} else { } else {
let update = sqlx::query("update users set ephemeral=$1 where username = $2") let update = sqlx::query(
.bind(ephemeral) "update users set ephemeral=$1 where username = $2",
.bind(&username) )
.execute(&users_db) .bind(ephemeral)
.await?; .bind(&username)
.execute(&users_db)
.await?;
if update.rows_affected() > 0 { if update.rows_affected() > 0 {
println!("Updated ephemerality for {}.", username); println!("Updated ephemerality for {}.", username);
@ -286,19 +267,13 @@ async fn main() -> Result<(), anyhow::Error> {
} }
} }
} else { } else {
println!( println!("User {} does not exist. Create them first with set-password.", username);
"User {} does not exist. Create them first with set-password.",
username
);
} }
} }
Some(Commands::Serve { port }) => { Some(Commands::Serve { port }) => {
serve(port).await?; serve(port).await?;
} }
Some(Commands::Version) => {
println!("mascarpone v{}", env!("CARGO_PKG_VERSION"));
println!("from git commit {}", env!("VERGEN_GIT_SHA"));
}
None => { None => {
serve(&3000).await?; 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::sqlite::SqliteRow;
use sqlx::{FromRow, Row}; use sqlx::{FromRow, Row};
use std::fmt::Display; use std::fmt::Display;
@ -20,39 +20,41 @@ pub enum Birthday {
impl Display for Birthday { impl Display for Birthday {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self { let str = match self {
Birthday::Date(date) => write!(f, "{}", date), Birthday::Date(date) => date.to_string(),
Birthday::Text(t) => write!(f, "{}", t.value), Birthday::Text(t) => t.value.clone(),
} };
write!(f, "{}", str)
} }
} }
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 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 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, (

View file

@ -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 })
} }
} }

View file

@ -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,43 +6,51 @@ 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
} }
} }
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 { impl Display for YearOptionalDate {
@ -57,18 +65,21 @@ impl Display for YearOptionalDate {
impl FromStr for YearOptionalDate { impl FromStr for YearOptionalDate {
type Err = anyhow::Error; type Err = anyhow::Error;
fn from_str(str: &str) -> Result<Self, Self::Err> { 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) { if let Some(caps) = date_re.captures(str) {
let year = caps let year_str = &caps[1];
.get(1) let month = u32::from_str(&caps[2]).unwrap();
.map(|yyyy| i16::from_str(yyyy.as_str()).unwrap()); let day = u32::from_str(&caps[3]).unwrap();
let month = i8::from_str(&caps[2]).unwrap(); let year = if year_str == "--" {
let day = i8::from_str(&caps[3]).unwrap(); None
} else {
Some(i32::from_str(year_str).unwrap())
};
return Ok(Self { year, month, day }); return Ok(Self { year, month, day });
} }
Err(anyhow::Error::msg(format!( 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 str
))) )))
} }
@ -98,6 +109,6 @@ where
&self, &self,
buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>, buf: &mut <Sqlite as Database>::ArgumentBuffer<'r>,
) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> { ) -> 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

@ -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();

View file

@ -81,28 +81,22 @@ mod get {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
title { "Mascarpone CRM" } title { "Mascarpone CRM" }
meta name="viewport" content="width=device-width"; meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) { script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src=(format!("/static/{}", asset!("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" {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {} script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.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/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!("index.css")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("login.css")));
title { "Mascarpone" } title { "Mascarpone" }
} }
body hx-ext="response-targets" { body hx-ext="response-targets" {
h1 { "Mascarpone" } 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" } 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" } 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 {} #error {}
} }
} }

View file

@ -7,19 +7,19 @@ use axum::{
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use cache_bust::asset; use cache_bust::asset;
use 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,41 +40,27 @@ 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 {
use super::*; 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( pub async fn contact(
auth_session: AuthSession, auth_session: AuthSession,
State(state): State<AppState>, State(state): State<AppState>,
@ -102,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),
); );
@ -139,21 +123,13 @@ mod get {
html! { html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" } 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" }} label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div { div {
@for name in &contact.names { @for name in &contact.names {
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 {
@ -234,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)
@ -254,7 +224,7 @@ mod get {
Ok(layout.render( Ok(layout.render(
format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))), format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))),
Some(vec![asset!("contact.css")]), Some(vec![asset!("contact.css")]),
html! { html! {
form hx-ext="response-targets" { form hx-ext="response-targets" {
div { div {
@ -263,7 +233,7 @@ mod get {
div #error; 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" }} 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" {
@ -279,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" } label { "birthday" }
div { div {
select #status name="status" x-model=("status") { input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
option value="normal" { "Normal" } span .hint { code { "(yyyy|--)mmdd" } " or free text" }
option value="permanent" { "Cannot go stale" }
option value="inactive" { "Inactive" }
}
} }
label x-show="status === 'normal'" for="periodicity" { "minimum stale time" } label { "freshened" }
div x-show="status === 'normal'"{ div x-data=(json!({ "date": mfresh_str })) {
input name="periodicity" id="periodicity" value=(format!("{:#}", contact.periodicity)); input type="hidden" name="manually_freshened_at" x-model="date";
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" } ")" } 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 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 { "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": "" })) {
@ -378,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,
@ -407,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 {
@ -435,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
@ -552,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?;
} }
} }
@ -584,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) ")
@ -623,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",

View file

@ -1,7 +1,7 @@
use axum::extract::State; use axum::extract::State;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use cache_bust::asset; use cache_bust::asset;
use jiff::{Timestamp, ToSpan, 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,63 +46,36 @@ 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>,
upcoming_birthdays: &Vec<KnownBirthdayContact>, upcoming_birthdays: &Vec<KnownBirthdayContact>,
) -> Result<Markup, AppError> { ) -> 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! { Ok(html! {
div id="birthdays" { div id="birthdays" {
h2 { "Birthdays" } h2 { "Birthdays" }
#birthday-sections { #birthday-sections {
.datelist #upcoming { .datelist {
h3 { "upcoming" } h3 { "upcoming" }
@for contact in upcoming { @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 #recent { .datelist {
h3 { "recent" } 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)) { 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"))
} }
} }
} }
@ -129,8 +102,8 @@ pub async fn journal_section(
are now, or leave everything blank to default to 'today'. Entries will be 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." 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 { 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";
} }
@ -161,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();
@ -227,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

View file

@ -61,9 +61,9 @@ mod get {
for contact in &contacts { for contact in &contacts {
if let Some(Birthday::Date(yo_date)) = &contact.birthday { if let Some(Birthday::Date(yo_date)) = &contact.birthday {
if let Some(date) = NaiveDate::from_ymd_opt( if let Some(date) = NaiveDate::from_ymd_opt(
yo_date.year.unwrap_or(1900).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()

View file

@ -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?)
} }
} }

View file

@ -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,40 +48,20 @@ impl FromRequestParts<AppState> for Layout {
from contacts c from contacts c
left join names n on c.id = n.contact_id left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0 where n.sort is null or n.sort = 0
and c.active = true order by name asc",
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",
) )
.fetch_all(&state.db(&user).pool) .fetch_all(&state.db(&user).pool)
.await?; .await?;
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 {
@ -94,15 +73,9 @@ impl Layout {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest"))); link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css"))); link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width"; meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) { script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src=(format!("/static/{}", asset!("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" {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {} script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.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/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
@if let Some(hrefs) = css { @if let Some(hrefs) = css {
@for href in hrefs { @for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href)); link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@ -118,39 +91,23 @@ impl Layout {
a href="/logout" { "Logout" } a href="/logout" { "Logout" }
} }
section #content { 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 { ul {
li { button hx-post="/contact/new" { "+ Add Contact" } } li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links { @for link in &self.contact_links {
li { li {
a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) { a href=(format!("/contact/{}", link.contact_id)) {
(link.name) (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)
}
}
}
}
}
}
}
} }
} }
main { main {
(content) (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 { 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;
@ -57,12 +54,11 @@ section#content {
height: 100%; height: 100%;
&.hide { &.hide {
left: -200%; display: none;
visibility: hidden;
} }
} }
& > ul { ul {
flex: 1; flex: 1;
width: fit-content; width: fit-content;
background-color: var(--main-bg-color); background-color: var(--main-bg-color);
@ -83,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 {
@ -107,5 +98,3 @@ a, a:visited {
color: var(--link-color); color: var(--link-color);
text-decoration: underline dotted; text-decoration: underline dotted;
} }
[x-cloak] { display: none !important; }