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
4 changes: 2 additions & 2 deletions Blackbook/Services/ContactMergeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)'")
Expand Down
2 changes: 1 addition & 1 deletion Blackbook/Services/ContactSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Blackbook/Services/SocialEnrichmentService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
2 changes: 1 addition & 1 deletion Blackbook/Views/Contacts/AddNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
}
Expand Down
14 changes: 7 additions & 7 deletions Blackbook/Views/Contacts/ContactDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -411,7 +411,7 @@ struct MetViaPickerView: View {
Section {
Button {
contact.metVia = nil
contact.updatedAt = Date()
contact.markLocallyEdited()
try? modelContext.save()
dismiss()
} label: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Blackbook/Views/Contacts/ContactFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Blackbook/Views/Contacts/ContactGroupPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions Blackbook/Views/Contacts/ContactListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Blackbook/Views/Contacts/ContactLocationPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Blackbook/Views/Contacts/ContactTagPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions Blackbook/Views/Dashboard/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion Blackbook/Views/Interactions/LogInteractionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
} }
}
}
Expand Down
6 changes: 3 additions & 3 deletions Blackbook/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion BlackbookServer/App/IMessageSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,14 @@ final class IMessageSyncService {

let contacts: [Contact]
do {
contacts = try context.fetch(FetchDescriptor<Contact>())
// 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<Contact> { !$0.isMergedAway && !$0.isHidden }
contacts = try context.fetch(FetchDescriptor<Contact>(predicate: predicate))
} catch {
syncError = "Failed to fetch contacts: \(error.localizedDescription)"
logger.error("Contact fetch failed: \(error)")
Expand Down
33 changes: 33 additions & 0 deletions docs/test-scenarios/TEST_SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Contact> { !$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=<now> WHERE ZCONTACT=1210;` — relink 4 Hugo iMessages from merged-away Hugo to live Hugo Dooner.
- `UPDATE ZCONTACT SET ZLASTNAME='Dooner', ZUPDATEDAT=<now> WHERE Z_PK=313;` — restore the last name and force a re-pull.
- `UPDATE ZCONTACT SET ZUPDATEDAT=<now> 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.
Loading