Skip to content

Add contact tags#421

Merged
tomcardoso merged 15 commits into
mainfrom
feature/contact-tags
Jun 5, 2026
Merged

Add contact tags#421
tomcardoso merged 15 commits into
mainfrom
feature/contact-tags

Conversation

@tomcardoso

@tomcardoso tomcardoso commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Free-form tags per contact, stored globally (one tag set per contact regardless of project)
  • Selectize-style editor in the contact detail view: type to filter existing tags, Enter or comma to create a new one, × to remove
  • Tags column in both AllContacts and ProjectView tables with a multi-select filter (OR semantics — show contacts that have any selected tag)
  • Tags included in the sync engine's sub-table merge/push path so they propagate across shared projects

What's new for users

You can now label contacts with one or more freeform tags (e.g. "whistleblower", "legal", "source") directly from the contact detail view. Tags autocomplete from existing ones across all your contacts. You can filter both the All Contacts list and any project view down to contacts that have a given tag.

Test plan

  • Open a contact — a Tags section appears below Notes. Add a tag by typing and pressing Enter or comma; confirm it appears as a chip
  • Add the same tag twice — only one chip should appear
  • Type a partial tag name — the autocomplete dropdown filters to matching existing tags; clicking one adds it
  • Press Backspace on an empty input — removes the last tag
  • Click × on a chip — tag is removed immediately
  • Open All Contacts — a Tags column is visible. Contacts with tags show chips; contacts without show —
  • Click the Tags column filter — multi-select dropdown lists all tags in the vault; selecting one filters the table to contacts that have it
  • Select multiple tags in the filter — table shows contacts that have any of the selected tags (OR logic)
  • Same filter behaviour in a Project view
  • Tags persist after closing and reopening the app
  • (If you have a shared project) Add a tag on one client, sync — tag appears on the other client

Implementation notes

  • contact_tags table added to both local and shared schema (no migration needed — pre-production)
  • Tags normalised to lowercase/trimmed on write; 50-char max; UNIQUE(contact_id, tag) enforces deduplication at the DB level
  • tags: string[] added to ContactListItem, ContactDetail, and ProjectContactRow
  • New IPC handlers: contacts:add-tag, contacts:remove-tag, contacts:list-all-tags
  • 20 new tests covering all IPC handlers and sync behaviour

Closes #418

tomcardoso and others added 8 commits June 5, 2026 12:19
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 <[email protected]>
Use --font-serif for the "Add a tag…" hint text instead of --font-mono.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- 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 <[email protected]>
@tomcardoso tomcardoso added the claude-review Trigger Claude code review label Jun 5, 2026
Comment thread src/main/database/schema.ts Outdated
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The synced_at column is declared here but never written to anywhere in this PR — it will always be NULL. Either populate it in the contacts:add-tag handler (and update it after a merge/push in the sync engine), or drop it to avoid dead schema.

Suggested change
synced_at INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
UNIQUE(contact_id, tag)

Comment thread src/renderer/src/views/ColumnHeader.tsx Outdated
Comment on lines +187 to +193
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)),
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cap (.slice(0, MULTISELECT_MAX_VISIBLE)) is applied before the selected-first sort, so if a user has selected tags that fall outside the first 50 options, those selected tags won't appear at the top (or at all until the user searches for them). Pin selected items first, then fill with unselected up to the cap:

Suggested change
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];

Comment thread src/main/sync/engine.ts Outdated
.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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tags are global (one set per contact across all projects), but pushSubTablesToShared is called per-project per-contact. This means if a contact belongs to two different shared projects synced from two different clients, the second push will DELETE the first client's tags before re-inserting its own — silently clobbering them on the shared DB.

The merge on the clobbered client's next sync will union them back locally, but then that client's next push overwrites shared again. The two clients can never converge on the shared copy.

One fix: merge rather than replace on push — only insert rows that are missing from shared, and optionally tombstone-delete rows that were explicitly removed locally (which requires tracking deletions). A simpler short-term fix is to union on push the same way merge does:

Suggested change
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 }[];
for (const t of tagRows) {
if (!sharedTagSet.has(t.tag)) {
shared
.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)')
.run(t.id, contactId, t.tag, t.created_at);
}
}

This won't propagate explicit local deletions to shared (since there's no tombstone table), but it at least prevents the clobber. Worth deciding explicitly which semantics you want before shipping this to multi-client users.


