diff --git a/Blackbook/Services/ContactMergeService.swift b/Blackbook/Services/ContactMergeService.swift index 77e9a71..bea5f02 100644 --- a/Blackbook/Services/ContactMergeService.swift +++ b/Blackbook/Services/ContactMergeService.swift @@ -22,8 +22,8 @@ final class ContactMergeService { secondary.isMergedAway = true secondary.mergedIntoContact = primary - primary.updatedAt = Date() - secondary.updatedAt = Date() + primary.markLocallyEdited() + secondary.markLocallyEdited() try context.save() logger.info("Merged '\(secondary.displayName)' into '\(primary.displayName)'") diff --git a/Blackbook/Services/ContactSyncService.swift b/Blackbook/Services/ContactSyncService.swift index 5fa1112..4193c6d 100644 --- a/Blackbook/Services/ContactSyncService.swift +++ b/Blackbook/Services/ContactSyncService.swift @@ -344,7 +344,7 @@ final class ContactSyncService { contact.company = cn.organizationName.isEmpty ? nil : cn.organizationName contact.jobTitle = cn.jobTitle.isEmpty ? nil : cn.jobTitle populateFields(contact, from: cn) - contact.updatedAt = Date() + contact.markLocallyEdited() } private func populateFields(_ contact: Contact, from cn: CNContact) { diff --git a/Blackbook/Services/SocialEnrichmentService.swift b/Blackbook/Services/SocialEnrichmentService.swift index 2acb15f..52ecca9 100644 --- a/Blackbook/Services/SocialEnrichmentService.swift +++ b/Blackbook/Services/SocialEnrichmentService.swift @@ -34,7 +34,7 @@ final class SocialEnrichmentService { contact.company = contact.company ?? components.dropFirst().first?.trimmingCharacters(in: .whitespaces) } } - contact.updatedAt = Date() contact.customFields["lastEnriched"] = ISO8601DateFormatter().string(from: Date()) + contact.markLocallyEdited() } } diff --git a/Blackbook/Views/Contacts/AddNoteView.swift b/Blackbook/Views/Contacts/AddNoteView.swift index f9107b5..d3653cc 100644 --- a/Blackbook/Views/Contacts/AddNoteView.swift +++ b/Blackbook/Views/Contacts/AddNoteView.swift @@ -22,7 +22,7 @@ struct AddNoteView: View { #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } - ToolbarItem(placement: .confirmationAction) { Button("Save") { modelContext.insert(Note(contact: contact, content: content, category: category)); contact.updatedAt = Date(); try? modelContext.save(); dismiss() }.disabled(content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + ToolbarItem(placement: .confirmationAction) { Button("Save") { modelContext.insert(Note(contact: contact, content: content, category: category)); contact.markLocallyEdited(); try? modelContext.save(); dismiss() }.disabled(content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } } diff --git a/Blackbook/Views/Contacts/ContactDetailView.swift b/Blackbook/Views/Contacts/ContactDetailView.swift index ec6b47b..bb27b58 100644 --- a/Blackbook/Views/Contacts/ContactDetailView.swift +++ b/Blackbook/Views/Contacts/ContactDetailView.swift @@ -61,7 +61,7 @@ struct ContactDetailView: View { Button { if contact.isHidden { contact.isHidden = false - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } else { showHideConfirm = true @@ -118,7 +118,7 @@ struct ContactDetailView: View { Button("Cancel", role: .cancel) {} Button("Hide", role: .destructive) { contact.isHidden = true - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() } @@ -411,7 +411,7 @@ struct MetViaPickerView: View { Section { Button { contact.metVia = nil - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() } label: { @@ -428,7 +428,7 @@ struct MetViaPickerView: View { ForEach(filtered) { c in Button { contact.metVia = c - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() } label: { @@ -528,13 +528,13 @@ struct IntroducedToPickerView: View { for c in eligible { if selectedIDs.contains(c.id) && !currentBacklinkIDs.contains(c.id) { c.metVia = contact - c.updatedAt = Date() + c.markLocallyEdited() } else if !selectedIDs.contains(c.id) && currentBacklinkIDs.contains(c.id) { c.metVia = nil - c.updatedAt = Date() + c.markLocallyEdited() } } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() }.fontWeight(.semibold) diff --git a/Blackbook/Views/Contacts/ContactFormView.swift b/Blackbook/Views/Contacts/ContactFormView.swift index bbb4f9a..34ae6b8 100644 --- a/Blackbook/Views/Contacts/ContactFormView.swift +++ b/Blackbook/Views/Contacts/ContactFormView.swift @@ -157,11 +157,12 @@ struct ContactFormView: View { t.linkedInURL = linkedInURL.isEmpty ? nil : linkedInURL t.twitterHandle = twitterHandle.isEmpty ? nil : twitterHandle t.instagramHandle = instagramHandle.isEmpty ? nil : instagramHandle - t.isPriority = isPriority; t.updatedAt = Date() + t.isPriority = isPriority t.tags = allTags.filter { selectedTagIds.contains($0.id) } t.groups = allGroups.filter { selectedGroupIds.contains($0.id) } t.locations = allLocations.filter { selectedLocationIds.contains($0.id) } t.metVia = allContacts.first { $0.id == metViaContactId } + t.markLocallyEdited() try? modelContext.save() Log.action(isNew ? "contact.create" : "contact.edit", metadata: [ "contactId": t.id.uuidString, diff --git a/Blackbook/Views/Contacts/ContactGroupPickerView.swift b/Blackbook/Views/Contacts/ContactGroupPickerView.swift index a20adf1..5c38e13 100644 --- a/Blackbook/Views/Contacts/ContactGroupPickerView.swift +++ b/Blackbook/Views/Contacts/ContactGroupPickerView.swift @@ -97,7 +97,7 @@ struct ContactGroupPickerView: View { ToolbarItem(placement: .confirmationAction) { Button("Done") { contact.groups = allGroups.filter { selectedIDs.contains($0.id) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() }.fontWeight(.semibold) diff --git a/Blackbook/Views/Contacts/ContactListView.swift b/Blackbook/Views/Contacts/ContactListView.swift index 30f98ba..94f3eea 100644 --- a/Blackbook/Views/Contacts/ContactListView.swift +++ b/Blackbook/Views/Contacts/ContactListView.swift @@ -199,7 +199,7 @@ struct ContactListView: View { Button("Cancel", role: .cancel) { contactToHide = nil } Button("Hide", role: .destructive) { contact.isHidden = true - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() contactToHide = nil } @@ -605,7 +605,7 @@ struct ContactFieldToggleSheet: View { Button { if isSelected { contact.groups.removeAll { $0.id == group.id } } else { contact.groups.append(group) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } label: { HStack(spacing: 10) { @@ -627,7 +627,7 @@ struct ContactFieldToggleSheet: View { Button { if isSelected { contact.tags.removeAll { $0.id == tag.id } } else { contact.tags.append(tag) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } label: { HStack(spacing: 10) { @@ -649,7 +649,7 @@ struct ContactFieldToggleSheet: View { Button { if isSelected { contact.locations.removeAll { $0.id == location.id } } else { contact.locations.append(location) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } label: { HStack(spacing: 10) { diff --git a/Blackbook/Views/Contacts/ContactLocationPickerView.swift b/Blackbook/Views/Contacts/ContactLocationPickerView.swift index 52f3b7b..e7d208a 100644 --- a/Blackbook/Views/Contacts/ContactLocationPickerView.swift +++ b/Blackbook/Views/Contacts/ContactLocationPickerView.swift @@ -97,7 +97,7 @@ struct ContactLocationPickerView: View { ToolbarItem(placement: .confirmationAction) { Button("Done") { contact.locations = allLocations.filter { selectedIDs.contains($0.id) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() }.fontWeight(.semibold) diff --git a/Blackbook/Views/Contacts/ContactTagPickerView.swift b/Blackbook/Views/Contacts/ContactTagPickerView.swift index 8d24eaf..bef73f7 100644 --- a/Blackbook/Views/Contacts/ContactTagPickerView.swift +++ b/Blackbook/Views/Contacts/ContactTagPickerView.swift @@ -101,7 +101,7 @@ struct ContactTagPickerView: View { ToolbarItem(placement: .confirmationAction) { Button("Done") { contact.tags = allTags.filter { selectedIDs.contains($0.id) } - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() dismiss() }.fontWeight(.semibold) diff --git a/Blackbook/Views/Dashboard/DashboardView.swift b/Blackbook/Views/Dashboard/DashboardView.swift index eabd975..591816a 100644 --- a/Blackbook/Views/Dashboard/DashboardView.swift +++ b/Blackbook/Views/Dashboard/DashboardView.swift @@ -98,7 +98,10 @@ struct DashboardView: View { ForEach(prioritized) { contact in NavigationLink(value: contact.id) { PriorityContactChip(contact: contact) { - withAnimation { contact.isPriority = false } + withAnimation { + contact.isPriority = false + contact.markLocallyEdited() + } } } .buttonStyle(.plain) @@ -304,7 +307,10 @@ struct PrioritizeContactPicker: View { NavigationStack { List(filtered) { contact in Button { - withAnimation { contact.isPriority = true } + withAnimation { + contact.isPriority = true + contact.markLocallyEdited() + } dismiss() } label: { HStack(spacing: 12) { diff --git a/Blackbook/Views/Interactions/LogInteractionView.swift b/Blackbook/Views/Interactions/LogInteractionView.swift index dd939ba..4b5b4fd 100644 --- a/Blackbook/Views/Interactions/LogInteractionView.swift +++ b/Blackbook/Views/Interactions/LogInteractionView.swift @@ -26,7 +26,9 @@ struct LogInteractionView: View { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Save") { modelContext.insert(Interaction(contact: contact, type: type, date: date, duration: nil, summary: summary.isEmpty ? nil : summary, sentiment: nil)) - contact.lastInteractionDate = date; contact.updatedAt = Date(); try? modelContext.save(); dismiss() + contact.lastInteractionDate = date + contact.markLocallyEdited() + try? modelContext.save(); dismiss() } } } } diff --git a/Blackbook/Views/Settings/SettingsView.swift b/Blackbook/Views/Settings/SettingsView.swift index f00ff30..bad90df 100644 --- a/Blackbook/Views/Settings/SettingsView.swift +++ b/Blackbook/Views/Settings/SettingsView.swift @@ -636,7 +636,7 @@ struct HiddenContactsView: View { ForEach(hiddenContacts) { contact in contactRow(contact, label: "Unhide") { contact.isHidden = false - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } } @@ -649,7 +649,7 @@ struct HiddenContactsView: View { contactRow(contact, label: "Restore") { contact.isMergedAway = false contact.mergedIntoContact = nil - contact.updatedAt = Date() + contact.markLocallyEdited() try? modelContext.save() } } @@ -766,7 +766,7 @@ struct HideContactsView: View { Button("Hide (\(selectedIDs.count))") { for contact in allContacts where selectedIDs.contains(contact.id) { contact.isHidden = true - contact.updatedAt = Date() + contact.markLocallyEdited() } try? modelContext.save() dismiss() diff --git a/BlackbookServer/App/IMessageSyncService.swift b/BlackbookServer/App/IMessageSyncService.swift index 2854dc8..c6db9d2 100644 --- a/BlackbookServer/App/IMessageSyncService.swift +++ b/BlackbookServer/App/IMessageSyncService.swift @@ -232,7 +232,14 @@ final class IMessageSyncService { let contacts: [Contact] do { - contacts = try context.fetch(FetchDescriptor()) + // Exclude merged-away and hidden contacts — they're "dead" pointers from the user's + // perspective. If a live duplicate and a merged-away version both share a phone or + // email, the handle-lookup dict-assignment order can pick the merged-away one, which + // makes the iMessage invisible on the live contact's detail view (observed + // 2026-06-02 with Hugo Dooner: 4 messages attached to merged-away Z_PK 1210 + // instead of live Z_PK 313). + let predicate = #Predicate { !$0.isMergedAway && !$0.isHidden } + contacts = try context.fetch(FetchDescriptor(predicate: predicate)) } catch { syncError = "Failed to fetch contacts: \(error.localizedDescription)" logger.error("Contact fetch failed: \(error)") diff --git a/docs/test-scenarios/TEST_SCENARIOS.md b/docs/test-scenarios/TEST_SCENARIOS.md index 5bc8104..0b3a78c 100644 --- a/docs/test-scenarios/TEST_SCENARIOS.md +++ b/docs/test-scenarios/TEST_SCENARIOS.md @@ -436,3 +436,36 @@ Two changes ship together: - Other views still use `@Query` — they're rendered only when their tab is selected, so a settled main store at that point keeps them safe. - Verified by: iOS Simulator + macOS clean builds; 13 Swift Testing tests pass. - Crash signature confirmed via `~/Downloads/Blackbook-2026-06-02-093228.ips` — frames 12-13 in `_SwiftData_SwiftUI` (`@Query`), frame 18 in `ScrollView.init`, frame 5 `Sequence.forEach`, into SwiftData fault on tagged address `0x8000000000000010`. + +## 2026-06-02 — Silent sync drift fix: every contact edit now flips syncStatus to pending; iMessage handle lookup ignores merged-away + +**Context:** User reported three divergences between iOS and macOS for the same Contact records: (1) Hugo Dooner shows as "Hugo" on iOS but "Hugo Dooner" on macOS, (2) Kail Walker is priority=1 on iOS but priority=0 on macOS, (3) Hugo's recent iMessages don't show on the Hugo Dooner contact detail. Root cause for (1) and (2): 17 edit sites across the iOS/macOS app wrote `contact.updatedAt = Date()` (or just mutated a syncable field) without calling `contact.markLocallyEdited()` to flip `syncStatus` to `.pending`. The next push filters by `syncStatus != .synced`, so silently-edited records never leave the device — server stays stale, the other device pulls the stale version. Audit on the user's macOS store found **643 contacts** with `updatedAt > lastSyncedAt && syncStatus = .synced` — silently-edited but never pushed. Root cause for (3): `BlackbookServer/App/IMessageSyncService.buildHandleLookup` fetched every Contact including merged-away ones, so when a live duplicate and a merged-away version shared a phone/email, the dict-assignment order could pick the merged-away one and attach incoming iMessages to a hidden record. Confirmed on server: 4 Hugo iMessages were attached to merged-away Z_PK 1210, not live Z_PK 313. + +Changes: +1. **17 edit sites updated** to call `markLocallyEdited()` instead of direct `updatedAt = Date()` / unmarked field writes. Files: `ContactDetailView`, `ContactListView`, `ContactFormView`, `AddNoteView`, `LogInteractionView`, `ContactTagPickerView`, `ContactGroupPickerView`, `ContactLocationPickerView`, `DashboardView`, `SettingsView`, `ContactSyncService`, `SocialEnrichmentService`, `ContactMergeService`. +2. **`IMessageSyncService`** fetches contacts with `#Predicate { !$0.isMergedAway && !$0.isHidden }` before building the handle lookup. + +Server-side one-time SQL heal applied directly to the master store while the daemon was stopped: +- `UPDATE ZINTERACTION SET ZCONTACT=313, ZUPDATEDAT= WHERE ZCONTACT=1210;` — relink 4 Hugo iMessages from merged-away Hugo to live Hugo Dooner. +- `UPDATE ZCONTACT SET ZLASTNAME='Dooner', ZUPDATEDAT= WHERE Z_PK=313;` — restore the last name and force a re-pull. +- `UPDATE ZCONTACT SET ZUPDATEDAT= WHERE Z_PK=1197;` — bump Kail Walker so macOS adopts the server's `isPriority=1`. + +### Scenario A — single-edit propagation +1. On either device (build 119+), open any contact, change one field (last name, priority toggle, add tag), save. +2. Within 5 min the change appears on the other device's view of the same contact. +3. Watch the server access log: the source device's next `/sync/changes` push includes that record; the other device's pull response includes it. + +### Scenario B — silent-edit backlog clears as users touch contacts +1. With build 119+ on macOS, open any of the 643 silently-edited contacts and make any change (or just toggle a flag and back). +2. That contact's next push reaches the server; the other device adopts it. +3. The full 643 backfill is opt-in — only contacts the user re-touches will sync. This avoids macOS-wins overwriting iOS edits that were also silent. + +### Scenario C — iMessage attaches to live contact when duplicates exist +1. Have a contact with a live record (`isMergedAway=false`) and a merged-away duplicate (`isMergedAway=true`) sharing the same phone/email. +2. Receive a new iMessage from that handle. +3. Open the live contact → Interactions tab → the message appears. The merged-away contact gets nothing. + +### Notes / caveats +- The fix is per-edit-going-forward. The existing 643-record backlog clears only as users naturally touch each contact (or via a deliberate one-time migration, which we deliberately skipped because it would overwrite iOS edits for any field where the two devices disagree). +- BlackbookServer is local-deploy only (not TestFlight). After this PR lands, rebuild + reinstall `/Applications/BlackbookServer.app` from main so the iMessage-lookup fix takes effect. +- Verified: iOS Simulator + macOS clean builds, BlackbookServer clean build, 13 Swift Testing tests pass.