Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

20 changed files with 66 additions and 534 deletions

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

192
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import { login, verifyCreateUser, todate } from './util';
test.beforeEach(async ({ page }) => {
await login(page);
await verifyCreateUser(page, { names: ['Test Testerson'] });
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
});
test('manual-freshen date is editable', async ({ page }) => {
@ -18,21 +19,19 @@ test('last-contact date on display resolves journal mentions and manual-freshen'
const entryBox = page.getByPlaceholder(/new entry/i);
await entryDate.fill("2025-05-05");
await entryBox.fill("[[Test Testerson]]");
let load = page.waitForResponse('/journal_entry');
await page.getByRole('button', { name: /add entry/i }).click();
await load;
await page.reload();
await expect(page.locator('#fields')).toContainText("freshened2025-05-05");
});
test.skip("groups wrap nicely", async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
const groupBox = page.getByPlaceholder(/group name/i);
await groupBox.fill('this is a long group name');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
// TODO: this drives to the right location but i can't figure out how to assert
// that the text is all on one line. Manual inspection looks good at time of writing.
@ -40,6 +39,7 @@ test.skip("groups wrap nicely", async ({ page }) => {
test('allow marking as inactive', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
await page.getByLabel('status').selectOption('Inactive');
await page.getByRole('button', { name: /save/i }).click();
@ -55,7 +55,6 @@ test('allow exempting from stale', async ({ page }) => {
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');
});
@ -97,5 +96,9 @@ test('bullet points in free text display well', async ({ page }) => {
});
home: contact list scrolls in screen, not off screen
home: clicking off contact list closes it
home: contact list is sorted ignoring case
home: contact list should scroll to current contact in center of view
journal: bullet points don't display
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -61,20 +61,6 @@ fn human_delta(span: &jiff::Span) -> String {
mod get {
use super::*;
fn scroll_to(id: DbId) -> String {
format!(
"\
const top=document\
.getElementById('nav-link-{}')\
?.getBoundingClientRect()\
?.top;\
top&&document\
.getElementById('contacts-sidebar')\
.scrollTo({{top:top-window.innerHeight/2,left:0,behavior:'instant'}});",
id
)
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
@ -139,7 +125,7 @@ mod get {
html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div #fields x-init=(scroll_to(contact_id)) {
div id="fields" {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div {
@for name in &contact.names {
@ -263,7 +249,7 @@ mod get {
div #error;
}
#fields x-data=(json!({ "status": contact.status() })) x-init=(scroll_to(contact_id)) x-cloak {
#fields x-data=(json!({ "status": contact.status() })){
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" {

View file

@ -129,7 +129,7 @@ pub async fn journal_section(
are now, or leave everything blank to default to 'today'. Entries will be
added to the top of the list regardless of date; refresh the page to re-sort."
}
form hx-post="/journal_entry" hx-target="next .entries" hx-target-error="#journal-error" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" x-cloak {
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());
textarea name="value" placeholder="New entry..." autofocus {}
input type="submit" value="Add Entry";

View file

@ -50,7 +50,7 @@ impl FromRequestParts<AppState> for Layout {
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = true
order by name collate nocase asc",
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
@ -63,7 +63,7 @@ impl FromRequestParts<AppState> for Layout {
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = false
order by name collate nocase asc",
order by name asc",
)
.fetch_all(&state.db(&user).pool)
.await?;
@ -94,15 +94,9 @@ impl Layout {
link rel="manifest" href=(format!("/static/{}", asset!("site.webmanifest")));
link rel="stylesheet" type="text/css" href=(format!("/static/{}", asset!("index.css")));
meta name="viewport" content="width=device-width";
@if cfg!(debug_assertions) {
script src=(format!("/static/{}", asset!("htmx.min.js"))) defer {}
script src=(format!("/static/{}", asset!("htmx-ext-response-targets.js"))) defer {}
script src=(format!("/static/{}", asset!("alpinejs.min.js"))) defer {}
} @else {
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" defer {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" defer {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
}
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
@if let Some(hrefs) = css {
@for href in hrefs {
link rel="stylesheet" type="text/css" href=(format!("/static/{}", href));
@ -118,12 +112,12 @@ impl Layout {
a href="/logout" { "Logout" }
}
section #content {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" "x-on:click.self"="sidebar = !sidebar" {
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
ul {
li { button hx-post="/contact/new" { "+ Add Contact" } }
@for link in &self.contact_links {
li {
a id=(format!("nav-link-{}", link.contact_id)) href=(format!("/contact/{}", link.contact_id)) {
a href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
@ -151,6 +145,7 @@ impl Layout {
(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

@ -57,8 +57,7 @@ section#content {
height: 100%;
&.hide {
left: -200%;
visibility: hidden;
display: none;
}
}
@ -107,5 +106,3 @@ a, a:visited {
color: var(--link-color);
text-decoration: underline dotted;
}
[x-cloak] { display: none !important; }