Skip to content
Merged
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)
}
}
32 changes: 25 additions & 7 deletions Blackbook/Services/ContactSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
6 changes: 6 additions & 0 deletions Blackbook/Services/LocalServerSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
33 changes: 31 additions & 2 deletions Blackbook/Services/RelationshipScoreEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Interaction>` 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<Contact>())
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<Interaction>()) {
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 {
Expand Down
Loading
Loading