.col-filter-search {
width: 100%;
border: 1px solid var(--color-border);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--color-border isn't in the design token list in CLAUDE.md. If it isn't defined in global.css, this falls back to currentColor in most browsers and the border will be invisible/wrong in dark mode. The established border tokens are rgba(26, 24, 21, 0.14) (light) and 0.28 (strong). Consider using one of those directly:

Suggested change
border: 1px solid var(--color-border);
border: 1px solid rgba(26, 24, 21, 0.14);

Comment on lines +17 to +19
useEffect(() => {
window.sourcerer.listAllContactTags().then(setAllTags);
}, [tags]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refetches the complete global tag list every time the contact's tags array changes (i.e. on every add/remove). For vaults with many contacts and tags this could be a noticeable round-trip per keystroke.

Consider fetching once on mount and appending the new tag to allTags optimistically in addTag instead of relying on the re-fetch, or at minimum memoize the IPC call behind a short debounce. At minimum, the dependency could be [] with a manual refresh after a successful add, avoiding the unnecessary fetch on remove too.

Comment thread src/main/sync/engine.ts
Comment on lines +409 to +413
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: db.prepare() inside a loop re-parses the SQL on every iteration. Hoist it above the loop:

Suggested change
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);
}
const insertTag = local.prepare(
'INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)',
);
for (const t of mergedTags) {
insertTag.run(t.id, contactId, t.tag, t.created_at);
}

Same pattern applies in pushSubTablesToShared.

tomcardoso and others added 4 commits June 5, 2026 16:51
- 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 <[email protected]>
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 <[email protected]>
@tomcardoso tomcardoso added claude-review Trigger Claude code review and removed claude-review Trigger Claude code review labels Jun 5, 2026
Comment thread src/main/sync/engine.ts
const localOnlyTags = localTags.filter((t) => !sharedTagValues.has(t.tag));
const mergedTags = [...sharedTags, ...localOnlyTags];

local.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync doesn't propagate tag deletions

The merge strategy here is purely additive: it takes sharedTags ∪ localOnlyTags and writes the union back to local. That means if a user on client A removes a tag and syncs, client B will never learn about the deletion — the tag stays on B forever.

The same additive-only pattern is used in pushSubTablesToShared (only inserts new tags, never deletes). This is consistent with how some other sub-tables are handled, but for an explicitly user-removable attribute like tags it's a meaningful limitation worth calling out in docs or comments.

If you want true delete propagation you'd need a tombstone/deletion-log table (hard). A middle ground is "shared wins on delete" — delete local tags not present in shared during merge — but that would silently remove legitimately local-only tags. At minimum this behaviour should be documented here so future developers don't assume deletions propagate.

Comment thread src/main/ipc/contacts.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated_at is bumped even when nothing was deleted

If tag doesn't exist on contactId, the DELETE is a no-op, but contacts SET updated_at still runs, marking the contact as modified and triggering a spurious sync push.

Suggested change
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);

Comment thread src/main/ipc/contacts.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove-tag doesn't normalize the tag before querying

add-tag stores the tag lowercased and trimmed. If the renderer ever passes an un-normalized value here (e.g. a tag with trailing whitespace), the DELETE will silently miss and the tag won't be removed.

Suggested change
db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag);
db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, tag.trim().toLowerCase());

Comment thread src/main/ipc/contacts.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated_at bumped even when the INSERT was a no-op (duplicate tag)

INSERT OR IGNORE silently skips duplicates, but the UPDATE contacts SET updated_at always runs regardless. Adding the same tag twice sends a spurious sync. Use db.changes() on the statement result to guard it:

Suggested change
db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId);
const { changes } = db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)')
.run(uuidv4(), contactId, normalized, now);
if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId);

line-height: 1.5;
}

