diff --git a/Blackbook.xcodeproj/project.pbxproj b/Blackbook.xcodeproj/project.pbxproj index 5aae4b7..64a4960 100644 --- a/Blackbook.xcodeproj/project.pbxproj +++ b/Blackbook.xcodeproj/project.pbxproj @@ -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 */; }; @@ -171,6 +173,7 @@ 032E7774C5F4D3CFD826B428 /* EntityListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityListRow.swift; sourceTree = ""; }; 0C76007980526C6D096CFBD6 /* NetworkGraphViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphViewModel.swift; sourceTree = ""; }; 0D88B14476C7E4A4574B1FB1 /* LogInteractionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogInteractionView.swift; sourceTree = ""; }; + 0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSuggestionEngineTests.swift; sourceTree = ""; }; 101D3964C1DEC2520B694372 /* GroupModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupModelTests.swift; sourceTree = ""; }; 107F0516F43606F715A04CAC /* ServerMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMenuView.swift; sourceTree = ""; }; 12AE2904F18728288E8C2218 /* TagModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagModelTests.swift; sourceTree = ""; }; @@ -200,6 +203,7 @@ 367B9B783BB9B16DC5A78564 /* SyncApplyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncApplyTests.swift; sourceTree = ""; }; 367F11DDCEA44032C3674833 /* Reminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reminder.swift; sourceTree = ""; }; 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 = ""; }; 3C395757BFF2A510337F73D5 /* ContactDeduplicationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactDeduplicationService.swift; sourceTree = ""; }; 3CA28E6E954153EB857D2D17 /* UserActionLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActionLogger.swift; sourceTree = ""; }; 4353ABA45B9630F4CF7DBF0C /* ReminderModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderModelTests.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/Blackbook/Models/Contact.swift b/Blackbook/Models/Contact.swift index 6565ef0..a5b02a3 100644 --- a/Blackbook/Models/Contact.swift +++ b/Blackbook/Models/Contact.swift @@ -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" diff --git a/Blackbook/Services/ContactSuggestionEngine.swift b/Blackbook/Services/ContactSuggestionEngine.swift new file mode 100644 index 0000000..7406cbb --- /dev/null +++ b/Blackbook/Services/ContactSuggestionEngine.swift @@ -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 = [], + 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) + } +} diff --git a/Blackbook/Services/ContactSyncService.swift b/Blackbook/Services/ContactSyncService.swift index 4193c6d..6e8116a 100644 --- a/Blackbook/Services/ContactSyncService.swift +++ b/Blackbook/Services/ContactSyncService.swift @@ -45,9 +45,16 @@ final class ContactSyncService { return } - // Run synchronously on the main thread to keep ModelContext safe. - importContacts(into: modelContext) - startObservingChanges() + // Defer the launch import by one second so the first frame renders before the import + // runs. Combined with the background-context apply in importContacts, this prevents the + // launch-time re-import from faulting a live @Query mid-insert — the crash loop that + // left the app unable to reopen after an "Import All" (work_log: import crash). + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard let self else { return } + self.importContacts(into: modelContext) + self.startObservingChanges() + } } func startObservingChanges() { @@ -95,9 +102,15 @@ final class ContactSyncService { let started = Date() do { let cnContacts = try fetchAllSystemContacts() - let outcome = try mergeOrInsert(cnContacts, into: modelContext) - try modelContext.save() + // Apply on a background context with a single settled commit so the main context's + // @Queries (ContactListView, etc.) never observe the partial-state insert window — + // the SwiftData inverse fault that crashed "Import All" (mirrors the PR #43 pull fix). + let bgContext = ModelContext(modelContext.container) + bgContext.autosaveEnabled = false + let outcome = try mergeOrInsert(cnContacts, into: bgContext) + try bgContext.save() lastSyncDate = Date() + NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil) logger.info("Contact sync completed — processed=\(cnContacts.count) updated=\(outcome.identifierMatched) reattached=\(outcome.reattached) inserted=\(outcome.inserted)") let durationMs = Int(Date().timeIntervalSince(started) * 1000) Log.action("contacts.sync", metadata: [ @@ -153,9 +166,14 @@ final class ContactSyncService { do { let allSystem = fetchSystemContacts() let selected = allSystem.filter { identifiers.contains($0.identifier) } - let outcome = try mergeOrInsert(selected, into: modelContext) - try modelContext.save() + // Background-context apply + single commit, as in importContacts (avoids the + // partial-state @Query fault during bulk insert). + let bgContext = ModelContext(modelContext.container) + bgContext.autosaveEnabled = false + let outcome = try mergeOrInsert(selected, into: bgContext) + try bgContext.save() lastSyncDate = Date() + NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil) logger.info("Selective import completed — \(selected.count) contacts imported") let durationMs = Int(Date().timeIntervalSince(started) * 1000) Log.action("contacts.sync.selective", metadata: [ diff --git a/Blackbook/Services/LocalServerSyncService.swift b/Blackbook/Services/LocalServerSyncService.swift index 3721643..bcfa810 100644 --- a/Blackbook/Services/LocalServerSyncService.swift +++ b/Blackbook/Services/LocalServerSyncService.swift @@ -466,6 +466,12 @@ final class LocalServerSyncService { } try bgContext.save() + + // Re-derive lastInteractionDate from the interaction records this device now holds and + // recompute relationship scores, so health reflects freshly-synced interactions even + // when the Dashboard isn't the visible tab (on macOS its @State recalc wouldn't run). + // Runs on the background context → the main context's @Queries see one settled commit. + RelationshipScoreEngine().recalculateAll(context: bgContext) } private func fetchPendingContacts(context: ModelContext) throws -> [Contact] { diff --git a/Blackbook/Services/RelationshipScoreEngine.swift b/Blackbook/Services/RelationshipScoreEngine.swift index 9a9a2ed..53053fc 100644 --- a/Blackbook/Services/RelationshipScoreEngine.swift +++ b/Blackbook/Services/RelationshipScoreEngine.swift @@ -12,14 +12,43 @@ final class RelationshipScoreEngine { UserDefaults.standard.object(forKey: "scoring.recencyWeight") as? Double ?? AppConstants.Scoring.recencyWeight } - /// Recalculates scores and trends for all contacts using only direct - /// Contact properties. Never accesses lazy SwiftData relationships. + /// Recalculates scores and trends for all contacts. + /// + /// First re-derives each contact's `lastInteractionDate` from its actual `Interaction` + /// records on *this* device, then scores from that field. This decouples the score from + /// cross-device propagation of the denormalized `lastInteractionDate`: synced interaction + /// rows arrive cleanly, but the contact-field update can be rejected by conflict resolution + /// (`ContactSyncApply.applyRemoteContact`) when the local copy is newer + pending, leaving + /// recency stuck at 0 even though the texts are present (work_log: Hugo Dooner). + /// + /// We deliberately read interactions via a single `FetchDescriptor` and group + /// by the to-one `interaction.contact?.id` — the same controlled service-side pattern used + /// by the server's `IMessageSyncService`. We never touch the `Contact.interactions` to-many + /// inverse, which is what faults under `@Query` re-renders (work_log 2026-06-01/02). func recalculateAll(context: ModelContext) { do { let contacts = try context.fetch(FetchDescriptor()) let sevenDaysAgo = Date.daysAgo(7) + // Latest interaction date per contact, derived from the records this device holds. + var latestInteraction: [UUID: Date] = [:] + for ix in try context.fetch(FetchDescriptor()) { + guard let cid = ix.contact?.id else { continue } + if let current = latestInteraction[cid] { + if ix.date > current { latestInteraction[cid] = ix.date } + } else { + latestInteraction[cid] = ix.date + } + } + for contact in contacts { + // Heal a stale/missing denormalized date from real interaction records. + // Local-only correction — no markLocallyEdited(); each device derives its own. + if let derived = latestInteraction[contact.id], + contact.lastInteractionDate == nil || derived > contact.lastInteractionDate! { + contact.lastInteractionDate = derived + } + // Score based on recency of last interaction + priority boost var score: Double = 0 if let lastDate = contact.lastInteractionDate { diff --git a/Blackbook/ViewModels/ContactListViewModel.swift b/Blackbook/ViewModels/ContactListViewModel.swift index 92ad362..103f9db 100644 --- a/Blackbook/ViewModels/ContactListViewModel.swift +++ b/Blackbook/ViewModels/ContactListViewModel.swift @@ -7,19 +7,49 @@ final class ContactListViewModel { var selectedTags: Set = [] var selectedGroups: Set = [] var selectedLocations: Set = [] - 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 { @@ -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 + } } diff --git a/Blackbook/Views/Contacts/ContactDetailView.swift b/Blackbook/Views/Contacts/ContactDetailView.swift index bb27b58..d24dbd4 100644 --- a/Blackbook/Views/Contacts/ContactDetailView.swift +++ b/Blackbook/Views/Contacts/ContactDetailView.swift @@ -396,7 +396,7 @@ struct MetViaPickerView: View { @State private var searchText = "" private var eligible: [Contact] { - allContacts.filter { $0.id != contact.id && !$0.isHidden && !$0.isMergedAway } + allContacts.selectable.filter { $0.id != contact.id } } private var filtered: [Contact] { @@ -405,8 +405,37 @@ struct MetViaPickerView: View { return eligible.filter { $0.displayName.lowercased().contains(query) } } + /// Three likely introducers (shared tags/groups/locations, or the same `metVia` chain), shown + /// when not searching. Excludes the current selection. + private var suggestions: [Contact] { + let excluded = contact.metVia.map { Set([$0.id]) } ?? [] + return ContactSuggestionEngine.suggestions(for: contact, field: .metVia, from: allContacts, excluding: excluded) + } + + private func select(_ c: Contact) { + contact.metVia = c + contact.markLocallyEdited() + try? modelContext.save() + dismiss() + } + + @ViewBuilder + private func metViaRow(_ c: Contact) -> some View { + Button { select(c) } label: { + HStack(spacing: 10) { + ContactAvatarView(contact: c, size: 32) + Text(c.displayName).font(.body) + Spacer() + if contact.metVia?.id == c.id { + Image(systemName: "checkmark").foregroundStyle(AppConstants.UI.accentGold) + } + } + }.buttonStyle(.plain) + } + var body: some View { - NavigationStack { + let suggestionIDs = Set(suggestions.map(\.id)) + return NavigationStack { List { Section { Button { @@ -424,25 +453,14 @@ struct MetViaPickerView: View { } } } - Section { - ForEach(filtered) { c in - Button { - contact.metVia = c - contact.markLocallyEdited() - try? modelContext.save() - dismiss() - } label: { - HStack(spacing: 10) { - ContactAvatarView(contact: c, size: 32) - Text(c.displayName).font(.body) - Spacer() - if contact.metVia?.id == c.id { - Image(systemName: "checkmark").foregroundStyle(AppConstants.UI.accentGold) - } - } - }.buttonStyle(.plain) + if searchText.isEmpty && !suggestions.isEmpty { + Section("Suggested") { + ForEach(suggestions) { metViaRow($0) } } } + Section(searchText.isEmpty ? "All Contacts" : "") { + ForEach(filtered.filter { !suggestionIDs.contains($0.id) }) { metViaRow($0) } + } } .searchable(text: $searchText, prompt: "Search contacts") .navigationTitle("Met via") @@ -470,7 +488,7 @@ struct IntroducedToPickerView: View { @State private var selectedIDs: Set = [] private var eligible: [Contact] { - allContacts.filter { $0.id != contact.id && !$0.isHidden && !$0.isMergedAway } + allContacts.selectable.filter { $0.id != contact.id } } private var filtered: [Contact] { @@ -482,34 +500,49 @@ struct IntroducedToPickerView: View { } } + /// Three contextually-similar contacts (shared tags/groups/locations), excluding any already + /// introduced. Shown only when not searching, so the right person is usually one tap away. + private var suggestions: [Contact] { + ContactSuggestionEngine.suggestions(for: contact, field: .introducedTo, from: allContacts, excluding: selectedIDs) + } + + private func toggle(_ c: Contact) { + if selectedIDs.contains(c.id) { selectedIDs.remove(c.id) } else { selectedIDs.insert(c.id) } + } + + @ViewBuilder + private func contactRow(_ c: Contact) -> some View { + Button { toggle(c) } label: { + HStack(spacing: 10) { + ContactAvatarView(contact: c, size: 32) + VStack(alignment: .leading, spacing: 2) { + Text(c.displayName).font(.body) + if let company = c.company, !company.isEmpty { + Text(company).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if selectedIDs.contains(c.id) { + Image(systemName: "checkmark").foregroundStyle(AppConstants.UI.accentGold) + } + } + }.buttonStyle(.plain) + } + var body: some View { - NavigationStack { + let suggestionIDs = Set(suggestions.map(\.id)) + return NavigationStack { List { + if searchText.isEmpty && !suggestions.isEmpty { + Section("Suggested") { + ForEach(suggestions) { contactRow($0) } + } + } if !searchText.isEmpty && filtered.isEmpty { ContentUnavailableView.search(text: searchText) } else { - ForEach(filtered) { c in - Button { - if selectedIDs.contains(c.id) { - selectedIDs.remove(c.id) - } else { - selectedIDs.insert(c.id) - } - } label: { - HStack(spacing: 10) { - ContactAvatarView(contact: c, size: 32) - VStack(alignment: .leading, spacing: 2) { - Text(c.displayName).font(.body) - if let company = c.company, !company.isEmpty { - Text(company).font(.caption).foregroundStyle(.secondary) - } - } - Spacer() - if selectedIDs.contains(c.id) { - Image(systemName: "checkmark").foregroundStyle(AppConstants.UI.accentGold) - } - } - }.buttonStyle(.plain) + Section(searchText.isEmpty ? "All Contacts" : "") { + ForEach(filtered.filter { !suggestionIDs.contains($0.id) }) { contactRow($0) } } } } diff --git a/Blackbook/Views/Contacts/ContactFormView.swift b/Blackbook/Views/Contacts/ContactFormView.swift index 34ae6b8..69fa1cb 100644 --- a/Blackbook/Views/Contacts/ContactFormView.swift +++ b/Blackbook/Views/Contacts/ContactFormView.swift @@ -64,7 +64,7 @@ struct ContactFormView: View { } Section { DisclosureGroup(isExpanded: sectionBinding("Met via")) { - let eligible = allContacts.filter { $0.id != contact?.id && !$0.isHidden && !$0.isMergedAway } + let eligible = allContacts.selectable.filter { $0.id != contact?.id } Picker("Met via", selection: $metViaContactId) { Text("None").tag(UUID?.none) ForEach(eligible) { c in diff --git a/Blackbook/Views/Contacts/ContactListView.swift b/Blackbook/Views/Contacts/ContactListView.swift index a4e710d..962279c 100644 --- a/Blackbook/Views/Contacts/ContactListView.swift +++ b/Blackbook/Views/Contacts/ContactListView.swift @@ -134,8 +134,12 @@ struct ContactListView: View { } } } header: { - ContactTableHeaderView() - .textCase(nil) + ContactTableHeaderView( + sortColumn: viewModel.sortColumn, + ascending: viewModel.sortAscending, + onTap: { viewModel.toggleSort($0) } + ) + .textCase(nil) } } .navigationDestination(for: UUID.self) { id in @@ -153,8 +157,16 @@ struct ContactListView: View { ToolbarItem(placement: .primaryAction) { Button { showAddContact = true } label: { Image(systemName: "plus") } } ToolbarItem(placement: .automatic) { Menu { - Picker("Sort", selection: $viewModel.sortOrder) { - ForEach(ContactListViewModel.ContactSortOrder.allCases) { Text($0.rawValue).tag($0) } + ForEach([ContactListViewModel.SortColumn.name, .score, .recent, .dateAdded]) { column in + Button { + viewModel.toggleSort(column) + } label: { + if viewModel.sortColumn == column { + Label(column.rawValue, systemImage: viewModel.sortAscending ? "chevron.up" : "chevron.down") + } else { + Text(column.rawValue) + } + } } Divider() NavigationLink { SmartGroupsView() } label: { Label("Smart Groups", systemImage: "folder.badge.gearshape") } @@ -266,6 +278,9 @@ struct CollapsibleFilterSection: View { // MARK: - Table Header struct ContactTableHeaderView: View { + let sortColumn: ContactListViewModel.SortColumn + let ascending: Bool + let onTap: (ContactListViewModel.SortColumn) -> Void #if os(iOS) @Environment(\.horizontalSizeClass) private var sizeClass #endif @@ -281,25 +296,35 @@ struct ContactTableHeaderView: View { var body: some View { if !isCompact { HStack(spacing: 0) { - Text("Name") - .frame(maxWidth: .infinity, alignment: .leading) - Text("Groups") - .frame(width: ColumnWidth.groups, alignment: .leading) - Text("Locations") - .frame(width: ColumnWidth.locations, alignment: .leading) - Text("Tags") - .frame(width: ColumnWidth.tags, alignment: .leading) - Text("Met via") - .frame(width: ColumnWidth.metVia, alignment: .leading) - Text("Introduced to") - .frame(width: ColumnWidth.introducedTo, alignment: .leading) - Text("Score") - .frame(width: ColumnWidth.score, alignment: .trailing) + headerCell("Name", .name, width: nil, alignment: .leading) + headerCell("Groups", .groups, width: ColumnWidth.groups, alignment: .leading) + headerCell("Locations", .locations, width: ColumnWidth.locations, alignment: .leading) + headerCell("Tags", .tags, width: ColumnWidth.tags, alignment: .leading) + headerCell("Met via", .metVia, width: ColumnWidth.metVia, alignment: .leading) + headerCell("Introduced to", .introducedTo, width: ColumnWidth.introducedTo, alignment: .leading) + headerCell("Score", .score, width: ColumnWidth.score, alignment: .trailing) } .font(.title3.weight(.bold)) .foregroundStyle(.primary) } } + + /// A tappable column header. Tapping sorts by the column; tapping the active column flips the + /// direction. The active column shows a ▲/▼ chevron. + @ViewBuilder + private func headerCell(_ title: String, _ column: ContactListViewModel.SortColumn, width: CGFloat?, alignment: Alignment) -> some View { + Button { onTap(column) } label: { + HStack(spacing: 3) { + Text(title) + Image(systemName: ascending ? "chevron.up" : "chevron.down") + .font(.caption2.weight(.bold)) + .opacity(sortColumn == column ? 1 : 0) + } + .frame(maxWidth: width == nil ? .infinity : width, alignment: alignment) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } } // MARK: - Contact Row diff --git a/Blackbook/Views/Contacts/MergeContactPickerView.swift b/Blackbook/Views/Contacts/MergeContactPickerView.swift index af40df5..beec9df 100644 --- a/Blackbook/Views/Contacts/MergeContactPickerView.swift +++ b/Blackbook/Views/Contacts/MergeContactPickerView.swift @@ -13,7 +13,7 @@ struct MergeContactPickerView: View { @State private var showPrimarySelection = false private var eligible: [Contact] { - allContacts.filter { $0.id != initialPrimary.id && !$0.isHidden && !$0.isMergedAway } + allContacts.selectable.filter { $0.id != initialPrimary.id } } private var filtered: [Contact] { diff --git a/BlackbookTests/ContactListViewModelTests.swift b/BlackbookTests/ContactListViewModelTests.swift index 1fcae22..12f35b9 100644 --- a/BlackbookTests/ContactListViewModelTests.swift +++ b/BlackbookTests/ContactListViewModelTests.swift @@ -120,7 +120,7 @@ final class ContactListViewModelTests: XCTestCase { let c2 = makeContact(firstName: "Alice", lastName: "Zeller") let c3 = makeContact(firstName: "Bob", lastName: "Adams") - vm.sortOrder = .name + vm.sortColumn = .name // ascending by default let result = vm.filteredContacts([c1, c2, c3], tags: []) // Sorted by lastName then firstName: Adams (Bob), Adams (Zoe), Zeller (Alice) @@ -134,7 +134,7 @@ final class ContactListViewModelTests: XCTestCase { let c2 = makeContact(firstName: "High", lastName: "Score", score: 90) let c3 = makeContact(firstName: "Mid", lastName: "Score", score: 50) - vm.sortOrder = .score + vm.toggleSort(.score) // numeric columns default to descending (highest first) let result = vm.filteredContacts([c1, c2, c3], tags: []) XCTAssertEqual(result[0].firstName, "High") @@ -142,6 +142,47 @@ final class ContactListViewModelTests: XCTestCase { XCTAssertEqual(result[2].firstName, "Low") } + // MARK: - Click-to-sort column behavior + + func testToggleSortFlipsDirectionOnSameColumn() throws { + let c1 = makeContact(firstName: "Zoe", lastName: "Apple") + let c2 = makeContact(firstName: "Alice", lastName: "Mango") + let c3 = makeContact(firstName: "Bob", lastName: "Zephyr") + + vm.toggleSort(.name) // .name is the default column → first toggle flips to descending + XCTAssertFalse(vm.sortAscending) + XCTAssertEqual(vm.filteredContacts([c1, c2, c3], tags: []).map(\.lastName), ["Zephyr", "Mango", "Apple"]) + + vm.toggleSort(.name) // back to ascending + XCTAssertTrue(vm.sortAscending) + XCTAssertEqual(vm.filteredContacts([c1, c2, c3], tags: []).map(\.lastName), ["Apple", "Mango", "Zephyr"]) + } + + func testStringColumnSortsEmptyValuesLastBothDirections() throws { + let container = try makeContainer() + let context = ModelContext(container) + let work = Group(name: "Work"); context.insert(work) + let school = Group(name: "School"); context.insert(school) + + let a = makeContact(firstName: "Has", lastName: "Work"); a.groups = [work] + let b = makeContact(firstName: "Has", lastName: "School"); b.groups = [school] + let c = makeContact(firstName: "No", lastName: "Group") // empty → always last + + vm.toggleSort(.groups) // ascending by first group name + XCTAssertEqual(vm.filteredContacts([a, b, c], tags: []).map(\.lastName), ["School", "Work", "Group"]) + + vm.toggleSort(.groups) // descending; empties still last + XCTAssertEqual(vm.filteredContacts([a, b, c], tags: []).map(\.lastName), ["Work", "School", "Group"]) + } + + func testSelectableHelperExcludesHiddenAndMerged() throws { + let visible = makeContact(firstName: "Ann", lastName: "Visible") + let hidden = makeContact(firstName: "Hank", lastName: "Hidden", isHidden: true) + let merged = makeContact(firstName: "Mona", lastName: "Merged", isMergedAway: true) + + XCTAssertEqual([visible, hidden, merged].selectable.map(\.firstName), ["Ann"]) + } + func testEmptySearchReturnsAll() throws { let c1 = makeContact(firstName: "Alice", lastName: "Smith") let c2 = makeContact(firstName: "Bob", lastName: "Jones") diff --git a/BlackbookTests/ContactSuggestionEngineTests.swift b/BlackbookTests/ContactSuggestionEngineTests.swift new file mode 100644 index 0000000..393ac43 --- /dev/null +++ b/BlackbookTests/ContactSuggestionEngineTests.swift @@ -0,0 +1,80 @@ +import XCTest +import SwiftData +@testable import Blackbook + +/// Tests for `ContactSuggestionEngine` — the per-field "3 suggested records" ranking used by the +/// Introduced-to / Met-via pickers. Suggestions are ranked by contextual similarity (shared tags, +/// groups, locations) and fall back to relationship score so suggestions are always available. +@MainActor +final class ContactSuggestionEngineTests: XCTestCase { + + private var container: ModelContainer! + private var context: ModelContext! + + override func setUpWithError() throws { + container = try TestHelpers.makeContainer() + context = ModelContext(container) + } + + override func tearDown() { + context = nil + container = nil + } + + @discardableResult + private func contact(_ first: String, score: Double = 0, hidden: Bool = false) -> Contact { + let c = TestHelpers.makeContact(firstName: first, lastName: "X", score: score, isHidden: hidden, in: context) + return c + } + + private func tag(_ name: String) -> Tag { let t = Tag(name: name); context.insert(t); return t } + private func group(_ name: String) -> Group { let g = Group(name: name); context.insert(g); return g } + + func testRanksBySharedTagsAndGroups() { + let subject = contact("Subject") + let climbing = tag("Climbing") + let work = group("Work") + subject.tags = [climbing] + subject.groups = [work] + + let strong = contact("Strong") // shares tag (×3) + group (×2) = 5 + strong.tags = [climbing]; strong.groups = [work] + let weak = contact("Weak") // shares group only (×2) = 2 + weak.groups = [work] + let none = contact("None", score: 99) // no overlap; high score but should rank last + + let result = ContactSuggestionEngine.suggestions(for: subject, field: .introducedTo, from: [strong, weak, none]) + + XCTAssertEqual(result.map(\.firstName), ["Strong", "Weak", "None"]) + } + + func testExcludesSubjectHiddenAndExcludedIDs() { + let subject = contact("Subject") + let hidden = contact("Hidden", hidden: true) + let excluded = contact("Excluded") + let ok = contact("Ok") + + let result = ContactSuggestionEngine.suggestions( + for: subject, field: .introducedTo, + from: [subject, hidden, excluded, ok], + excluding: [excluded.id] + ) + + XCTAssertEqual(result.map(\.firstName), ["Ok"]) + XCTAssertFalse(result.contains { $0.id == subject.id }) + XCTAssertFalse(result.contains { $0.isHidden }) + } + + func testAlwaysReturnsUpToThreeWithScoreFallback() { + let subject = contact("Subject") // no tags/groups → no similarity for anyone + let a = contact("A", score: 10) + let b = contact("B", score: 90) + let c = contact("C", score: 50) + let d = contact("D", score: 70) + + let result = ContactSuggestionEngine.suggestions(for: subject, field: .metVia, from: [a, b, c, d]) + + // With zero overlap, falls back to highest relationship score, capped at 3. + XCTAssertEqual(result.map(\.firstName), ["B", "D", "C"]) + } +} diff --git a/BlackbookTests/RelationshipScoreEngineTests.swift b/BlackbookTests/RelationshipScoreEngineTests.swift index 31acd26..e199213 100644 --- a/BlackbookTests/RelationshipScoreEngineTests.swift +++ b/BlackbookTests/RelationshipScoreEngineTests.swift @@ -239,4 +239,53 @@ final class RelationshipScoreEngineTests: XCTestCase { XCTAssertGreaterThan(contact1.relationshipScore, contact2.relationshipScore, "Recent contact should score higher than old") XCTAssertGreaterThan(contact2.relationshipScore, contact3.relationshipScore, "Old contact should score higher than no-interaction") } + + // MARK: - 14. Heal stale/missing lastInteractionDate from interaction records + // + // Regression: synced iMessage interaction *records* arrive, but the contact-field update that + // carries lastInteractionDate is rejected by conflict resolution (local newer + pending), so the + // device shows the texts yet recency stays 0 and the score sits at priority-only (Hugo Dooner: 20). + // recalculateAll must re-derive the date from the records the device already holds. + + func testHealsStaleDateFromInteractionRecord() throws { + let contact = makeContact() + addInteraction(to: contact, type: .text, date: Date.daysAgo(2)) + // Simulate the rejected field update: record present, denormalized date stale. + contact.lastInteractionDate = nil + try context.save() + + let score = recalculateAndGetScore(for: contact) + + XCTAssertNotNil(contact.lastInteractionDate, "date should be re-derived from the record") + XCTAssertGreaterThan(score, 80.0, "a 2-day-old interaction must lift recency, not leave the score at 0") + XCTAssertEqual(contact.scoreTrend, .up) + } + + func testPriorityContactRecoversAboveBoostAfterHeal() throws { + // The exact Hugo Dooner shape: priority + recent texts but a stale date pinning the score at 20. + let contact = makeContact(isPriority: true) + addInteraction(to: contact, type: .text, date: Date.daysAgo(2)) + contact.lastInteractionDate = nil + contact.relationshipScore = AppConstants.Scoring.priorityBoost // the stuck "20 / Fading" value + try context.save() + + let score = recalculateAndGetScore(for: contact) + + XCTAssertGreaterThan(score, AppConstants.Scoring.priorityBoost + 40, + "recent interactions must contribute recency on top of the priority boost") + } + + func testHealNeverLowersNewerManualDate() throws { + // A manual edit set a date newer than any record; recalc uses max() and must not regress it. + let contact = makeContact() + addInteraction(to: contact, type: .text, date: Date.daysAgo(30)) + let newer = Date.daysAgo(1) + contact.lastInteractionDate = newer + try context.save() + + engine.recalculateAll(context: context) + + XCTAssertEqual(contact.lastInteractionDate?.timeIntervalSince1970 ?? 0, + newer.timeIntervalSince1970, accuracy: 1.0) + } }