From 0c25f265f5f149130d5ef06a856586d0acfdf9c2 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:19:40 -0400 Subject: [PATCH 01/15] Add contact tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-form tags per contact: editable via a selectize-style input in the contact detail view (type to filter existing tags, Enter/comma to create, × to remove). Tags surface as a filterable column in both AllContacts and ProjectView. Syncs across shared projects via the existing sub-table merge/push pattern. Closes #418 Co-Authored-By: Claude Sonnet 4.6 --- src/main/database/schema.ts | 10 + src/main/database/shared-schema.ts | 9 + src/main/ipc/contacts.ts | 39 ++- src/main/sync/engine.ts | 29 ++ src/preload/index.ts | 5 + src/renderer/src/contacts/GlobalTab.tsx | 7 + src/renderer/src/contacts/TagsSection.css | 96 ++++++ src/renderer/src/contacts/TagsSection.tsx | 118 +++++++ src/renderer/src/env.d.ts | 3 + src/renderer/src/hooks/useProjectContacts.ts | 3 + src/renderer/src/views/AllContacts.css | 19 ++ src/renderer/src/views/AllContacts.tsx | 11 + src/renderer/src/views/ContactsTable.tsx | 42 ++- src/renderer/src/views/ProjectView.tsx | 9 +- src/shared/types.ts | 3 + src/test/tags.test.ts | 321 +++++++++++++++++++ 16 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/contacts/TagsSection.css create mode 100644 src/renderer/src/contacts/TagsSection.tsx create mode 100644 src/test/tags.test.ts diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 724577df..6934d840 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -229,6 +229,16 @@ export const LOCAL_SCHEMA_DDL_SQL = ` captured_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS contact_tags ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + synced_at INTEGER, + UNIQUE(contact_id, tag) + ); + + CREATE INDEX IF NOT EXISTS idx_contact_tags_contact_id ON contact_tags(contact_id); CREATE INDEX IF NOT EXISTS idx_contact_emails_contact_id ON contact_emails(contact_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_emails_contact_email ON contact_emails(contact_id, email); CREATE INDEX IF NOT EXISTS idx_contact_phones_contact_id ON contact_phones(contact_id); diff --git a/src/main/database/shared-schema.ts b/src/main/database/shared-schema.ts index ee059d8b..63c081a0 100644 --- a/src/main/database/shared-schema.ts +++ b/src/main/database/shared-schema.ts @@ -56,6 +56,14 @@ export const SHARED_SCHEMA_SQL = ` created_at INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS contact_tags ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(contact_id, tag) + ); + CREATE TABLE IF NOT EXISTS contact_alert_rss ( id TEXT PRIMARY KEY, contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, @@ -109,6 +117,7 @@ export const SHARED_SCHEMA_SQL = ` value TEXT NOT NULL ); + CREATE INDEX IF NOT EXISTS idx_shared_contact_tags_contact_id ON contact_tags(contact_id); CREATE INDEX IF NOT EXISTS idx_shared_contact_emails_contact_id ON contact_emails(contact_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_shared_contact_emails_contact_email ON contact_emails(contact_id, email); CREATE INDEX IF NOT EXISTS idx_shared_contact_phones_contact_id ON contact_phones(contact_id); diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index 0180767d..f5aaa630 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -174,7 +174,8 @@ export function registerContactHandlers(): void { FROM (SELECT pm.project_id AS pid, p.name AS pname FROM project_memberships pm JOIN projects p ON p.id = pm.project_id WHERE pm.contact_id = c.id - ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw + ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw, + (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY tag) AS tags_raw FROM contacts c ORDER BY c.name COLLATE NOCASE ASC`, ) @@ -192,6 +193,7 @@ export function registerContactHandlers(): void { date_first_contacted: number | null; date_last_contacted: number | null; projects_raw: string | null; + tags_raw: string | null; }>; return rows.map((row) => ({ @@ -210,6 +212,7 @@ export function registerContactHandlers(): void { projects: row.projects_raw ? (JSON.parse(row.projects_raw) as { id: string; name: string }[]) : [], + tags: row.tags_raw ? row.tags_raw.split('\x1f') : [], })); }); @@ -267,7 +270,10 @@ export function registerContactHandlers(): void { reporters: allReporters.filter((r) => r.membership_id === p.membership_id), })); - return { ...contact, emails, phones, links, handles, projects: projectsWithReporters } as ContactDetail; + const tags = (stmt(db, 'SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY tag') + .all(id) as { tag: string }[]).map((r) => r.tag); + + return { ...contact, emails, phones, links, handles, projects: projectsWithReporters, tags } as ContactDetail; }); ipcMain.handle('contacts:create', (_, data: CreateContactInput): ContactListItem => { @@ -333,7 +339,7 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw FROM contacts c WHERE c.id = ?`, ).get(id) as { id: string; name: string; organization: string | null; title: string | null; notes: string | null; created_at: number; has_email: 0|1; has_phone: 0|1; emails_raw: string|null; phones_raw: string|null }; - return { ...row, date_first_contacted: null, date_last_contacted: null, projects: [] }; + return { ...row, date_first_contacted: null, date_last_contacted: null, projects: [], tags: [] }; }); ipcMain.handle('contacts:delete', (_, id: string): void => { @@ -405,7 +411,7 @@ export function registerContactHandlers(): void { ipcMain.handle('contacts:list-for-project', (_, projectId: string): ProjectContactRow[] => { const db = getDatabase(); - return stmt(db, + const rows = stmt(db, `SELECT c.id, c.name, c.organization, c.notes, pm.id AS membership_id, pm.created_at AS membership_created_at, pm.reporter_name, pm.reporter_email, @@ -415,12 +421,14 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id) AS emails_raw, (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw, (SELECT MIN(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_first_contacted, - (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted + (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted, + (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY tag) AS tags_raw FROM project_memberships pm JOIN contacts c ON c.id = pm.contact_id WHERE pm.project_id = ? ORDER BY c.name COLLATE NOCASE ASC`, - ).all(projectId) as ProjectContactRow[]; + ).all(projectId) as Array & { tags_raw: string | null }>; + return rows.map((r) => ({ ...r, tags: r.tags_raw ? r.tags_raw.split('\x1f') : [] })); }); ipcMain.handle('contacts:update', (_, data: UpdateContactInput): void => { @@ -749,6 +757,25 @@ export function registerContactHandlers(): void { if (membershipIds.length > 0) broadcastRemindersChanged(); }); + ipcMain.handle('contacts:add-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { + const normalized = tag.trim().toLowerCase(); + if (!normalized || normalized.length > 50) return; + const db = getDatabase(); + const now = Math.floor(Date.now() / 1000); + db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(uuidv4(), contactId, normalized, now); + broadcastContactsChanged(); + }); + + ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { + getDatabase().prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); + broadcastContactsChanged(); + }); + + ipcMain.handle('contacts:list-all-tags', (): string[] => { + return (getDatabase().prepare('SELECT DISTINCT tag FROM contact_tags ORDER BY tag').all() as { tag: string }[]).map((r) => r.tag); + }); + ipcMain.handle('contacts:count', (): number => { const row = getDatabase().prepare('SELECT COUNT(*) as n FROM contacts').get() as { n: number }; return row.n; diff --git a/src/main/sync/engine.ts b/src/main/sync/engine.ts index 80fa6855..11d7ab76 100644 --- a/src/main/sync/engine.ts +++ b/src/main/sync/engine.ts @@ -392,6 +392,25 @@ function mergeSubTablesFromShared( ) .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); } + + // ── Tags ────────────────────────────────────────────────────────────────── + const sharedTags = shared + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + const localTags = local + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + + const sharedTagValues = new Set(sharedTags.map((t) => t.tag)); + const localOnlyTags = localTags.filter((t) => !sharedTagValues.has(t.tag)); + const mergedTags = [...sharedTags, ...localOnlyTags]; + + local.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); + for (const t of mergedTags) { + local + .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(t.id, contactId, t.tag, t.created_at); + } } function pullMemberships( @@ -710,6 +729,16 @@ function pushSubTablesToShared( ) .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); } + + shared.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); + const tagRows = local + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + for (const t of tagRows) { + shared + .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(t.id, contactId, t.tag, t.created_at); + } } function pushMemberships( diff --git a/src/preload/index.ts b/src/preload/index.ts index 1cd82699..eed34385 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -167,6 +167,11 @@ const sourcererApi = { getContactInteractionCount: (contactId: string): Promise => ipcRenderer.invoke('contacts:interaction-count', contactId), validatePhone: (raw: string): Promise => ipcRenderer.invoke('contacts:validate-phone', raw), + addContactTag: (contactId: string, tag: string): Promise => + ipcRenderer.invoke('contacts:add-tag', { contactId, tag }), + removeContactTag: (contactId: string, tag: string): Promise => + ipcRenderer.invoke('contacts:remove-tag', { contactId, tag }), + listAllContactTags: (): Promise => ipcRenderer.invoke('contacts:list-all-tags'), // Scratchpad listScratchpad: (contactId: string, projectId: string): Promise => diff --git a/src/renderer/src/contacts/GlobalTab.tsx b/src/renderer/src/contacts/GlobalTab.tsx index a28bf0ca..8097aeaa 100644 --- a/src/renderer/src/contacts/GlobalTab.tsx +++ b/src/renderer/src/contacts/GlobalTab.tsx @@ -7,6 +7,7 @@ import ContactEditForm, { SOCIAL_TYPES, SOCIAL_META, KNOWN_LINK_TYPES } from './ import { isGoogleAlertUrl } from './contactValidation'; import RssAlertPanel from './RssAlertPanel'; import ScreenshotPanel from './ScreenshotPanel'; +import TagsSection from './TagsSection'; import { linkifyText } from '../utils/linkify'; import { HANDLE_TYPES, HANDLE_META } from './handleMeta'; import type { HandleType } from './handleMeta'; @@ -254,6 +255,12 @@ export default function GlobalTab({ contact, allProjects, onRefresh, onMembershi )} + + void; +} + +export default function TagsSection({ contactId, tags, onChanged }: Props) { + const [input, setInput] = useState(''); + const [allTags, setAllTags] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + window.sourcerer.listAllContactTags().then(setAllTags); + }, [tags]); + + useEffect(() => { + if (!dropdownOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setDropdownOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [dropdownOpen]); + + useEffect(() => { + if (!dropdownOpen) return; + const onPointer = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('pointerdown', onPointer); + return () => document.removeEventListener('pointerdown', onPointer); + }, [dropdownOpen]); + + const trimmed = input.trim().toLowerCase(); + const suggestions = allTags.filter( + (t) => !tags.includes(t) && (trimmed === '' || t.includes(trimmed)), + ); + + async function addTag(tag: string) { + const normalized = tag.trim().toLowerCase(); + if (!normalized || normalized.length > 50 || tags.includes(normalized)) return; + await window.sourcerer.addContactTag(contactId, normalized); + setInput(''); + setDropdownOpen(false); + onChanged(); + } + + async function removeTag(tag: string) { + await window.sourcerer.removeContactTag(contactId, tag); + onChanged(); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (trimmed) addTag(trimmed); + } else if (e.key === 'Backspace' && input === '' && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + } + + return ( +
+
Tags
+
+
+ {tags.map((t) => ( + + {t} + + + ))} + { + setInput(e.target.value.replace(',', '')); + setDropdownOpen(true); + }} + onFocus={() => setDropdownOpen(true)} + onKeyDown={handleKeyDown} + maxLength={50} + /> +
+ {dropdownOpen && suggestions.length > 0 && ( +
    + {suggestions.map((t) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 88fd71db..eea9fa5b 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -112,6 +112,9 @@ declare global { getContactCount: () => Promise; getContactInteractionCount: (contactId: string) => Promise; validatePhone: (raw: string) => Promise; + addContactTag: (contactId: string, tag: string) => Promise; + removeContactTag: (contactId: string, tag: string) => Promise; + listAllContactTags: () => Promise; // Scratchpad listScratchpad: (contactId: string, projectId: string) => Promise; diff --git a/src/renderer/src/hooks/useProjectContacts.ts b/src/renderer/src/hooks/useProjectContacts.ts index 9d37cbdb..e2877e1e 100644 --- a/src/renderer/src/hooks/useProjectContacts.ts +++ b/src/renderer/src/hooks/useProjectContacts.ts @@ -88,6 +88,9 @@ export function useProjectContacts( if (filters.reporter.length > 0) { result = result.filter((r) => filters.reporter.includes(r.reporter_name)); } + if (filters.tags.length > 0) { + result = result.filter((r) => filters.tags.some((t) => r.tags.includes(t))); + } if (sort.key) { const dir = sort.dir === 'asc' ? 1 : -1; diff --git a/src/renderer/src/views/AllContacts.css b/src/renderer/src/views/AllContacts.css index 39d52c14..2923b30e 100644 --- a/src/renderer/src/views/AllContacts.css +++ b/src/renderer/src/views/AllContacts.css @@ -121,6 +121,25 @@ white-space: nowrap; } +.contact-tags-cell { + max-width: 180px; + white-space: nowrap; + overflow: hidden; +} + +.contact-tag-chip { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + background: var(--color-surface-2); + color: var(--color-text-muted); + padding: 1px 6px; + margin-right: 3px; + white-space: nowrap; +} + /* Compact boolean cells (email, phone, notes) */ .contact-bool-cell { text-align: center; diff --git a/src/renderer/src/views/AllContacts.tsx b/src/renderer/src/views/AllContacts.tsx index 8f61bc02..ca22b4c7 100644 --- a/src/renderer/src/views/AllContacts.tsx +++ b/src/renderer/src/views/AllContacts.tsx @@ -154,6 +154,10 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta result = result.filter((c) => c.projects.some((p) => p.id === filters.project)); } + if (filters.tags.length > 0) { + result = result.filter((c) => filters.tags.some((t) => c.tags.includes(t))); + } + if (sort.key) { const dir = sort.dir === 'asc' ? 1 : -1; result = [...result].sort((a, b) => { @@ -272,6 +276,12 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta setDetailId(null); } + const tagFilterOptions = useMemo(() => { + const seen = new Set(); + for (const c of contacts) for (const t of c.tags) seen.add(t); + return [...seen].sort().map((t) => ({ value: t, label: t })); + }, [contacts]); + const anyFilter = isFilterActive(filters); return ( @@ -431,6 +441,7 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta selectAllRef={selectAllRef} user={user} projects={projects} + tagFilterOptions={tagFilterOptions} virtualize scrollContainerRef={tableAreaRef} /> diff --git a/src/renderer/src/views/ContactsTable.tsx b/src/renderer/src/views/ContactsTable.tsx index 3dc57dff..1cb1e93e 100644 --- a/src/renderer/src/views/ContactsTable.tsx +++ b/src/renderer/src/views/ContactsTable.tsx @@ -29,6 +29,7 @@ export interface AllContactsFilters { dateAddedFrom: string; // ISO date string YYYY-MM-DD dateAddedTo: string; // ISO date string YYYY-MM-DD project: string | null; + tags: string[]; } export interface ProjectFilters { @@ -46,6 +47,7 @@ export interface ProjectFilters { status: string[]; priority: string[]; reporter: string[]; + tags: string[]; } export const DEFAULT_ALL_CONTACTS_FILTERS: AllContactsFilters = { @@ -60,6 +62,7 @@ export const DEFAULT_ALL_CONTACTS_FILTERS: AllContactsFilters = { dateAddedFrom: '', dateAddedTo: '', project: null, + tags: [], }; export const DEFAULT_PROJECT_FILTERS: ProjectFilters = { @@ -77,6 +80,7 @@ export const DEFAULT_PROJECT_FILTERS: ProjectFilters = { status: [], priority: [], reporter: [], + tags: [], }; export function isAllContactsFilterActive(f: AllContactsFilters): boolean { @@ -91,7 +95,8 @@ export function isAllContactsFilterActive(f: AllContactsFilters): boolean { f.dateLastContacted !== null || f.dateAddedFrom !== '' || f.dateAddedTo !== '' || - f.project !== null + f.project !== null || + f.tags.length > 0 ); } @@ -110,7 +115,8 @@ export function isProjectFilterActive(f: ProjectFilters): boolean { f.dateAddedTo !== '' || f.status.length > 0 || f.priority.length > 0 || - f.reporter.length > 0 + f.reporter.length > 0 || + f.tags.length > 0 ); } @@ -154,6 +160,7 @@ interface AllContactsTableProps extends BaseTableProps { rows: ContactListItem[]; filters: AllContactsFilters; projects: Project[]; + tagFilterOptions: Array<{ value: string; label: string }>; } interface ProjectTableProps extends BaseTableProps { @@ -164,6 +171,7 @@ interface ProjectTableProps extends BaseTableProps { statusFilterOptions: Array<{ value: string; label: string }>; priorityFilterOptions: Array<{ value: string; label: string }>; reporterOptions: Array<{ value: string; label: string }>; + tagFilterOptions: Array<{ value: string; label: string }>; userEmail?: string; } @@ -253,7 +261,7 @@ export default function ContactsTable(props: ContactsTableProps) { const rowsToRender = virtualItems ? virtualItems.map((vi) => rows[vi.index]) : rows; const sd = (key: string) => (sort.key === key ? sort.dir : null); - const colSpan = isProject ? 13 : 10; + const colSpan = isProject ? 14 : 11; return ( @@ -497,6 +505,24 @@ export default function ContactsTable(props: ContactsTableProps) { /> + {/* ── Shared: Tags ── */} + + {/* ── Shared: First Contacted ── */} + From 0c48dcf611bd19a929b6f14904084ca007c0d5ae Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 13:22:46 -0400 Subject: [PATCH 07/15] Filter search input: flush to edges, no rule below Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/views/ColumnHeader.css | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index da424969..30a9a7c9 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -225,18 +225,17 @@ } .col-filter-search-wrap { - padding: 4px 6px; - border-bottom: 1px solid var(--color-border); + padding: 4px 0; } .col-filter-search { width: 100%; - border: 1px solid var(--color-border); + border: none; background: var(--color-bg); font-family: var(--font-serif); font-size: 13px; color: var(--color-text); - padding: 3px 6px; + padding: 3px 8px; outline: none; box-sizing: border-box; } From 8026d109f4a5c3f227067aaff5b09912ed8653ca Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 13:24:18 -0400 Subject: [PATCH 08/15] Restore border on filter search input Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/views/ColumnHeader.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index 30a9a7c9..b368005b 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -230,7 +230,7 @@ .col-filter-search { width: 100%; - border: none; + border: 1px solid var(--color-border); background: var(--color-bg); font-family: var(--font-serif); font-size: 13px; From d01bf17e80779c90dc198168af7d8065fbfeb745 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 16:51:24 -0400 Subject: [PATCH 09/15] Address PR review feedback - Drop unused synced_at column from contact_tags schema - Fix MultiSelectFilter: selected items always shown first, cap applied only to unselected items so selected options are never hidden - Sync push: union rather than delete-replace to avoid clobbering tags from other clients on the shared DB - Fix --color-border token (not a valid design token); use rgba value - Fetch allTags once on mount; append optimistically on add to avoid round-trip per keystroke - Hoist prepared statements above loops in sync engine merge/push Co-Authored-By: Claude Sonnet 4.6 --- src/main/database/schema.ts | 1 - src/main/sync/engine.ts | 16 +++++++++------- src/renderer/src/contacts/TagsSection.tsx | 3 ++- src/renderer/src/views/ColumnHeader.css | 2 +- src/renderer/src/views/ColumnHeader.tsx | 12 +++++------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 6934d840..757e20b2 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -234,7 +234,6 @@ export const LOCAL_SCHEMA_DDL_SQL = ` contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, tag TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, - synced_at INTEGER, UNIQUE(contact_id, tag) ); diff --git a/src/main/sync/engine.ts b/src/main/sync/engine.ts index 11d7ab76..46d8416e 100644 --- a/src/main/sync/engine.ts +++ b/src/main/sync/engine.ts @@ -406,10 +406,9 @@ function mergeSubTablesFromShared( const mergedTags = [...sharedTags, ...localOnlyTags]; local.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); + const insertLocalTag = local.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)'); for (const t of mergedTags) { - local - .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') - .run(t.id, contactId, t.tag, t.created_at); + insertLocalTag.run(t.id, contactId, t.tag, t.created_at); } } @@ -730,14 +729,17 @@ function pushSubTablesToShared( .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); } - shared.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); + const sharedTagSet = new Set( + (shared.prepare('SELECT tag FROM contact_tags WHERE contact_id = ?').all(contactId) as { tag: string }[]).map((r) => r.tag), + ); const tagRows = local .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') .all(contactId) as { id: string; tag: string; created_at: number }[]; + const insertSharedTag = shared.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)'); for (const t of tagRows) { - shared - .prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') - .run(t.id, contactId, t.tag, t.created_at); + if (!sharedTagSet.has(t.tag)) { + insertSharedTag.run(t.id, contactId, t.tag, t.created_at); + } } } diff --git a/src/renderer/src/contacts/TagsSection.tsx b/src/renderer/src/contacts/TagsSection.tsx index d987298a..348e1018 100644 --- a/src/renderer/src/contacts/TagsSection.tsx +++ b/src/renderer/src/contacts/TagsSection.tsx @@ -16,7 +16,7 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { useEffect(() => { window.sourcerer.listAllContactTags().then(setAllTags); - }, [tags]); + }, [contactId]); useEffect(() => { if (!dropdownOpen) return; @@ -40,6 +40,7 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { await window.sourcerer.addContactTag(contactId, normalized); setInput(''); setDropdownOpen(false); + setAllTags((prev) => prev.includes(normalized) ? prev : [...prev, normalized].sort()); onChanged(); } diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index b368005b..82a436cd 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -230,7 +230,7 @@ .col-filter-search { width: 100%; - border: 1px solid var(--color-border); + border: 1px solid rgba(26, 24, 21, 0.14); background: var(--color-bg); font-family: var(--font-serif); font-size: 13px; diff --git a/src/renderer/src/views/ColumnHeader.tsx b/src/renderer/src/views/ColumnHeader.tsx index 3f2e558b..232b2828 100644 --- a/src/renderer/src/views/ColumnHeader.tsx +++ b/src/renderer/src/views/ColumnHeader.tsx @@ -184,13 +184,11 @@ export function MultiSelectFilter({ const q = search.trim().toLowerCase(); const selectedSet = new Set(selected); - const filtered = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) - .slice(0, MULTISELECT_MAX_VISIBLE); - - const checkedFirst = [ - ...filtered.filter((o) => selectedSet.has(o.value)), - ...filtered.filter((o) => !selectedSet.has(o.value)), - ]; + const selectedItems = options.filter((o) => selectedSet.has(o.value)); + const unselectedVisible = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) + .filter((o) => !selectedSet.has(o.value)) + .slice(0, Math.max(0, MULTISELECT_MAX_VISIBLE - selectedItems.length)); + const checkedFirst = [...selectedItems, ...unselectedVisible]; return (
From 4d09babb95c390d184fc95bc0dc71867c8505a08 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 16:56:04 -0400 Subject: [PATCH 10/15] Revert --color-border change; token is defined in global.css Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/views/ColumnHeader.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index 82a436cd..b368005b 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -230,7 +230,7 @@ .col-filter-search { width: 100%; - border: 1px solid rgba(26, 24, 21, 0.14); + border: 1px solid var(--color-border); background: var(--color-bg); font-family: var(--font-serif); font-size: 13px; From e6f95c57cba1aadd78be9ed1910d1b38b3ddf9de Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 16:58:45 -0400 Subject: [PATCH 11/15] Fix sync: bump contact updated_at when tags change contacts:add-tag and contacts:remove-tag previously only wrote to contact_tags, leaving the contact's updated_at unchanged. The push path checks updated_at > synced_at to decide whether to push sub-tables, so tags added after a contact's last sync were silently never pushed. Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/contacts.ts | 14 +++++++++++--- src/test/tags.test.ts | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index 98cb6e0d..76848c46 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -762,13 +762,21 @@ export function registerContactHandlers(): void { if (!normalized || normalized.length > 50) return; const db = getDatabase(); const now = Math.floor(Date.now() / 1000); - db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') - .run(uuidv4(), contactId, normalized, now); + db.transaction(() => { + db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(uuidv4(), contactId, normalized, now); + db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + })(); broadcastContactsChanged(); }); ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { - getDatabase().prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); + const db = getDatabase(); + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); + db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + })(); broadcastContactsChanged(); }); diff --git a/src/test/tags.test.ts b/src/test/tags.test.ts index 8d985756..67f98144 100644 --- a/src/test/tags.test.ts +++ b/src/test/tags.test.ts @@ -252,6 +252,27 @@ function localInsertMembership(db: Database.Database, contactId: string, project return id; } +describe('contacts:add-tag / contacts:remove-tag — bumps updated_at', () => { + it('add-tag bumps contact updated_at so the sync engine will push', async () => { + const contactId = insertContact(testDb, 'Alice'); + const before = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; + await new Promise((r) => setTimeout(r, 1100)); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); + const after = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; + expect(after).toBeGreaterThan(before); + }); + + it('remove-tag bumps contact updated_at so the sync engine will push', async () => { + const contactId = insertContact(testDb, 'Alice'); + insertTag(testDb, contactId, 'source'); + const before = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; + await new Promise((r) => setTimeout(r, 1100)); + await handlers.get('contacts:remove-tag')!({}, { contactId, tag: 'source' }); + const after = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; + expect(after).toBeGreaterThan(before); + }); +}); + describe('syncProject — tags', () => { it('pushes local tags to shared on sync', () => { const localDb = createTestDb(); From c7607c0fdf7d1638056e285f080245b548b0569e Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 17:00:39 -0400 Subject: [PATCH 12/15] Refresh allTags on contacts:changed (sync arrives) Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/contacts/TagsSection.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/contacts/TagsSection.tsx b/src/renderer/src/contacts/TagsSection.tsx index 348e1018..e7471324 100644 --- a/src/renderer/src/contacts/TagsSection.tsx +++ b/src/renderer/src/contacts/TagsSection.tsx @@ -18,6 +18,12 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { window.sourcerer.listAllContactTags().then(setAllTags); }, [contactId]); + useEffect(() => { + return window.sourcerer.onContactsChanged(() => { + window.sourcerer.listAllContactTags().then(setAllTags); + }); + }, []); + useEffect(() => { if (!dropdownOpen) return; const onPointer = (e: PointerEvent) => { From 93c96069021de5c079490140cc1b77baea3c0bdc Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 17:27:40 -0400 Subject: [PATCH 13/15] Address second round of review feedback - add-tag/remove-tag: only bump updated_at if the write had changes - TagsSection: border-radius: 0 on chip remove button - MultiSelectFilter: focus search input once on mount via useEffect/useRef, not autoFocus (which re-fired on every checkbox toggle) - Tests: replace 1.1s sleeps with updated_at backdating; add cases for no-op add and no-op remove not bumping updated_at - Sync: add comment on additive-only tag merge limitation (see #422) Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/contacts.ts | 8 +++--- src/main/sync/engine.ts | 3 +++ src/renderer/src/contacts/TagsSection.css | 1 + src/renderer/src/views/ColumnHeader.tsx | 6 +++-- src/test/tags.test.ts | 31 +++++++++++++++++------ 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index 76848c46..fe0bcce6 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -763,9 +763,9 @@ export function registerContactHandlers(): void { const db = getDatabase(); const now = Math.floor(Date.now() / 1000); db.transaction(() => { - db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + const { changes } = db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') .run(uuidv4(), contactId, normalized, now); - db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); })(); broadcastContactsChanged(); }); @@ -774,8 +774,8 @@ export function registerContactHandlers(): void { const db = getDatabase(); const now = Math.floor(Date.now() / 1000); db.transaction(() => { - db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); - db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); + if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); })(); broadcastContactsChanged(); }); diff --git a/src/main/sync/engine.ts b/src/main/sync/engine.ts index 46d8416e..e65a7678 100644 --- a/src/main/sync/engine.ts +++ b/src/main/sync/engine.ts @@ -394,6 +394,9 @@ function mergeSubTablesFromShared( } // ── Tags ────────────────────────────────────────────────────────────────── + // Additive-only merge: deletions do not propagate across clients. A tag + // removed on one client will be restored on next sync if it still exists on + // shared. Full deletion propagation requires a tombstone table — see #422. const sharedTags = shared .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') .all(contactId) as { id: string; tag: string; created_at: number }[]; diff --git a/src/renderer/src/contacts/TagsSection.css b/src/renderer/src/contacts/TagsSection.css index ca1188e3..f06e1724 100644 --- a/src/renderer/src/contacts/TagsSection.css +++ b/src/renderer/src/contacts/TagsSection.css @@ -28,6 +28,7 @@ .tags-chip-remove { background: none; border: none; + border-radius: 0; padding: 0; margin: 0; cursor: pointer; diff --git a/src/renderer/src/views/ColumnHeader.tsx b/src/renderer/src/views/ColumnHeader.tsx index 232b2828..f3093ee1 100644 --- a/src/renderer/src/views/ColumnHeader.tsx +++ b/src/renderer/src/views/ColumnHeader.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import './ColumnHeader.css'; @@ -175,6 +175,8 @@ export function MultiSelectFilter({ onChange: (v: string[]) => void; }) { const [search, setSearch] = useState(''); + const searchRef = useRef(null); + useEffect(() => { searchRef.current?.focus(); }, []); function toggle(val: string) { onChange(selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val]); @@ -200,12 +202,12 @@ export function MultiSelectFilter({ {showSearch && (
setSearch(e.target.value)} - autoFocus />
)} diff --git a/src/test/tags.test.ts b/src/test/tags.test.ts index 67f98144..6e74b514 100644 --- a/src/test/tags.test.ts +++ b/src/test/tags.test.ts @@ -255,21 +255,36 @@ function localInsertMembership(db: Database.Database, contactId: string, project describe('contacts:add-tag / contacts:remove-tag — bumps updated_at', () => { it('add-tag bumps contact updated_at so the sync engine will push', async () => { const contactId = insertContact(testDb, 'Alice'); - const before = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; - await new Promise((r) => setTimeout(r, 1100)); + testDb.prepare('UPDATE contacts SET updated_at = 0 WHERE id = ?').run(contactId); await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); - const after = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; - expect(after).toBeGreaterThan(before); + const { updated_at } = testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }; + expect(updated_at).toBeGreaterThan(0); + }); + + it('add-tag does not bump updated_at for a duplicate tag', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); + testDb.prepare('UPDATE contacts SET updated_at = 0 WHERE id = ?').run(contactId); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); + const { updated_at } = testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }; + expect(updated_at).toBe(0); }); it('remove-tag bumps contact updated_at so the sync engine will push', async () => { const contactId = insertContact(testDb, 'Alice'); insertTag(testDb, contactId, 'source'); - const before = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; - await new Promise((r) => setTimeout(r, 1100)); + testDb.prepare('UPDATE contacts SET updated_at = 0 WHERE id = ?').run(contactId); await handlers.get('contacts:remove-tag')!({}, { contactId, tag: 'source' }); - const after = (testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }).updated_at; - expect(after).toBeGreaterThan(before); + const { updated_at } = testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }; + expect(updated_at).toBeGreaterThan(0); + }); + + it('remove-tag does not bump updated_at when tag does not exist', async () => { + const contactId = insertContact(testDb, 'Alice'); + testDb.prepare('UPDATE contacts SET updated_at = 0 WHERE id = ?').run(contactId); + await handlers.get('contacts:remove-tag')!({}, { contactId, tag: 'ghost' }); + const { updated_at } = testDb.prepare('SELECT updated_at FROM contacts WHERE id = ?').get(contactId) as { updated_at: number }; + expect(updated_at).toBe(0); }); }); From acd79ff26656cb9356c038c53d9570343f21409d Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 17:35:33 -0400 Subject: [PATCH 14/15] Fix GROUP_CONCAT ordering and comma stripping - Wrap GROUP_CONCAT in an inner subquery to guarantee ORDER BY created_at is respected by the aggregate (SQLite does not guarantee aggregate order from an outer ORDER BY on a scalar subquery) - Strip all commas from tag input, not just the first (replace with /,/g) Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/contacts.ts | 4 ++-- src/renderer/src/contacts/TagsSection.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index fe0bcce6..c43a676a 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -175,7 +175,7 @@ export function registerContactHandlers(): void { FROM project_memberships pm JOIN projects p ON p.id = pm.project_id WHERE pm.contact_id = c.id ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw, - (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw + (SELECT GROUP_CONCAT(tag, '\x1f') FROM (SELECT tag FROM contact_tags WHERE contact_id = c.id ORDER BY created_at)) AS tags_raw FROM contacts c ORDER BY c.name COLLATE NOCASE ASC`, ) @@ -422,7 +422,7 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw, (SELECT MIN(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_first_contacted, (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted, - (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw + (SELECT GROUP_CONCAT(tag, '\x1f') FROM (SELECT tag FROM contact_tags WHERE contact_id = c.id ORDER BY created_at)) AS tags_raw FROM project_memberships pm JOIN contacts c ON c.id = pm.contact_id WHERE pm.project_id = ? diff --git a/src/renderer/src/contacts/TagsSection.tsx b/src/renderer/src/contacts/TagsSection.tsx index e7471324..bcf9a5ed 100644 --- a/src/renderer/src/contacts/TagsSection.tsx +++ b/src/renderer/src/contacts/TagsSection.tsx @@ -90,7 +90,7 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { value={input} placeholder={tags.length === 0 ? 'Add a tag…' : ''} onChange={(e) => { - setInput(e.target.value.replace(',', '')); + setInput(e.target.value.replace(/,/g, '')); setDropdownOpen(true); }} onFocus={() => setDropdownOpen(true)} From 913e9cd2a2c7295b13dfdd5229f8c8e11b4e720a Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 17:36:32 -0400 Subject: [PATCH 15/15] Normalize tag in remove-tag handler Defensive guard: trim and lowercase before DELETE so un-normalized values passed by future callers can't silently miss the row. Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/contacts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index c43a676a..3d4e0146 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -771,10 +771,11 @@ export function registerContactHandlers(): void { }); ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { + const normalized = tag.trim().toLowerCase(); const db = getDatabase(); const now = Math.floor(Date.now() / 1000); db.transaction(() => { - const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag); + const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, normalized); if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); })(); broadcastContactsChanged();
+ 0} + filterActive={(isProject ? (pf?.tags.length ?? 0) : (af?.tags.length ?? 0)) > 0} + filterOpen={openFilter === 'tags'} + onFilterToggle={() => toggleFilter('tags')} + filterContent={ + setFilter('tags', v)} + /> + } + /> + — )} + + {row.tags.length === 0 ? ( + + ) : ( + row.tags.map((t) => ( + {t} + )) + )} + {row.date_first_contacted === null ? ( diff --git a/src/renderer/src/views/ProjectView.tsx b/src/renderer/src/views/ProjectView.tsx index f0985747..cb0d8e26 100644 --- a/src/renderer/src/views/ProjectView.tsx +++ b/src/renderer/src/views/ProjectView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Project, ProjectContactRow, StatusOption, PriorityOption, ImportResult, User } from '@shared/types'; import { useClickOutside } from '../hooks/useClickOutside'; import { useSortFilter } from '../hooks/useSortFilter'; @@ -341,6 +341,12 @@ export default function ProjectView({ project, user, onProjectUpdated, refreshTr ...new Map(rows.map((r) => [r.reporter_name, { value: r.reporter_name, label: r.reporter_name }])).values(), ].sort((a, b) => a.label.localeCompare(b.label)); + const tagFilterOptions = useMemo(() => { + const seen = new Set(); + for (const r of rows) for (const t of r.tags) seen.add(t); + return [...seen].sort().map((t) => ({ value: t, label: t })); + }, [rows]); + const hasNullStatus = rows.some((r) => r.status === null); const statusFilterOptions = [ ...statusOptions.map((o) => ({ value: o.label, label: o.label })), @@ -549,6 +555,7 @@ export default function ProjectView({ project, user, onProjectUpdated, refreshTr statusFilterOptions={statusFilterOptions} priorityFilterOptions={priorityFilterOptions} reporterOptions={reporterOptions} + tagFilterOptions={tagFilterOptions} userEmail={user?.email} /> diff --git a/src/shared/types.ts b/src/shared/types.ts index 82d9d874..8339d3bd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -89,6 +89,7 @@ export interface ContactListItem { date_first_contacted: number | null; date_last_contacted: number | null; projects: Array<{ id: string; name: string }>; + tags: string[]; } export interface ContactEmail { @@ -160,6 +161,7 @@ export interface ContactDetail { links: ContactLink[]; handles: ContactHandle[]; projects: ContactProject[]; + tags: string[]; } export interface ContactLinkInput { @@ -280,6 +282,7 @@ export interface ProjectContactRow { theme: string | null; priority: string | null; status: string | null; + tags: string[]; } export interface CreateSharedProjectResult { diff --git a/src/test/tags.test.ts b/src/test/tags.test.ts new file mode 100644 index 00000000..9c9c844b --- /dev/null +++ b/src/test/tags.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import Database from 'better-sqlite3-multiple-ciphers'; +import { createTestDb, insertContact, insertProject, TEST_REPORTER } from './vitest.setup'; +import { SHARED_SCHEMA_SQL } from '../main/database/shared-schema'; +import { syncProject } from '../main/sync/engine'; + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + net: { fetch: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') }, +})); + +let testDb: ReturnType; +vi.mock('../main/database', () => ({ getDatabase: () => testDb, isDatabaseOpen: () => true })); +vi.mock('../main/sync/outreach-checker', () => ({ checkOutreachReminders: vi.fn() })); + +import { ipcMain } from 'electron'; +import { registerContactHandlers } from '../main/ipc/contacts'; + +const handlers = new Map unknown>(); + +beforeEach(() => { + testDb = createTestDb(); + handlers.clear(); + vi.mocked(ipcMain.handle).mockImplementation((channel: string, fn) => { + handlers.set(channel, fn as (...args: unknown[]) => unknown); + return ipcMain; + }); + registerContactHandlers(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function insertTag(db: Database.Database, contactId: string, tag: string): void { + db.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(uuidv4(), contactId, tag, Math.floor(Date.now() / 1000)); +} + +function getTags(db: Database.Database, contactId: string): string[] { + return (db.prepare('SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY tag').all(contactId) as { tag: string }[]) + .map((r) => r.tag); +} + +// --------------------------------------------------------------------------- +// contacts:add-tag +// --------------------------------------------------------------------------- + +describe('contacts:add-tag', () => { + it('adds a tag to a contact', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); + expect(getTags(testDb, contactId)).toEqual(['source']); + }); + + it('normalises to lowercase and trims whitespace', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: ' WHISTLEBLOWER ' }); + expect(getTags(testDb, contactId)).toEqual(['whistleblower']); + }); + + it('ignores duplicate tags (INSERT OR IGNORE)', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'legal' }); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'legal' }); + expect(getTags(testDb, contactId)).toHaveLength(1); + }); + + it('ignores empty tag strings', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: ' ' }); + expect(getTags(testDb, contactId)).toHaveLength(0); + }); + + it('ignores tags exceeding 50 characters', async () => { + const contactId = insertContact(testDb, 'Alice'); + const long = 'a'.repeat(51); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: long }); + expect(getTags(testDb, contactId)).toHaveLength(0); + }); + + it('allows a contact to have multiple distinct tags', async () => { + const contactId = insertContact(testDb, 'Alice'); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'source' }); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'legal' }); + await handlers.get('contacts:add-tag')!({}, { contactId, tag: 'whistleblower' }); + expect(getTags(testDb, contactId)).toEqual(['legal', 'source', 'whistleblower']); + }); +}); + +// --------------------------------------------------------------------------- +// contacts:remove-tag +// --------------------------------------------------------------------------- + +describe('contacts:remove-tag', () => { + it('removes an existing tag', async () => { + const contactId = insertContact(testDb, 'Bob'); + insertTag(testDb, contactId, 'source'); + insertTag(testDb, contactId, 'legal'); + await handlers.get('contacts:remove-tag')!({}, { contactId, tag: 'source' }); + expect(getTags(testDb, contactId)).toEqual(['legal']); + }); + + it('is a no-op for a tag that does not exist', async () => { + const contactId = insertContact(testDb, 'Bob'); + await handlers.get('contacts:remove-tag')!({}, { contactId, tag: 'ghost' }); + expect(getTags(testDb, contactId)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// contacts:list-all-tags +// --------------------------------------------------------------------------- + +describe('contacts:list-all-tags', () => { + it('returns all distinct tags across all contacts in alphabetical order', async () => { + const a = insertContact(testDb, 'Alice'); + const b = insertContact(testDb, 'Bob'); + insertTag(testDb, a, 'source'); + insertTag(testDb, a, 'legal'); + insertTag(testDb, b, 'source'); + insertTag(testDb, b, 'whistleblower'); + + const result = await handlers.get('contacts:list-all-tags')!({}) as string[]; + expect(result).toEqual(['legal', 'source', 'whistleblower']); + }); + + it('returns an empty array when no tags exist', async () => { + insertContact(testDb, 'Alice'); + const result = await handlers.get('contacts:list-all-tags')!({}) as string[]; + expect(result).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// contacts:list — tags included +// --------------------------------------------------------------------------- + +describe('contacts:list — tags field', () => { + it('returns an empty tags array for a contact with no tags', async () => { + insertContact(testDb, 'Alice'); + const rows = await handlers.get('contacts:list')!({}) as { tags: string[] }[]; + expect(rows[0].tags).toEqual([]); + }); + + it('returns tags sorted alphabetically', async () => { + const contactId = insertContact(testDb, 'Alice'); + insertTag(testDb, contactId, 'zebra'); + insertTag(testDb, contactId, 'alpha'); + const rows = await handlers.get('contacts:list')!({}) as { tags: string[] }[]; + expect(rows[0].tags).toEqual(['alpha', 'zebra']); + }); + + it('does not mix tags between contacts', async () => { + const a = insertContact(testDb, 'Alice'); + const b = insertContact(testDb, 'Bob'); + insertTag(testDb, a, 'source'); + insertTag(testDb, b, 'legal'); + + const rows = await handlers.get('contacts:list')!({}) as { name: string; tags: string[] }[]; + const alice = rows.find((r) => r.name === 'Alice')!; + const bob = rows.find((r) => r.name === 'Bob')!; + expect(alice.tags).toEqual(['source']); + expect(bob.tags).toEqual(['legal']); + }); +}); + +// --------------------------------------------------------------------------- +// contacts:get — tags included +// --------------------------------------------------------------------------- + +describe('contacts:get — tags field', () => { + it('returns tags for a contact', async () => { + const contactId = insertContact(testDb, 'Alice'); + insertTag(testDb, contactId, 'source'); + insertTag(testDb, contactId, 'legal'); + const detail = await handlers.get('contacts:get')!({}, contactId) as { tags: string[] }; + expect(detail.tags).toEqual(['legal', 'source']); + }); + + it('returns an empty tags array when none exist', async () => { + const contactId = insertContact(testDb, 'Alice'); + const detail = await handlers.get('contacts:get')!({}, contactId) as { tags: string[] }; + expect(detail.tags).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// contacts:list-for-project — tags included +// --------------------------------------------------------------------------- + +describe('contacts:list-for-project — tags field', () => { + it('returns tags for contacts in a project', async () => { + const contactId = insertContact(testDb, 'Alice'); + const projectId = insertProject(testDb, 'Proj'); + const now = Math.floor(Date.now() / 1000); + testDb.prepare( + 'INSERT INTO project_memberships (id, contact_id, project_id, reporter_email, reporter_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run(uuidv4(), contactId, projectId, TEST_REPORTER.email, TEST_REPORTER.name, now, now); + insertTag(testDb, contactId, 'source'); + + const rows = await handlers.get('contacts:list-for-project')!({}, projectId) as { tags: string[] }[]; + expect(rows[0].tags).toEqual(['source']); + }); + + it('returns an empty tags array when the contact has no tags', async () => { + const contactId = insertContact(testDb, 'Alice'); + const projectId = insertProject(testDb, 'Proj'); + const now = Math.floor(Date.now() / 1000); + testDb.prepare( + 'INSERT INTO project_memberships (id, contact_id, project_id, reporter_email, reporter_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run(uuidv4(), contactId, projectId, TEST_REPORTER.email, TEST_REPORTER.name, now, now); + + const rows = await handlers.get('contacts:list-for-project')!({}, projectId) as { tags: string[] }[]; + expect(rows[0].tags).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Sync engine — tags +// --------------------------------------------------------------------------- + +const SYNC_NOW = Math.floor(Date.now() / 1000); + +function createSharedDb(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + db.exec(SHARED_SCHEMA_SQL); + return db; +} + +function sharedInsertContact(db: Database.Database, id: string, name: string): void { + db.prepare('INSERT INTO contacts (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)').run(id, name, SYNC_NOW, SYNC_NOW); +} + +function sharedInsertMembership(db: Database.Database, id: string, contactId: string): void { + db.prepare( + 'INSERT INTO project_memberships (id, contact_id, reporter_email, reporter_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, contactId, 'r@r.com', 'Reporter', SYNC_NOW, SYNC_NOW); +} + +function localInsertMembership(db: Database.Database, contactId: string, projectId: string): string { + const id = uuidv4(); + const ts = SYNC_NOW - 10; + db.prepare( + 'INSERT INTO project_memberships (id, contact_id, project_id, reporter_email, reporter_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run(id, contactId, projectId, 'r@r.com', 'Reporter', ts, ts); + return id; +} + +describe('syncProject — tags', () => { + it('pushes local tags to shared on sync', () => { + const localDb = createTestDb(); + const sharedDb = createSharedDb(); + const projectId = insertProject(localDb, 'Test'); + + // Insert contact with a fixed UUID so both sides share the same ID + const contactId = uuidv4(); + const localTs = SYNC_NOW - 10; + localDb.prepare('INSERT INTO contacts (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)').run(contactId, 'Alice', localTs, localTs); + localInsertMembership(localDb, contactId, projectId); + insertTag(localDb, contactId, 'source'); + insertTag(localDb, contactId, 'legal'); + + sharedInsertContact(sharedDb, contactId, 'Alice'); + sharedInsertMembership(sharedDb, uuidv4(), contactId); + + const result = syncProject(localDb, sharedDb, projectId); + expect(result.success).toBe(true); + + const sharedTags = (sharedDb.prepare('SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY tag').all(contactId) as { tag: string }[]).map((r) => r.tag); + expect(sharedTags).toEqual(['legal', 'source']); + }); + + it('pulls tags from shared into local on sync', () => { + const localDb = createTestDb(); + const sharedDb = createSharedDb(); + const projectId = insertProject(localDb, 'Test'); + + const contactId = uuidv4(); + const localTs = SYNC_NOW - 10; + localDb.prepare('INSERT INTO contacts (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)').run(contactId, 'Alice', localTs, localTs); + localInsertMembership(localDb, contactId, projectId); + + sharedInsertContact(sharedDb, contactId, 'Alice'); + sharedInsertMembership(sharedDb, uuidv4(), contactId); + sharedDb.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)').run(uuidv4(), contactId, 'whistleblower', SYNC_NOW); + sharedDb.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)').run(uuidv4(), contactId, 'source', SYNC_NOW); + + const result = syncProject(localDb, sharedDb, projectId); + expect(result.success).toBe(true); + + expect(getTags(localDb, contactId)).toEqual(['source', 'whistleblower']); + }); + + it('merges local-only and shared tags without duplicates', () => { + const localDb = createTestDb(); + const sharedDb = createSharedDb(); + const projectId = insertProject(localDb, 'Test'); + + const contactId = uuidv4(); + const localTs = SYNC_NOW - 10; + localDb.prepare('INSERT INTO contacts (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)').run(contactId, 'Alice', localTs, localTs); + localInsertMembership(localDb, contactId, projectId); + insertTag(localDb, contactId, 'local-only'); + insertTag(localDb, contactId, 'shared-tag'); + + sharedInsertContact(sharedDb, contactId, 'Alice'); + sharedInsertMembership(sharedDb, uuidv4(), contactId); + sharedDb.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)').run(uuidv4(), contactId, 'shared-tag', SYNC_NOW); + sharedDb.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)').run(uuidv4(), contactId, 'shared-only', SYNC_NOW); + + const result = syncProject(localDb, sharedDb, projectId); + expect(result.success).toBe(true); + + expect(getTags(localDb, contactId)).toEqual(['local-only', 'shared-only', 'shared-tag']); + }); +}); From 495f7ec08d43a4029bcfd11a9cf50897453707a9 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:29:14 -0400 Subject: [PATCH 02/15] Fix tags input placeholder font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use --font-serif for the "Add a tag…" hint text instead of --font-mono. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/contacts/TagsSection.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/contacts/TagsSection.css b/src/renderer/src/contacts/TagsSection.css index 0d58b36d..11aee5f0 100644 --- a/src/renderer/src/contacts/TagsSection.css +++ b/src/renderer/src/contacts/TagsSection.css @@ -57,6 +57,9 @@ } .tags-input::placeholder { + font-family: var(--font-serif); + font-size: 13px; + letter-spacing: 0; color: var(--color-text-dim); } From cf2c71059d54607baf091d6b0b8cfce0237f8a06 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:30:00 -0400 Subject: [PATCH 03/15] Remove left padding from tags input Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/contacts/TagsSection.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/contacts/TagsSection.css b/src/renderer/src/contacts/TagsSection.css index 11aee5f0..ca1188e3 100644 --- a/src/renderer/src/contacts/TagsSection.css +++ b/src/renderer/src/contacts/TagsSection.css @@ -50,7 +50,7 @@ font-size: 11px; letter-spacing: 0.1em; color: var(--color-text); - padding: 2px 4px; + padding: 2px 4px 2px 0; min-width: 80px; flex: 1; outline: none; From 41d2ed19982fdce7e8fe91fcbdc7043385d12911 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:36:34 -0400 Subject: [PATCH 04/15] Fix tags input: cap dropdown at 10, Escape blurs instead of closing drawer Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/contacts/TagsSection.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/contacts/TagsSection.tsx b/src/renderer/src/contacts/TagsSection.tsx index a4f5dca1..d987298a 100644 --- a/src/renderer/src/contacts/TagsSection.tsx +++ b/src/renderer/src/contacts/TagsSection.tsx @@ -18,15 +18,6 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { window.sourcerer.listAllContactTags().then(setAllTags); }, [tags]); - useEffect(() => { - if (!dropdownOpen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setDropdownOpen(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [dropdownOpen]); - useEffect(() => { if (!dropdownOpen) return; const onPointer = (e: PointerEvent) => { @@ -39,9 +30,9 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { }, [dropdownOpen]); const trimmed = input.trim().toLowerCase(); - const suggestions = allTags.filter( - (t) => !tags.includes(t) && (trimmed === '' || t.includes(trimmed)), - ); + const suggestions = allTags + .filter((t) => !tags.includes(t) && (trimmed === '' || t.includes(trimmed))) + .slice(0, 10); async function addTag(tag: string) { const normalized = tag.trim().toLowerCase(); @@ -58,7 +49,12 @@ export default function TagsSection({ contactId, tags, onChanged }: Props) { } function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' || e.key === ',') { + if (e.key === 'Escape') { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setDropdownOpen(false); + inputRef.current?.blur(); + } else if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (trimmed) addTag(trimmed); } else if (e.key === 'Backspace' && input === '' && tags.length > 0) { From 8d4f80e0e18e2bc91669904f2eb39af82d6b65f2 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:40:41 -0400 Subject: [PATCH 05/15] Sort applied tags by insertion order, not alphabetically Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/contacts.ts | 6 +++--- src/test/tags.test.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index f5aaa630..98cb6e0d 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -175,7 +175,7 @@ export function registerContactHandlers(): void { FROM project_memberships pm JOIN projects p ON p.id = pm.project_id WHERE pm.contact_id = c.id ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw, - (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY tag) AS tags_raw + (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw FROM contacts c ORDER BY c.name COLLATE NOCASE ASC`, ) @@ -270,7 +270,7 @@ export function registerContactHandlers(): void { reporters: allReporters.filter((r) => r.membership_id === p.membership_id), })); - const tags = (stmt(db, 'SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY tag') + const tags = (stmt(db, 'SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY created_at') .all(id) as { tag: string }[]).map((r) => r.tag); return { ...contact, emails, phones, links, handles, projects: projectsWithReporters, tags } as ContactDetail; @@ -422,7 +422,7 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw, (SELECT MIN(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_first_contacted, (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted, - (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY tag) AS tags_raw + (SELECT GROUP_CONCAT(tag, '\x1f') FROM contact_tags WHERE contact_id = c.id ORDER BY created_at) AS tags_raw FROM project_memberships pm JOIN contacts c ON c.id = pm.contact_id WHERE pm.project_id = ? diff --git a/src/test/tags.test.ts b/src/test/tags.test.ts index 9c9c844b..8d985756 100644 --- a/src/test/tags.test.ts +++ b/src/test/tags.test.ts @@ -35,9 +35,10 @@ beforeEach(() => { // Helpers // --------------------------------------------------------------------------- +let _tagTs = Math.floor(Date.now() / 1000); function insertTag(db: Database.Database, contactId: string, tag: string): void { db.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') - .run(uuidv4(), contactId, tag, Math.floor(Date.now() / 1000)); + .run(uuidv4(), contactId, tag, _tagTs++); } function getTags(db: Database.Database, contactId: string): string[] { @@ -146,12 +147,12 @@ describe('contacts:list — tags field', () => { expect(rows[0].tags).toEqual([]); }); - it('returns tags sorted alphabetically', async () => { + it('returns tags in insertion order', async () => { const contactId = insertContact(testDb, 'Alice'); insertTag(testDb, contactId, 'zebra'); insertTag(testDb, contactId, 'alpha'); const rows = await handlers.get('contacts:list')!({}) as { tags: string[] }[]; - expect(rows[0].tags).toEqual(['alpha', 'zebra']); + expect(rows[0].tags).toEqual(['zebra', 'alpha']); }); it('does not mix tags between contacts', async () => { @@ -173,12 +174,12 @@ describe('contacts:list — tags field', () => { // --------------------------------------------------------------------------- describe('contacts:get — tags field', () => { - it('returns tags for a contact', async () => { + it('returns tags in insertion order', async () => { const contactId = insertContact(testDb, 'Alice'); insertTag(testDb, contactId, 'source'); insertTag(testDb, contactId, 'legal'); const detail = await handlers.get('contacts:get')!({}, contactId) as { tags: string[] }; - expect(detail.tags).toEqual(['legal', 'source']); + expect(detail.tags).toEqual(['source', 'legal']); }); it('returns an empty tags array when none exist', async () => { From cbd92299e40a2799b70abf52cba6a6d82daadef0 Mon Sep 17 00:00:00 2001 From: Tom Cardoso Date: Fri, 5 Jun 2026 12:51:15 -0400 Subject: [PATCH 06/15] Improve tags table cell and filter dropdown - Table cell: show first 2 tags + +N overflow badge instead of clipping - Filter dropdown: selected options float to top; search input appears when options exceed 10; list capped at 50 visible items Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/views/AllContacts.css | 10 +++++-- src/renderer/src/views/ColumnHeader.css | 21 +++++++++++++++ src/renderer/src/views/ColumnHeader.tsx | 33 ++++++++++++++++++++++-- src/renderer/src/views/ContactsTable.tsx | 11 +++++--- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/views/AllContacts.css b/src/renderer/src/views/AllContacts.css index 2923b30e..d72273d4 100644 --- a/src/renderer/src/views/AllContacts.css +++ b/src/renderer/src/views/AllContacts.css @@ -122,9 +122,7 @@ } .contact-tags-cell { - max-width: 180px; white-space: nowrap; - overflow: hidden; } .contact-tag-chip { @@ -140,6 +138,14 @@ white-space: nowrap; } +.contact-tag-overflow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + color: var(--color-text-dim); + white-space: nowrap; +} + /* Compact boolean cells (email, phone, notes) */ .contact-bool-cell { text-align: center; diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index f2a47f79..da424969 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -224,6 +224,27 @@ overflow-y: auto; } +.col-filter-search-wrap { + padding: 4px 6px; + border-bottom: 1px solid var(--color-border); +} + +.col-filter-search { + width: 100%; + border: 1px solid var(--color-border); + background: var(--color-bg); + font-family: var(--font-serif); + font-size: 13px; + color: var(--color-text); + padding: 3px 6px; + outline: none; + box-sizing: border-box; +} + +.col-filter-search::placeholder { + color: var(--color-text-dim); +} + .col-filter-clear-all { display: block; width: 100%; diff --git a/src/renderer/src/views/ColumnHeader.tsx b/src/renderer/src/views/ColumnHeader.tsx index ad1d62e8..3f2e558b 100644 --- a/src/renderer/src/views/ColumnHeader.tsx +++ b/src/renderer/src/views/ColumnHeader.tsx @@ -162,6 +162,9 @@ export function PresetFilter({ ); } +const MULTISELECT_SEARCH_THRESHOLD = 10; +const MULTISELECT_MAX_VISIBLE = 50; + export function MultiSelectFilter({ options, selected, @@ -171,10 +174,24 @@ export function MultiSelectFilter({ selected: string[]; onChange: (v: string[]) => void; }) { + const [search, setSearch] = useState(''); + function toggle(val: string) { onChange(selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val]); } + const showSearch = options.length > MULTISELECT_SEARCH_THRESHOLD; + const q = search.trim().toLowerCase(); + + const selectedSet = new Set(selected); + const filtered = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) + .slice(0, MULTISELECT_MAX_VISIBLE); + + const checkedFirst = [ + ...filtered.filter((o) => selectedSet.has(o.value)), + ...filtered.filter((o) => !selectedSet.has(o.value)), + ]; + return (
{selected.length > 0 && ( @@ -182,11 +199,23 @@ export function MultiSelectFilter({ Clear all )} - {options.map((opt) => ( + {showSearch && ( +
+ setSearch(e.target.value)} + autoFocus + /> +
+ )} + {checkedFirst.map((opt) => (