From 5e51e70585a6f1446736e8e482d67238b236bced Mon Sep 17 00:00:00 2001 From: Michael Yeack Date: Wed, 3 Jun 2026 17:53:27 -0700 Subject: [PATCH 1/2] Hotfix: stop Import-All crash loop + make recent interactions raise the score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two app-breaking issues, shipped together as an urgent hotfix. 1. Import-All crash / app won't reopen ContactSyncService imported on the MAIN ModelContext and saved once there. "Import All" inserts hundreds of Contacts; that main-context save fires ContactListView's @Query mid-transaction and faults the Contact.interactions inverse (EXC_BAD_ACCESS) — the exact failure PR #43 fixed for the pull path. Because startAutoSync re-imports on every .onAppear, it became a crash loop so the app couldn't reopen. Fix: importContacts/importSelected apply mergeOrInsert on a background ModelContext (autosaveEnabled=false) with a single settled save, so the main context's @Queries only ever see one committed state. startAutoSync also defers the launch import by 1s so the first frame renders first. Posts .blackbookSyncDidComplete after import so the Dashboard refetches. 2. Recent interactions don't raise the relationship score (Hugo Dooner) The score reads the denormalized Contact.lastInteractionDate. Synced iMessage interaction *records* arrive cleanly, but the contact-field update carrying lastInteractionDate is rejected by conflict resolution when the local copy is newer + pending — so recency stays 0 and the score sits at priority-only (exactly 20 / "Fading"). Fix: RelationshipScoreEngine.recalculateAll now re-derives each contact's lastInteractionDate from the Interaction records this device already holds (single FetchDescriptor, grouped by the to-one interaction.contact?.id — the same controlled pattern the server uses; never touches the faulting Contact.interactions inverse), taking max() so a newer manual date is never lowered. recalculateAll is now also invoked right after each sync pull on the background context, so scores refresh regardless of the visible tab (on macOS the Dashboard's own recalc wouldn't run otherwise). Tests: +3 regression cases in RelationshipScoreEngineTests covering the heal-from-records behavior (stale date healed, priority contact recovers above the boost, newer manual date preserved). Full macOS suite green (220 tests). Co-Authored-By: Claude Opus 4.8 --- Blackbook/Services/ContactSyncService.swift | 32 +++++++++--- .../Services/LocalServerSyncService.swift | 6 +++ .../Services/RelationshipScoreEngine.swift | 33 ++++++++++++- .../RelationshipScoreEngineTests.swift | 49 +++++++++++++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) 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/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) + } } From 06562120159eb7dd3a846f52cd872c433291c13a Mon Sep 17 00:00:00 2001 From: Michael Yeack Date: Wed, 3 Jun 2026 18:06:32 -0700 Subject: [PATCH 2/2] Features: 3 suggested records in pickers, click-to-sort columns, hidden-filter chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the five requested features (notifications + archive-on-import follow in PR 3). 2a. Three suggested records in contact pickers New ContactSuggestionEngine ranks candidates by contextual similarity to the subject — shared tags (x3), groups (x2), locations (x2), plus a per-field signal — and falls back to relationship score so suggestions are always available. IntroducedToPickerView and MetViaPickerView now show a "Suggested" section (top 3) when not searching, with the rest under "All Contacts". 2b. Click column headers to sort ContactListViewModel gains sortColumn + sortAscending and toggleSort(): clicking a header sorts by that column; clicking the active column flips direction. The active column shows a chevron. Sorts Name, Score, Groups, Locations, Tags, Met via, Introduced to (string columns sort their first value alphabetically with blanks last; numeric columns default to descending). The sort menu was updated to the same model and keeps Recent/Added. Replaces the old ContactSortOrder enum. 2e. Hidden contacts excluded from every search surface Audit found all selection surfaces already filtered !isHidden && !isMergedAway. Added a single chokepoint — `Sequence.selectable` — and adopted it in the contact pickers and ContactListViewModel so the CLAUDE.md rule has one enforcement point. (A hidden contact still appearing on another device is sync propagation, not a missing filter — same conflict-resolution family as the score fix in PR #48.) Tests: ContactSuggestionEngineTests (ranking, exclusions, score fallback) and ContactListViewModelTests (+toggle direction, string-column blanks-last, .selectable). Full macOS suite green (226 tests). Co-Authored-By: Claude Opus 4.8 --- Blackbook.xcodeproj/project.pbxproj | 8 ++ Blackbook/Models/Contact.swift | 9 ++ .../Services/ContactSuggestionEngine.swift | 66 ++++++++++ .../ViewModels/ContactListViewModel.swift | 117 ++++++++++++++--- .../Views/Contacts/ContactDetailView.swift | 119 +++++++++++------- .../Views/Contacts/ContactFormView.swift | 2 +- .../Views/Contacts/ContactListView.swift | 61 ++++++--- .../Contacts/MergeContactPickerView.swift | 2 +- .../ContactListViewModelTests.swift | 45 ++++++- .../ContactSuggestionEngineTests.swift | 80 ++++++++++++ 10 files changed, 427 insertions(+), 82 deletions(-) create mode 100644 Blackbook/Services/ContactSuggestionEngine.swift create mode 100644 BlackbookTests/ContactSuggestionEngineTests.swift 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/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"]) + } +}