From 6d6018aa32e397ce18d26f0251e762fd1bea3c1b Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Wed, 4 Feb 2026 13:32:03 -0600 Subject: [PATCH 1/3] fix: store refresh-at as string type, not date type, for sqlx autotyping --- Taskfile | 3 ++- migrations/each_user/0012_contact_fresh_type.sql | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Taskfile b/Taskfile index 211d1e8..9eeaa50 100755 --- a/Taskfile +++ b/Taskfile @@ -12,7 +12,8 @@ refresh_sqlx_db() { rm -f some_user.db for migration in migrations/each_user/*.sql; do echo "Applying $migration..." - sqlite3 some_user.db < "$migration" + echo "BEGIN TRANSACTION;$(cat "$migration");COMMIT TRANSACTION;"\ + | sqlite3 some_user.db done } diff --git a/migrations/each_user/0012_contact_fresh_type.sql b/migrations/each_user/0012_contact_fresh_type.sql index a9685ef..7a5f8bb 100644 --- a/migrations/each_user/0012_contact_fresh_type.sql +++ b/migrations/each_user/0012_contact_fresh_type.sql @@ -1,5 +1,12 @@ +-- foreign_keys can only up/down outside of transactions +-- so we first pre-commit the one started by sqlx... +COMMIT TRANSACTION; +-- turn off foreign keys... PRAGMA foreign_keys=OFF; + +-- start our own transaction... +BEGIN TRANSACTION; create table if not exists new_contacts ( id integer primary key autoincrement, birthday text, @@ -16,4 +23,12 @@ insert into new_contacts ( drop table contacts; alter table new_contacts rename to contacts; PRAGMA foreign_key_check; + +-- commit our own transaction... +COMMIT TRANSACTION; + +-- put our own pragmas back... PRAGMA foreign_keys=ON; + +-- and start a dummy transaction so sqlx's COMMIT doesn't explode +BEGIN TRANSACTION; From 7e2f5d0a18a439b0a6e88a554a85a8b87a8d2a6a Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Wed, 4 Feb 2026 14:07:01 -0600 Subject: [PATCH 2/3] fix: correct contact entry display --- src/web/contact/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index 8eea1b0..bdeac88 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -75,7 +75,7 @@ mod get { let entries: Vec = sqlx::query_as( "select distinct j.id, j.value, j.date from journal_entries j join mentions m on j.id = m.entity_id - where m.entity_type = $1 and (m.url = '/contact/'||$1 or m.url in ( + where m.entity_type = $1 and (m.url = '/contact/'||$2 or m.url in ( select '/group/'||slug from groups where contact_id = $2 )) From b0630a25e117c176c12654465c7cca6d9ea51e65 Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sat, 14 Feb 2026 13:35:59 -0600 Subject: [PATCH 3/3] wip: more tests --- e2e/custom-expects.ts | 37 ++++++++++++++++++++++ e2e/pages/contact.spec.ts | 64 +++++++++++++++++++++++++++++++++++++++ e2e/pages/home.spec.ts | 10 ++---- e2e/playwright.config.ts | 1 + src/web/contact/mod.rs | 9 ++++-- src/web/mod.rs | 1 + static/contact.css | 2 +- 7 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 e2e/custom-expects.ts create mode 100644 e2e/pages/contact.spec.ts diff --git a/e2e/custom-expects.ts b/e2e/custom-expects.ts new file mode 100644 index 0000000..9b169e0 --- /dev/null +++ b/e2e/custom-expects.ts @@ -0,0 +1,37 @@ +import { expect, type Locator } from '@playwright/test'; +expect.extend({ + async toBeAbove(self: Locator, other: Locator) { + const name = 'toBeAbove'; + let pass: boolean; + let matcherResult: any; + let selfY: number | null = null; + let otherY: number | null = null; + try { + selfY = (await self.boundingBox())?.y ?? null; + otherY = (await self.boundingBox())?.y ?? null; + pass = selfY !== null && otherY !== null && (selfY < otherY); + } catch (e: any) { + matcherResult = e.matcherResult; + pass = false; + } + + if (this.isNot) { + pass =!pass; + } + + const message = () => this.utils.matcherHint(name, undefined, undefined, { isNot: this.isNot }) + + '\n\n' + + `Locator: ${self}\n` + + `Expected: above ${other} (y=${this.utils.printExpected(otherY)})\n` + + (matcherResult ? `Received: y=${this.utils.printReceived(selfY)}` : ''); + + return { + message, + pass, + name, + expected: (this.isNot ? '>=' : '<') + otherY, + actual: selfY, + }; + + } +}); diff --git a/e2e/pages/contact.spec.ts b/e2e/pages/contact.spec.ts new file mode 100644 index 0000000..25f0798 --- /dev/null +++ b/e2e/pages/contact.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import { login, verifyCreateUser, todate } from './util'; + +test.beforeEach(async ({ page }) => { + await login(page); + await verifyCreateUser(page, { names: ['Test Testerson'] }); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); +}); + +test('manual-freshen date is editable', async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); +}); + +test('last-contact date on display resolves journal mentions and manual-freshen', async ({ page }) => { + const today = new Date().toISOString().split("T")[0]; + const todayRe = new RegExp(today.substring(0, today.length - 1) + "."); + const entryDate = page.getByPlaceholder(todayRe); + const entryBox = page.getByPlaceholder(/new entry/i); + await entryDate.fill("2025-05-05"); + await entryBox.fill("[[Test Testerson]]"); + await page.getByRole('button', { name: /add entry/i }).click(); + await page.reload(); + await expect(page.locator('#fields')).toContainText("freshened2025-05-05"); +}); + +test.skip("groups wrap nicely", async ({ page }) => { + await page.getByRole('link', { name: /edit/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + const groupBox = page.getByPlaceholder(/group name/i); + await groupBox.fill('this is a long group name'); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak'); + + // TODO: this drives to the right location but i can't figure out how to assert + // that the text is all on one line. Manual inspection looks good at time of writing. +}); + +test('allow marking as hidden', async ({ page }) => { + +}); + +test('allow exempting from stale', async ({ page }) => { + +}); + +test('something is fucky with lives_with insertion triggering mention generation', async ({ page }) => { + +}); + +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: saving journal entry should stay in edit +journal: sometimes editing entries fucks up mentions (probably another $1/$2 error) +journal: bullet points don't display +*/ diff --git a/e2e/pages/home.spec.ts b/e2e/pages/home.spec.ts index 7d70bbb..13b9fa1 100644 --- a/e2e/pages/home.spec.ts +++ b/e2e/pages/home.spec.ts @@ -1,28 +1,26 @@ import { test, expect } from '@playwright/test'; import { login, verifyCreateUser, todate } from './util'; -test('can log out', async ({ page }) => { +test.beforeEach(async ({ page }) => { await login(page); +}); +test('can log out', async ({ page }) => { await page.getByText("Logout").click(); await expect(page.getByLabel("Username")).toBeVisible(); }); test('has no contacts', async ({ page }) => { - await login(page); - await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(0); }); test('can add contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await verifyCreateUser(page, { names: ['Jack Contact'] }); await expect(page.getByRole("navigation").getByRole("link")).toHaveCount(2); }); test('shows "never" for unfreshened contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await page.getByRole('link', { name: 'Mascarpone' }).click(); @@ -30,7 +28,6 @@ test('shows "never" for unfreshened contacts', async ({ page }) => { }); test('shows the date for fresh contacts', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['John Contact'] }); await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('button', { name: /fresh/i }).click(); @@ -40,7 +37,6 @@ test('shows the date for fresh contacts', async ({ page }) => { }); test('sidebar is sorted alphabetically', async ({ page }) => { - await login(page); await verifyCreateUser(page, { names: ['Zulu'] }); await verifyCreateUser(page, { names: ['Alfa'] }); await verifyCreateUser(page, { names: ['Golf'] }); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f1295d4..1065269 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import 'custom-expects'; // purposefully not using ??: we want to replace empty empty string with default const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; diff --git a/src/web/contact/mod.rs b/src/web/contact/mod.rs index bdeac88..525090c 100644 --- a/src/web/contact/mod.rs +++ b/src/web/contact/mod.rs @@ -87,6 +87,11 @@ mod get { .fetch_all(pool) .await?; + let freshened = std::cmp::max( + contact.manually_freshened_at.map(|when| when.date_naive()), + entries.get(0).map(|entry| entry.date), + ); + let phone_numbers: Vec = sqlx::query_as!( PhoneNumber, "select * from phone_numbers where contact_id = $1", @@ -141,8 +146,8 @@ mod get { } label { "freshened" } div { - @if let Some(when) = &contact.manually_freshened_at { - (when.date_naive().to_string()) + @if let Some(freshened) = freshened { + (freshened.to_string()) } @else { "(never)" } diff --git a/src/web/mod.rs b/src/web/mod.rs index 66ab59c..bfee974 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -102,6 +102,7 @@ impl Layout { (content) } } + template #alpine-loaded x-cloak {} } } } diff --git a/static/contact.css b/static/contact.css index d725284..765ff62 100644 --- a/static/contact.css +++ b/static/contact.css @@ -47,7 +47,7 @@ main { #groups { display: flex; flex-direction: column; - width: min-content; + width: fit-content; } #text_body {