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
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
49 changes: 49 additions & 0 deletions BlackbookTests/RelationshipScoreEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading