Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Blackbook.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
28D30061B05714E65E0FAA2C /* ForgotPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23843A28716C56CF55E7614C /* ForgotPasswordView.swift */; };
349461D101C51D6D178972AD /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8222BEE7020B6711480F644E /* Contact.swift */; };
38133D435F6BEF39527FC2CB /* GroupModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101D3964C1DEC2520B694372 /* GroupModelTests.swift */; };
3882488C9E1137855A4160D3 /* ContactSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AC89F208DED881F3BEFA245 /* ContactSuggestionEngine.swift */; };
391963EE7FEDF8139B2F90F5 /* ContactSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */; };
3C97130622FF6D29BACDEA77 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21445D604CF5DB4A2F69B7DF /* Activity.swift */; };
3D5BA26EDD7B1258E870B1D4 /* BonjourBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB12E01D4EB1C6E3A68182E /* BonjourBrowser.swift */; };
3D6714171EE7057CBE848A07 /* BlackbookServerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49097698C68BE415965A964 /* BlackbookServerApp.swift */; };
Expand Down Expand Up @@ -171,6 +173,7 @@
032E7774C5F4D3CFD826B428 /* EntityListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityListRow.swift; sourceTree = "<group>"; };
0C76007980526C6D096CFBD6 /* NetworkGraphViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphViewModel.swift; sourceTree = "<group>"; };
0D88B14476C7E4A4574B1FB1 /* LogInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogInteractionView.swift; sourceTree = "<group>"; };
0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSuggestionEngineTests.swift; sourceTree = "<group>"; };
101D3964C1DEC2520B694372 /* GroupModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupModelTests.swift; sourceTree = "<group>"; };
107F0516F43606F715A04CAC /* ServerMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMenuView.swift; sourceTree = "<group>"; };
12AE2904F18728288E8C2218 /* TagModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -200,6 +203,7 @@
367B9B783BB9B16DC5A78564 /* SyncApplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncApplyTests.swift; sourceTree = "<group>"; };
367F11DDCEA44032C3674833 /* Reminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reminder.swift; sourceTree = "<group>"; };
3744DE7F4CFD2EF1B4D4067B /* Blackbook.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Blackbook.app; sourceTree = BUILT_PRODUCTS_DIR; };
3AC89F208DED881F3BEFA245 /* ContactSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSuggestionEngine.swift; sourceTree = "<group>"; };
3C395757BFF2A510337F73D5 /* ContactDeduplicationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactDeduplicationService.swift; sourceTree = "<group>"; };
3CA28E6E954153EB857D2D17 /* UserActionLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActionLogger.swift; sourceTree = "<group>"; };
4353ABA45B9630F4CF7DBF0C /* ReminderModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -433,6 +437,7 @@
9C6BD1DE0B2466EA3DCFB985 /* ClaudeAPIService.swift */,
3C395757BFF2A510337F73D5 /* ContactDeduplicationService.swift */,
67485481A0ED6072989C3A0D /* ContactMergeService.swift */,
3AC89F208DED881F3BEFA245 /* ContactSuggestionEngine.swift */,
DC159C3515CC1B41D178A551 /* ContactSyncService.swift */,
2382E25FE6BDF339DA44560D /* GoogleCalendarService.swift */,
D876B79CE3D85BAABBEBD128 /* LocalServerSyncService.swift */,
Expand Down Expand Up @@ -518,6 +523,7 @@
FA40028F884B4B1161FA87B0 /* ContactMergeServiceTests.swift */,
D95AA83A5437DFA01312E219 /* ContactModelTests.swift */,
5D94FB331F41A7E2A777101F /* ContactRelationshipModelTests.swift */,
0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */,
8211A08153FF7DE2A707EB06 /* DashboardViewModelTests.swift */,
B242CFE0D00227BB630A5801 /* DateHelpersTests.swift */,
4DCA116B785721C8942FD325 /* FeatureGatingTests.swift */,
Expand Down Expand Up @@ -813,6 +819,7 @@
7FD59E02711302FDE3365206 /* ContactLocationPickerView.swift in Sources */,
138F6CF8015FD8C3F5728519 /* ContactMergeService.swift in Sources */,
8B2CB978D413E80BD9F38F3D /* ContactRelationship.swift in Sources */,
3882488C9E1137855A4160D3 /* ContactSuggestionEngine.swift in Sources */,
FE2725ADA73D3CD058F2D403 /* ContactSyncApply.swift in Sources */,
DB5A2A585D046B17B96EA4E2 /* ContactSyncService.swift in Sources */,
90834021AE7B9E22B64F9497 /* ContactTagPickerView.swift in Sources */,
Expand Down Expand Up @@ -891,6 +898,7 @@
28092BCCCBD27C677F28E65F /* ContactMergeServiceTests.swift in Sources */,
69E8237701B2BDD1763EEE1F /* ContactModelTests.swift in Sources */,
ACFC1AC1A920460AF58376E2 /* ContactRelationshipModelTests.swift in Sources */,
391963EE7FEDF8139B2F90F5 /* ContactSuggestionEngineTests.swift in Sources */,
55E07EAEBFC82392133172C8 /* DashboardViewModelTests.swift in Sources */,
FC4D35EB1F0F93963E0499E2 /* DateHelpersTests.swift in Sources */,
0143CB904AAF1E6B53CCBAD1 /* FeatureGatingTests.swift in Sources */,
Expand Down
9 changes: 9 additions & 0 deletions Blackbook/Models/Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ final class Contact {
}
}