.tags-chip-remove {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing border-radius: 0 on a button element

Per the project design system (CLAUDE.md): "All interactive elements (buttons, inputs, selects, textareas) use border-radius: 0 — square corners everywhere, no exceptions." The .tags-chip-remove button needs an explicit reset here, even if the global stylesheet may default to 0, to be safe across browser agents and to follow the convention used elsewhere.

Suggested change
.tags-chip-remove {
.tags-chip-remove {
background: none;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-dim);
line-height: 1;
display: flex;
align-items: center;
}

onChange: (v: string[]) => void;
}) {
const [search, setSearch] = useState('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Search state is not reset when the filter panel closes and reopens

search is component-local state on MultiSelectFilter, which is re-mounted each time the filter panel is toggled. That means the state is actually reset on every open — but only because the component unmounts. If that rendering strategy ever changes (e.g. hidden with display:none rather than conditionally rendered), stale search terms would reappear.

More immediately: autoFocus on the search input fires every render while the panel is open (including when a checkbox is toggled), which can cause jarring focus jumps. Consider moving focus management to a useEffect with an empty dep array instead.

Comment thread src/test/tags.test.ts
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' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTimeout(1100) makes this test slow and fragile on a heavily loaded CI machine

The 1.1-second sleep is needed because updated_at uses Unix seconds (Math.floor(Date.now() / 1000)) and the test needs the two values to differ. A cleaner approach is to insert the contact with a fixed historical timestamp and compare it directly instead of relying on wall-clock advancement:

// Instead of sleeping, set a known past updated_at
const contactId = insertContact(testDb, 'Alice');
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(0);

Same pattern applies to the remove-tag variant below.

- 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 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class “contact tags” support across the app (DB schema, IPC, renderer UI, filtering, and shared-project sync) so tags can be edited in contact details and used to filter contact tables.

Changes:

  • Introduces contact_tags table to local/shared SQLite schemas and includes tags in sync sub-table merge/push.
  • Adds IPC + preload APIs for adding/removing tags and listing all distinct tags; updates contact list/detail/project-row payloads to include tags: string[].
  • Adds renderer UI for editing tags on the contact detail view and filtering/displaying tags in All Contacts and Project View tables.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/test/tags.test.ts New Vitest coverage for tag IPC handlers, list/get/list-for-project tag fields, and sync behavior.
src/shared/types.ts Extends shared contact row/detail/list types to include tags: string[].
src/renderer/src/views/ProjectView.tsx Computes tag filter options for project contacts and passes them into the table.
src/renderer/src/views/ContactsTable.tsx Adds Tags column rendering + multi-select filter support; adds tags to filter models.
src/renderer/src/views/ColumnHeader.tsx Enhances MultiSelectFilter with search + checked-first ordering + limits.
src/renderer/src/views/ColumnHeader.css Styles the new multiselect search input.
src/renderer/src/views/AllContacts.tsx Implements OR-semantics tag filtering and builds tag filter options for All Contacts.
src/renderer/src/views/AllContacts.css Adds styling for tag chips/overflow in the contacts table.
src/renderer/src/hooks/useProjectContacts.ts Adds OR-semantics tag filtering to project contact filtering hook.
src/renderer/src/env.d.ts Types the new window.sourcerer.*ContactTag* preload APIs.
src/renderer/src/contacts/TagsSection.tsx New selectize-style tag editor for contact detail view.
src/renderer/src/contacts/TagsSection.css Styles the tag editor (chips/input/dropdown).
src/renderer/src/contacts/GlobalTab.tsx Renders the new Tags section in the contact detail global tab.
src/preload/index.ts Exposes new tag IPC invocations via window.sourcerer.
src/main/sync/engine.ts Includes contact_tags in shared/local merge + push paths (additive-only merge).
src/main/ipc/contacts.ts Adds tag fields to contact list/get/list-for-project and introduces tag IPC handlers.
src/main/database/shared-schema.ts Adds contact_tags table + index to shared schema.
src/main/database/schema.ts Adds contact_tags table + index to local schema.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main/ipc/contacts.ts Outdated
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 created_at) AS tags_raw
Comment thread src/main/ipc/contacts.ts Outdated
(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 created_at) AS tags_raw
Comment thread src/main/ipc/contacts.ts
Comment on lines +773 to +781
ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => {
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);
if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId);
})();
broadcastContactsChanged();
});
Comment on lines +92 to +95
onChange={(e) => {
setInput(e.target.value.replace(',', ''));
setDropdownOpen(true);
}}
tomcardoso and others added 2 commits June 5, 2026 17:35
- 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 <[email protected]>
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 <[email protected]>
@tomcardoso tomcardoso merged commit e0a8df5 into main Jun 5, 2026
1 check passed
@tomcardoso tomcardoso deleted the feature/contact-tags branch June 5, 2026 21:37
@tomcardoso tomcardoso mentioned this pull request Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review Trigger Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Contact tags

2 participants