feat: inactive contacts hidden in sidebar

This commit is contained in:
Robert Perce 2026-04-03 16:03:16 -05:00
parent f75260c079
commit b079001cc5
7 changed files with 123 additions and 58 deletions

View file

@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => {
test('manual-freshen date is editable', async ({ page }) => { test('manual-freshen date is editable', async ({ page }) => {
await page.getByRole('link', { name: /edit/i }).click(); await page.getByRole('link', { name: /edit/i }).click();
await expect(page.getByRole('textbox', { name: /freshened/i })).toBeVisible(); await expect(page.locator('input[name="manually_freshened_on"]')).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 }) => {
@ -38,7 +38,15 @@ test.skip("groups wrap nicely", async ({ page }) => {
}); });
test('allow marking as inactive', 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');
// TODO: this only works if there's no other comboboxes on the page :/
await page.getByRole('combobox').selectOption('Inactive')
await page.getByRole('button', { name: /save/i }).click();
await expect(page.locator('#alpine-loaded')).not.toHaveAttribute('x-cloak');
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 }) => {
@ -50,7 +58,7 @@ test('stale list considers periodicity', async ({ page }) => {
}); });
test('page title has contact primary name', async ({ page }) => { test('page title has contact primary name', async ({ page }) => {
await expect(page.title()).toContain("Test Testerson"); expect(await page.title()).toContain("Test Testerson");
}); });

View file

@ -43,7 +43,6 @@ test("changing a contact's names updates journal entries", async ({ page }) => {
await page.getByRole('button', { name: 'Add' }).nth(1).click(); await page.getByRole('button', { name: 'Add' }).nth(1).click();
await page.getByRole('button', { name: /save/i }).click(); await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('link', { name: 'Mascarpone' }).click(); await page.getByRole('link', { name: 'Mascarpone' }).click();
console.log(await journal.innerHTML());
await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1); await expect.soft(journal.getByRole('link', { name: 'JC' })).toHaveCount(1);
// delete an existing name // delete an existing name

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

@ -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) values insert into contacts(id, birthday, active) values
(1, 'April?'); (1, 'April?', false);
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');

View file

@ -223,7 +223,9 @@ mod get {
let mfresh_str = contact let mfresh_str = contact
.manually_freshened_at .manually_freshened_at
.clone() .clone()
.map_or("".to_string(), |m| m.to_string()); .map_or("".to_string(), |m| {
m.to_zoned(TimeZone::UTC).date().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)
@ -278,10 +280,20 @@ mod get {
span .hint { code { "(yyyy|--)mmdd" } " or free text" } span .hint { code { "(yyyy|--)mmdd" } " or free text" }
} }
label { "freshened" } label { "freshened" }
div x-data=(json!({ "date": mfresh_str })) { div x-data=(json!({ "date": mfresh_str, "stamp": "" })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
input type="hidden" name="manually_freshened_at" x-model="date"; input
span x-text="date.length ? date.split('T')[0] : '(never)'" {} type="hidden"
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()"; name="manually_freshened_at"
x-model="stamp";
input
type="date"
name="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": "" })) {

View file

@ -25,6 +25,7 @@ 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,
} }
@ -48,6 +49,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",
)
.fetch_all(&state.db(&user).pool)
.await?;
let inactive_contact_links = sqlx::query_as!(
ContactLink,
"select c.id as contact_id,
coalesce(n.name, '(unnamed)') as name
from contacts c
left join names n on c.id = n.contact_id
where n.sort is null or n.sort = 0
and c.active = false
order by name asc", order by name asc",
) )
.fetch_all(&state.db(&user).pool) .fetch_all(&state.db(&user).pool)
@ -55,13 +70,19 @@ impl FromRequestParts<AppState> for Layout {
Ok(Layout { Ok(Layout {
contact_links, contact_links,
inactive_contact_links,
user, user,
}) })
} }
} }
impl Layout { impl Layout {
pub fn render(&self, title: impl AsRef<str>, css: Option<Vec<&str>>, content: Markup) -> Markup { pub fn render(
&self,
title: impl AsRef<str>,
css: Option<Vec<&str>>,
content: Markup,
) -> Markup {
html! { html! {
(DOCTYPE) (DOCTYPE)
html { html {
@ -101,6 +122,23 @@ impl Layout {
} }
} }
} }
@if !self.inactive_contact_links.is_empty() {
li .inactive {
details {
summary { "Inactive contacts" }
ul {
@for link in &self.inactive_contact_links {
li {
a href=(format!("/contact/{}", link.contact_id)) {
(link.name)
}
}
}
}
}
}
}
} }
} }
main { main {

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%;
min-height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -35,6 +35,7 @@ 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;
} }
@ -44,6 +45,8 @@ 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;
@ -58,7 +61,7 @@ section#content {
} }
} }
ul { & > ul {
flex: 1; flex: 1;
width: fit-content; width: fit-content;
background-color: var(--main-bg-color); background-color: var(--main-bg-color);
@ -79,12 +82,17 @@ 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 {