extension Sequence where Element == Contact {
/// Contacts eligible to appear in any list, picker, or search surface — excludes hidden and
/// merged-away contacts. Single chokepoint for the CLAUDE.md rule "Hidden contacts must never
/// appear outside Settings > Hidden Contacts." Use everywhere contacts are offered for selection.
var selectable: [Contact] {
filter { !$0.isHidden && !$0.isMergedAway }
}
}

/// Buckets for relationship health based on numeric score thresholds.
enum ScoreCategory: String, Codable {
case strong = "Strong"
Expand Down
66 changes: 66 additions & 0 deletions Blackbook/Services/ContactSuggestionEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

/// Ranks contacts to surface as "suggested" records when picking a contact for a relationship field
/// (e.g. "Introduced to", "Met via"). Suggestions are a function of contextual similarity to the
/// subject — shared tags, groups, and locations — plus a per-field signal, so the user usually finds
/// the right person without typing. Falls back to highest relationship score when there's no overlap,
/// so the picker can *always* offer suggestions.
enum ContactSuggestionEngine {

/// The field a suggestion is being made for. Each field weights the similarity signals differently.
enum Field {
/// People the subject introduced you to (`metViaBacklinks`).
case introducedTo
/// The person who introduced you to the subject (`metVia`).
case metVia
}

/// Returns up to `limit` suggested contacts for `subject` in `field`, ranked by similarity.
///
/// - Parameters:
/// - subject: the contact whose field is being edited.
/// - field: which relationship field the suggestions are for.
/// - candidates: the pool to draw from (hidden / merged-away are filtered out here).
/// - excluding: contact IDs to omit (e.g. already-selected, the subject itself).
/// - limit: maximum number of suggestions (default 3).
static func suggestions(
for subject: Contact,
field: Field,
from candidates: [Contact],
excluding: Set<UUID> = [],
limit: Int = 3
) -> [Contact] {
let subjectTags = Set(subject.tags.map(\.id))
let subjectGroups = Set(subject.groups.map(\.id))
let subjectLocations = Set(subject.locations.map(\.id))

func similarity(_ c: Contact) -> Double {
var score = 0.0
score += Double(Set(c.tags.map(\.id)).intersection(subjectTags).count) * 3
score += Double(Set(c.groups.map(\.id)).intersection(subjectGroups).count) * 2
score += Double(Set(c.locations.map(\.id)).intersection(subjectLocations).count) * 2
switch field {
case .metVia:
// A likely connector shares the same introducer as the subject.
if let mv = c.metVia?.id, mv == subject.metVia?.id { score += 1 }
case .introducedTo:
// People you'd introduce tend to be already linked to the subject either way.
if c.metVia?.id == subject.id || subject.metVia?.id == c.id { score += 1 }
}
return score
}

let pool = candidates.selectable.filter { $0.id != subject.id && !excluding.contains($0.id) }
return pool
.map { (contact: $0, score: similarity($0)) }
.sorted { lhs, rhs in
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.contact.relationshipScore != rhs.contact.relationshipScore {
return lhs.contact.relationshipScore > rhs.contact.relationshipScore
}
return lhs.contact.displayName.localizedCaseInsensitiveCompare(rhs.contact.displayName) == .orderedAscending
}
.prefix(limit)
.map(\.contact)
}
}
117 changes: 100 additions & 17 deletions Blackbook/ViewModels/ContactListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,49 @@ final class ContactListViewModel {
var selectedTags: Set<UUID> = []
var selectedGroups: Set<UUID> = []
var selectedLocations: Set<UUID> = []
var sortOrder: ContactSortOrder = .name
var sortColumn: SortColumn = .name
var sortAscending = true

/// Available sort orders for the contact list.
enum ContactSortOrder: String, CaseIterable, Identifiable {
case name = "Name", score = "Score", recentInteraction = "Recent", dateAdded = "Added"
/// A sortable column. The first seven map to the table headers; `recent` / `dateAdded` are
/// extra sorts offered in the sort menu.
enum SortColumn: String, CaseIterable, Identifiable {
case name = "Name"
case groups = "Groups"
case locations = "Locations"
case tags = "Tags"
case metVia = "Met via"
case introducedTo = "Introduced to"
case score = "Score"
case recent = "Recent"
case dateAdded = "Added"
var id: String { rawValue }

/// Numeric columns default to descending (highest / most-recent first) on first selection.
var defaultsDescending: Bool {
switch self {
case .score, .recent, .dateAdded: return true
default: return false
}
}
}

var showHidden = false

/// Filters contacts by search text, selected tags/groups/locations, and hidden state, then sorts by the current sort order.
/// Click a column header (or pick a sort): selects the column, or toggles direction if it's
/// already the active column.
func toggleSort(_ column: SortColumn) {
if sortColumn == column {
sortAscending.toggle()
} else {
sortColumn = column
sortAscending = !column.defaultsDescending
}
}

/// Filters contacts by search text, selected tags/groups/locations, and hidden state, then sorts
/// by the active column + direction.
func filteredContacts(_ contacts: [Contact], tags: [Tag], groups: [Group] = [], locations: [Location] = []) -> [Contact] {
var result = showHidden ? contacts.filter { !$0.isMergedAway } : contacts.filter { !$0.isHidden && !$0.isMergedAway }
var result = showHidden ? contacts.filter { !$0.isMergedAway } : contacts.selectable
if !searchText.isEmpty {
let q = searchText.lowercased()
result = result.filter {
Expand All @@ -36,18 +66,71 @@ final class ContactListViewModel {
if !selectedLocations.isEmpty {
result = result.filter { !Set($0.locations.map(\.id)).isDisjoint(with: selectedLocations) }
}
switch sortOrder {
case .name: return result.sorted {
let lhs = $0.lastName.isEmpty
let rhs = $1.lastName.isEmpty
if lhs != rhs { return rhs }
let lastCmp = $0.lastName.localizedCaseInsensitiveCompare($1.lastName)
if lastCmp != .orderedSame { return lastCmp == .orderedAscending }
return $0.firstName.localizedCaseInsensitiveCompare($1.firstName) == .orderedAscending
return sorted(result)
}

// MARK: - Sorting

private func sorted(_ contacts: [Contact]) -> [Contact] {
switch sortColumn {
case .name:
return contacts.sorted { directionalNameLess($0, $1) }
case .score:
return contacts.sorted { numericLess($0, $1, key: { $0.relationshipScore }) }
case .recent:
return contacts.sorted { numericLess($0, $1, key: { $0.lastInteractionDate?.timeIntervalSince1970 ?? -.greatestFiniteMagnitude }) }
case .dateAdded:
return contacts.sorted { numericLess($0, $1, key: { $0.createdAt.timeIntervalSince1970 }) }
case .groups, .locations, .tags, .metVia, .introducedTo:
return contacts.sorted { stringLess($0, $1) }
}
}

/// A→Z by last name (blank last names last), first name as tiebreak; reversed when descending.
private func directionalNameLess(_ a: Contact, _ b: Contact) -> Bool {
let aEmpty = a.lastName.isEmpty, bEmpty = b.lastName.isEmpty
if aEmpty != bEmpty { return bEmpty } // blank last names always sort to the bottom
let cmp = a.lastName.localizedCaseInsensitiveCompare(b.lastName)
if cmp == .orderedSame {
return a.firstName.localizedCaseInsensitiveCompare(b.firstName) == .orderedAscending
}
case .score: return result.sorted { $0.relationshipScore > $1.relationshipScore }
case .recentInteraction: return result.sorted { ($0.lastInteractionDate ?? .distantPast) > ($1.lastInteractionDate ?? .distantPast) }
case .dateAdded: return result.sorted { $0.createdAt > $1.createdAt }
return sortAscending ? (cmp == .orderedAscending) : (cmp == .orderedDescending)
}

private func numericLess(_ a: Contact, _ b: Contact, key: (Contact) -> Double) -> Bool {
let ka = key(a), kb = key(b)
if ka == kb { return nameAscending(a, b) }
return sortAscending ? ka < kb : ka > kb
}

/// Sort by the active string-valued column. Empty values always sort to the bottom; ties fall
/// back to alphabetical name order.
private func stringLess(_ a: Contact, _ b: Contact) -> Bool {
let ka = stringKey(a), kb = stringKey(b)
if ka.isEmpty != kb.isEmpty { return kb.isEmpty } // non-empty before empty, regardless of direction
let cmp = ka.localizedCaseInsensitiveCompare(kb)
if cmp == .orderedSame { return nameAscending(a, b) }
return sortAscending ? (cmp == .orderedAscending) : (cmp == .orderedDescending)
}

private func stringKey(_ c: Contact) -> String {
switch sortColumn {
case .groups: return c.groups.map(\.name).min(by: caseInsensitiveLess) ?? ""
case .locations: return c.locations.map(\.name).min(by: caseInsensitiveLess) ?? ""
case .tags: return c.tags.map(\.name).min(by: caseInsensitiveLess) ?? ""
case .metVia: return c.metVia?.displayName ?? ""
case .introducedTo: return c.metViaBacklinks.map(\.displayName).min(by: caseInsensitiveLess) ?? ""
default: return ""
}
}

private func caseInsensitiveLess(_ a: String, _ b: String) -> Bool {
a.localizedCaseInsensitiveCompare(b) == .orderedAscending
}

private func nameAscending(_ a: Contact, _ b: Contact) -> Bool {
let cmp = a.lastName.localizedCaseInsensitiveCompare(b.lastName)
if cmp != .orderedSame { return cmp == .orderedAscending }
return a.firstName.localizedCaseInsensitiveCompare(b.firstName) == .orderedAscending
}
}
Loading
Loading