diff --git a/Blackbook.xcodeproj/project.pbxproj b/Blackbook.xcodeproj/project.pbxproj index 5aae4b7..d5604c2 100644 --- a/Blackbook.xcodeproj/project.pbxproj +++ b/Blackbook.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 1D99B1A030BE9CA3B7CF9713 /* ContactDeduplicationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBCD54BD3218CA370A819170 /* ContactDeduplicationServiceTests.swift */; }; 1E261EB5A02886029E363553 /* SyncTypesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6169F3E4455CDCD77042D958 /* SyncTypesTests.swift */; }; 1F15A9907F62206EA97F4555 /* NetworkGraphEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682862BCC7F0F257DF35CEB8 /* NetworkGraphEngine.swift */; }; + 233530392EB2ADCFAA331164 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */; }; 25A80D3929B5841C1D224E29 /* TagModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AE2904F18728288E8C2218 /* TagModelTests.swift */; }; 25F4C43A32CF59B6B592D404 /* DateHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815CE74C56D359F1D0990254 /* DateHelpers.swift */; }; 264F82AF586E5CA45E98051A /* NetworkGraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA16574DE27E1A8A48DC9564 /* NetworkGraphView.swift */; }; @@ -32,7 +33,10 @@ 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 */; }; + 3CDE87A5D86BE3E2306E8C77 /* AppNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */; }; 3D5BA26EDD7B1258E870B1D4 /* BonjourBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB12E01D4EB1C6E3A68182E /* BonjourBrowser.swift */; }; 3D6714171EE7057CBE848A07 /* BlackbookServerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49097698C68BE415965A964 /* BlackbookServerApp.swift */; }; 3DDF69D8B580022FFDE12FCA /* ServerModelContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB554426E75CDDDB20498E2 /* ServerModelContainer.swift */; }; @@ -58,6 +62,7 @@ 5C59F290C171167D7664BE76 /* ContactSyncApply.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE3F1034482B48A2B7CCF08 /* ContactSyncApply.swift */; }; 5D10E3A07EE83D1C213B9CBD /* IconAndColorPickers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C76958AF5D1BEA2F22D389A /* IconAndColorPickers.swift */; }; 5D65835C77649F257190DEB3 /* SyncTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49D100318268DC2B37475CA /* SyncTypes.swift */; }; + 621D41D8C9FA0212D6730A6E /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73CB8F7063A295D786A0459 /* NotificationService.swift */; }; 62218CD59C45E6A0B43B84AA /* RejectedCalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88153A388C33AA1F9FCA3424 /* RejectedCalendarEvent.swift */; }; 627F00BB1C55B4CD93AD362C /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B68EA01CC90E403184F8383 /* DashboardView.swift */; }; 63AED38B279341EDDCFAA16A /* GroupDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34F85542EF75DA29ECB0FBC2 /* GroupDetailView.swift */; }; @@ -150,6 +155,7 @@ F92545D544AD003815547A34 /* DeviceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535A0087C38A75EF251D28DF /* DeviceIdentity.swift */; }; F9C8E6C6BBBDE1468720591E /* ContactListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBCBC491C2BFAA0AABA68B3 /* ContactListViewModelTests.swift */; }; FA0527F27C39C281C7287EBE /* IMessageSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530F5990544546A519FE2390 /* IMessageSyncService.swift */; }; + FA992EA56F0A795E5F1E088F /* NotificationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */; }; FC428EE351F1C7F125A3E819 /* AIInsightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C58C1200FB5DE674799040E /* AIInsightsView.swift */; }; FC4D35EB1F0F93963E0499E2 /* DateHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B242CFE0D00227BB630A5801 /* DateHelpersTests.swift */; }; FD356F0646511FD58F59CCEF /* RelationshipScoreEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7B07D8F95B7197892AB8D6 /* RelationshipScoreEngineTests.swift */; }; @@ -171,6 +177,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 +207,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 = ""; }; @@ -247,6 +255,7 @@ 8C76958AF5D1BEA2F22D389A /* IconAndColorPickers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconAndColorPickers.swift; sourceTree = ""; }; 9070FA35720DE1331E514BFF /* MoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreView.swift; sourceTree = ""; }; 9111F21061CF4BB14E6FE866 /* NetworkGraphViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphViewModelTests.swift; sourceTree = ""; }; + 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotification.swift; sourceTree = ""; }; 950AC9FC1E76731546573DEA /* ReminderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderService.swift; sourceTree = ""; }; 970CC06BF6BA7D37D1C593DC /* InteractionLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionLogView.swift; sourceTree = ""; }; 972BC195190AAFA50ABE3DC7 /* ContactGroupPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactGroupPickerView.swift; sourceTree = ""; }; @@ -294,9 +303,11 @@ ED64DF1DD19F6ADC3991B97A /* DuplicateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicateScanView.swift; sourceTree = ""; }; EF98EBF38277BBA19D0BA8C7 /* TagManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManagerView.swift; sourceTree = ""; }; F00610DA298D9C87714AF36E /* RejectedCalendarEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RejectedCalendarEventsView.swift; sourceTree = ""; }; + F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTests.swift; sourceTree = ""; }; F3DBA07B5E3D5EAA2BFA3446 /* BlackbookApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbookApp.swift; sourceTree = ""; }; F49097698C68BE415965A964 /* BlackbookServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackbookServerApp.swift; sourceTree = ""; }; F49D100318268DC2B37475CA /* SyncTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTypes.swift; sourceTree = ""; }; + F73CB8F7063A295D786A0459 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; F767094E24F344D3643565AA /* LocationIconSuggestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationIconSuggestionView.swift; sourceTree = ""; }; F8A766A33F57F94C9BDF7ACF /* ConstantsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantsTests.swift; sourceTree = ""; }; F8CBD51D0C167CD7EB595D1F /* NetworkGraphEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkGraphEngineTests.swift; sourceTree = ""; }; @@ -433,10 +444,12 @@ 9C6BD1DE0B2466EA3DCFB985 /* ClaudeAPIService.swift */, 3C395757BFF2A510337F73D5 /* ContactDeduplicationService.swift */, 67485481A0ED6072989C3A0D /* ContactMergeService.swift */, + 3AC89F208DED881F3BEFA245 /* ContactSuggestionEngine.swift */, DC159C3515CC1B41D178A551 /* ContactSyncService.swift */, 2382E25FE6BDF339DA44560D /* GoogleCalendarService.swift */, D876B79CE3D85BAABBEBD128 /* LocalServerSyncService.swift */, 682862BCC7F0F257DF35CEB8 /* NetworkGraphEngine.swift */, + F73CB8F7063A295D786A0459 /* NotificationService.swift */, 1D046FD999BC660D58C26E32 /* PhotoStorageService.swift */, 56EBAC598F8585225B1F09E4 /* RelationshipScoreEngine.swift */, 950AC9FC1E76731546573DEA /* ReminderService.swift */, @@ -471,6 +484,7 @@ isa = PBXGroup; children = ( 21445D604CF5DB4A2F69B7DF /* Activity.swift */, + 9466FE0A50E27F3BABB99ABE /* AppNotification.swift */, CDB72A4E15A1B06A6877A310 /* BackupMetadata.swift */, 8222BEE7020B6711480F644E /* Contact.swift */, 364729C23FA9F3F62DDA06EF /* ContactRelationship.swift */, @@ -518,6 +532,7 @@ FA40028F884B4B1161FA87B0 /* ContactMergeServiceTests.swift */, D95AA83A5437DFA01312E219 /* ContactModelTests.swift */, 5D94FB331F41A7E2A777101F /* ContactRelationshipModelTests.swift */, + 0F30DE3186049895D31C581D /* ContactSuggestionEngineTests.swift */, 8211A08153FF7DE2A707EB06 /* DashboardViewModelTests.swift */, B242CFE0D00227BB630A5801 /* DateHelpersTests.swift */, 4DCA116B785721C8942FD325 /* FeatureGatingTests.swift */, @@ -529,6 +544,7 @@ F8CBD51D0C167CD7EB595D1F /* NetworkGraphEngineTests.swift */, 9111F21061CF4BB14E6FE866 /* NetworkGraphViewModelTests.swift */, AD6C2B5FA5933483BFCEC3B4 /* NoteModelTests.swift */, + F0940DAFA5EFF5AEB3F54BE9 /* NotificationsTests.swift */, AB7B07D8F95B7197892AB8D6 /* RelationshipScoreEngineTests.swift */, 4353ABA45B9630F4CF7DBF0C /* ReminderModelTests.swift */, B10282AE3F76E876ADDB3A4A /* ReminderServiceTests.swift */, @@ -788,6 +804,7 @@ C31D09F55E3735C61BE790F0 /* ActivityFormView.swift in Sources */, B164427D17DE85E450AEA4C1 /* ActivityListView.swift in Sources */, E93AF93D1470F43091DF3613 /* AddNoteView.swift in Sources */, + 3CDE87A5D86BE3E2306E8C77 /* AppNotification.swift in Sources */, 82E24EDF90B4FDD321E60DC3 /* AuthGateView.swift in Sources */, CFDEF705ED6BC7629FB51319 /* AuthenticationService.swift in Sources */, A9C0AC72BC7536D4CA4B2702 /* BackupDetailView.swift in Sources */, @@ -813,6 +830,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 */, @@ -852,6 +870,7 @@ 264F82AF586E5CA45E98051A /* NetworkGraphView.swift in Sources */, F363626541A0478C9B36D5FC /* NetworkGraphViewModel.swift in Sources */, 56A7A484B95A560F451576D7 /* Note.swift in Sources */, + 621D41D8C9FA0212D6730A6E /* NotificationService.swift in Sources */, 85EC2CFCD1D9D03FC6B07DCC /* PhotoStorageService.swift in Sources */, CB7C648273262A5D3B49C2D4 /* RejectedCalendarEvent.swift in Sources */, C3B6C1B2D9C55DCFB285AB82 /* RejectedCalendarEventsView.swift in Sources */, @@ -891,6 +910,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 */, @@ -902,6 +922,7 @@ B3D765199ED7FF84739621F0 /* NetworkGraphEngineTests.swift in Sources */, FD484B077257640144869BCF /* NetworkGraphViewModelTests.swift in Sources */, 17FC135DAFA6AACBD52DFD7D /* NoteModelTests.swift in Sources */, + FA992EA56F0A795E5F1E088F /* NotificationsTests.swift in Sources */, FD356F0646511FD58F59CCEF /* RelationshipScoreEngineTests.swift in Sources */, E331D0D1AF87D4E1FA420AAC /* ReminderModelTests.swift in Sources */, C853CFD3BB51EC978673715D /* ReminderServiceTests.swift in Sources */, @@ -918,6 +939,7 @@ buildActionMask = 2147483647; files = ( 54ED84A76C60399BC05F5C99 /* Activity.swift in Sources */, + 233530392EB2ADCFAA331164 /* AppNotification.swift in Sources */, E9C12D0FA76386A83E75A5A7 /* BackupMetadata.swift in Sources */, 9CFACF1D237FBFDB8BE35DA2 /* BackupServer.swift in Sources */, 3D6714171EE7057CBE848A07 /* BlackbookServerApp.swift in Sources */, diff --git a/Blackbook/App/BlackbookApp.swift b/Blackbook/App/BlackbookApp.swift index d38476f..1d20cbf 100644 --- a/Blackbook/App/BlackbookApp.swift +++ b/Blackbook/App/BlackbookApp.swift @@ -50,7 +50,8 @@ struct BlackbookApp: App { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) // Wipe the store when the schema version changes so SwiftData never diff --git a/Blackbook/Models/AppNotification.swift b/Blackbook/Models/AppNotification.swift new file mode 100644 index 0000000..bdd0f39 --- /dev/null +++ b/Blackbook/Models/AppNotification.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +/// A persisted, synced notification / suggested action shown in the Overview "Notifications" chiclet. +/// References its target contact by id (no SwiftData relationship — the id is enough to navigate and +/// keeps the model a simple leaf that syncs without dependency ordering). +@Model +final class AppNotification { + var id: UUID + var kindRaw: String + var title: String + var message: String + /// The contact this notification points to (for tap-to-navigate). Nil for non-contact notifications. + var contactId: UUID? + var createdAt: Date + var isRead: Bool + var isDismissed: Bool + + var updatedAt: Date = Date() + var syncStatus: String = SyncStatus.pending.rawValue + var lastSyncedAt: Date? + + // MARK: - Source-device provenance + + var createdByDeviceId: String? + var createdByPlatform: String? + var createdByDeviceName: String? + var lastEditedByDeviceId: String? + var lastEditedByPlatform: String? + var lastEditedByDeviceName: String? + + /// The category of this notification, driving its icon and any inline action. + var kind: AppNotificationKind { + get { AppNotificationKind(rawValue: kindRaw) ?? .fadingRelationship } + set { kindRaw = newValue.rawValue } + } + + init( + kind: AppNotificationKind, + title: String, + message: String, + contactId: UUID? = nil, + createdAt: Date = Date() + ) { + self.id = UUID() + self.kindRaw = kind.rawValue + self.title = title + self.message = message + self.contactId = contactId + self.createdAt = createdAt + self.isRead = false + self.isDismissed = false + self.updatedAt = Date() + self.createdByDeviceId = DeviceIdentity.installId + self.createdByPlatform = DeviceIdentity.platform + self.createdByDeviceName = DeviceIdentity.deviceName + self.lastEditedByDeviceId = DeviceIdentity.installId + self.lastEditedByPlatform = DeviceIdentity.platform + self.lastEditedByDeviceName = DeviceIdentity.deviceName + } + + func markLocallyEdited() { + updatedAt = Date() + if syncStatus != SyncStatus.deleted.rawValue { + syncStatus = SyncStatus.pending.rawValue + } + lastEditedByDeviceId = DeviceIdentity.installId + lastEditedByPlatform = DeviceIdentity.platform + lastEditedByDeviceName = DeviceIdentity.deviceName + } +} + +/// The source/category of an `AppNotification`. +enum AppNotificationKind: String, Codable, CaseIterable { + /// A relationship whose score has dropped — suggest reaching out. + case fadingRelationship + /// A previously-imported contact no longer found in the address book — suggest archiving. + case archiveSuggestion + + var icon: String { + switch self { + case .fadingRelationship: return "arrow.down.right.circle.fill" + case .archiveSuggestion: return "archivebox.fill" + } + } +} 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/Services/ContactSyncService.swift b/Blackbook/Services/ContactSyncService.swift index 4193c6d..a1e979e 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, detectArchives: true) + 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: [ @@ -205,7 +223,7 @@ final class ContactSyncService { /// Resolves each CNContact to an existing Blackbook Contact (reattaching by name + /// email/phone if the cnContactIdentifier no longer matches), or inserts a new one. /// Returns counts of each outcome. - private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext) throws -> MergeOutcome { + private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext, detectArchives: Bool = false) throws -> MergeOutcome { var outcome = MergeOutcome() let allActive = try modelContext.fetch( @@ -270,6 +288,20 @@ final class ContactSyncService { "cnIdentifier": cnContact.identifier ]) } + + // After a full import, flag contacts that were imported before but are no longer in the + // address book (their cnContactIdentifier is gone from the live set) as archive suggestions. + if detectArchives { + for c in allActive where !c.isHidden { + guard let cn = c.cnContactIdentifier, !liveIdentifiers.contains(cn) else { continue } + if NotificationService.suggestArchive(contactId: c.id, displayName: Self.displayName(c), context: modelContext) { + Log.action("contact.import.archiveSuggested", metadata: [ + "contactId": c.id.uuidString, + "displayName": Self.displayName(c) + ]) + } + } + } return outcome } diff --git a/Blackbook/Services/LocalServerSyncService.swift b/Blackbook/Services/LocalServerSyncService.swift index 3721643..2980b7b 100644 --- a/Blackbook/Services/LocalServerSyncService.swift +++ b/Blackbook/Services/LocalServerSyncService.swift @@ -153,6 +153,7 @@ final class LocalServerSyncService { flip(Reminder.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } flip(ContactRelationship.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } flip(RejectedCalendarEvent.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } + flip(AppNotification.self, #Predicate { $0.syncStatus == synced }) { $0.syncStatus = pending } try? context.save() logger.info("Bootstrap marked \(flippedCount) record(s) pending") Log.action("sync.bootstrap.markPending", metadata: ["count": "\(flippedCount)"]) @@ -318,6 +319,8 @@ final class LocalServerSyncService { total += (try? context.fetchCount(reminderDescriptor)) ?? 0 let relationshipDescriptor = FetchDescriptor(predicate: #Predicate { $0.syncStatus != synced }) total += (try? context.fetchCount(relationshipDescriptor)) ?? 0 + let appNotificationDescriptor = FetchDescriptor(predicate: #Predicate { $0.syncStatus != synced }) + total += (try? context.fetchCount(appNotificationDescriptor)) ?? 0 return total } @@ -334,10 +337,12 @@ final class LocalServerSyncService { let pendingNotes = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let pendingReminders = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let pendingRelationships = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) + let pendingAppNotifications = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.syncStatus != syncedStatus })) let totalPending = pendingTags.count + pendingGroups.count + pendingLocations.count + pendingActivities.count + pendingContacts.count + pendingInteractions.count + - pendingNotes.count + pendingReminders.count + pendingRelationships.count + pendingNotes.count + pendingReminders.count + pendingRelationships.count + + pendingAppNotifications.count logger.info("Pushing \(totalPending) record(s)") guard totalPending > 0 else { return } @@ -358,6 +363,7 @@ final class LocalServerSyncService { if !pendingNotes.isEmpty { body["notes"] = pendingNotes.map { ModelSyncApply.noteToDict($0) } } if !pendingReminders.isEmpty { body["reminders"] = pendingReminders.map { ModelSyncApply.reminderToDict($0) } } if !pendingRelationships.isEmpty { body["contactRelationships"] = pendingRelationships.map { ModelSyncApply.contactRelationshipToDict($0) } } + if !pendingAppNotifications.isEmpty { body["appNotifications"] = pendingAppNotifications.map { ModelSyncApply.appNotificationToDict($0) } } guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } @@ -387,6 +393,7 @@ final class LocalServerSyncService { for note in pendingNotes { note.syncStatus = SyncStatus.synced.rawValue; note.lastSyncedAt = now } for reminder in pendingReminders { reminder.syncStatus = SyncStatus.synced.rawValue; reminder.lastSyncedAt = now } for rel in pendingRelationships { rel.syncStatus = SyncStatus.synced.rawValue; rel.lastSyncedAt = now } + for n in pendingAppNotifications { n.syncStatus = SyncStatus.synced.rawValue; n.lastSyncedAt = now } try context.save() } @@ -464,8 +471,21 @@ final class LocalServerSyncService { logger.info("Pulled \(rels.count) relationship(s)") for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: bgContext) } } + if let appNotifications = top["appNotifications"] as? [[String: Any]] { + logger.info("Pulled \(appNotifications.count) notification(s)") + for dict in appNotifications { try ModelSyncApply.applyRemoteAppNotification(dict, to: bgContext) } + } 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) + + // Generate "reconnect" suggestions for newly-fading contacts (deduped). Same settled commit. + NotificationService.generateFadingNotifications(context: bgContext) } private func fetchPendingContacts(context: ModelContext) throws -> [Contact] { diff --git a/Blackbook/Services/NotificationService.swift b/Blackbook/Services/NotificationService.swift new file mode 100644 index 0000000..a35a971 --- /dev/null +++ b/Blackbook/Services/NotificationService.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftData + +/// Generates `AppNotification` records (suggested actions) from app state. Notifications are deduped +/// one-per-contact-per-kind, so re-running is idempotent and a dismissed notification is not recreated. +/// Generation runs on a background context during sync / import; callers save the context. +enum NotificationService { + /// Cap on new fading notifications created per run, so the chiclet stays a focused suggested-action + /// list rather than mirroring every below-threshold contact. + private static let maxNewFadingPerRun = 10 + + /// Creates fading-relationship notifications for contacts whose score has slipped below the fading + /// threshold (but is still > 0 — i.e. previously engaged, now cooling). Mirrors the Dashboard + /// "Fading Relationships" definition. Returns the number created. + @discardableResult + static func generateFadingNotifications(context: ModelContext) -> Int { + let alreadyNotified = existingContactIDs(ofKind: .fadingRelationship, in: context) + + let threshold = AppConstants.Scoring.fadingThreshold + let predicate = #Predicate { + !$0.isHidden && !$0.isMergedAway && $0.relationshipScore > 0 && $0.relationshipScore < threshold + } + guard let fading = try? context.fetch(FetchDescriptor(predicate: predicate)) else { return 0 } + + let candidates = fading + .filter { !alreadyNotified.contains($0.id) } + .sorted { $0.relationshipScore < $1.relationshipScore } // most faded first + .prefix(maxNewFadingPerRun) + + var created = 0 + for contact in candidates { + context.insert(AppNotification( + kind: .fadingRelationship, + title: "Reconnect with \(contact.displayName)", + message: "This relationship is fading — reach out to stay in touch.", + contactId: contact.id + )) + created += 1 + } + if created > 0 { try? context.save() } + return created + } + + /// Creates an archive-suggestion notification for a contact no longer found in the address book, + /// unless one already exists for that contact. Does not save (the import flow saves the context). + @discardableResult + static func suggestArchive(contactId: UUID, displayName: String, context: ModelContext) -> Bool { + guard !existingContactIDs(ofKind: .archiveSuggestion, in: context).contains(contactId) else { return false } + context.insert(AppNotification( + kind: .archiveSuggestion, + title: "Archive \(displayName)?", + message: "No longer in your address book — archive to hide them from your lists.", + contactId: contactId + )) + return true + } + + /// Contact IDs that already have a notification of the given kind in any state (active or dismissed), + /// so a dismissed suggestion is never recreated. + private static func existingContactIDs(ofKind kind: AppNotificationKind, in context: ModelContext) -> Set { + guard let all = try? context.fetch(FetchDescriptor()) else { return [] } + return Set(all.filter { $0.kind == kind }.compactMap { $0.contactId }) + } +} 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/Blackbook/Utilities/ModelSyncApply.swift b/Blackbook/Utilities/ModelSyncApply.swift index fe8a93b..18d45b4 100644 --- a/Blackbook/Utilities/ModelSyncApply.swift +++ b/Blackbook/Utilities/ModelSyncApply.swift @@ -548,6 +548,79 @@ enum ModelSyncApply { // MARK: - ContactRelationship + // MARK: - AppNotification + + static func appNotificationToDict(_ n: AppNotification) -> [String: Any] { + var dict: [String: Any] = [ + "id": n.id.uuidString, + "kindRaw": n.kindRaw, + "title": n.title, + "message": n.message, + "isRead": n.isRead, + "isDismissed": n.isDismissed, + "createdAt": iso8601.string(from: n.createdAt), + "updatedAt": iso8601.string(from: n.updatedAt) + ] + if let cid = n.contactId { dict["contactId"] = cid.uuidString } + appendProvenance(&dict, + createdByDeviceId: n.createdByDeviceId, + createdByPlatform: n.createdByPlatform, + createdByDeviceName: n.createdByDeviceName, + lastEditedByDeviceId: n.lastEditedByDeviceId, + lastEditedByPlatform: n.lastEditedByPlatform, + lastEditedByDeviceName: n.lastEditedByDeviceName) + return dict + } + + static func applyRemoteAppNotification(_ dict: [String: Any], to context: ModelContext) throws { + guard let idString = dict["id"] as? String, + let remoteId = UUID(uuidString: idString) else { return } + + let predicate = #Predicate { $0.id == remoteId } + let existing = try context.fetch(FetchDescriptor(predicate: predicate)).first + + guard let remoteUpdatedStr = dict["updatedAt"] as? String, + let remoteUpdated = iso8601.date(from: remoteUpdatedStr) else { return } + + if let local = existing { + if local.updatedAt > remoteUpdated && local.syncStatus != SyncStatus.synced.rawValue { return } + applyDictToAppNotification(dict, notification: local) + local.updatedAt = remoteUpdated + local.syncStatus = SyncStatus.synced.rawValue + local.lastSyncedAt = Date() + } else { + let n = AppNotification( + kind: AppNotificationKind(rawValue: (dict["kindRaw"] as? String) ?? "") ?? .fadingRelationship, + title: (dict["title"] as? String) ?? "", + message: (dict["message"] as? String) ?? "" + ) + n.id = remoteId + applyDictToAppNotification(dict, notification: n) + n.updatedAt = remoteUpdated + n.syncStatus = SyncStatus.synced.rawValue + n.lastSyncedAt = Date() + context.insert(n) + } + } + + private static func applyDictToAppNotification(_ dict: [String: Any], notification n: AppNotification) { + if let v = dict["kindRaw"] as? String { n.kindRaw = v } + if let v = dict["title"] as? String { n.title = v } + if let v = dict["message"] as? String { n.message = v } + if let v = dict["isRead"] as? Bool { n.isRead = v } + if let v = dict["isDismissed"] as? Bool { n.isDismissed = v } + if let v = dict["createdAt"] as? String, let d = iso8601.date(from: v) { n.createdAt = d } + n.contactId = (dict["contactId"] as? String).flatMap { UUID(uuidString: $0) } + + let p = readProvenance(dict) + n.createdByDeviceId = p.createdByDeviceId + n.createdByPlatform = p.createdByPlatform + n.createdByDeviceName = p.createdByDeviceName + n.lastEditedByDeviceId = p.lastEditedByDeviceId + n.lastEditedByPlatform = p.lastEditedByPlatform + n.lastEditedByDeviceName = p.lastEditedByDeviceName + } + static func contactRelationshipToDict(_ rel: ContactRelationship) -> [String: Any] { var dict: [String: Any] = [ "id": rel.id.uuidString, 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/Blackbook/Views/Dashboard/DashboardView.swift b/Blackbook/Views/Dashboard/DashboardView.swift index 591816a..653e955 100644 --- a/Blackbook/Views/Dashboard/DashboardView.swift +++ b/Blackbook/Views/Dashboard/DashboardView.swift @@ -15,6 +15,9 @@ struct DashboardView: View { @State private var viewModel = DashboardViewModel() @State private var showingPrioritizePicker = false @State private var weeklyStats = WeeklyStats(totalInteractions: 0, uniqueContacts: 0, byType: [:]) + @State private var notifications: [AppNotification] = [] + @State private var showNotifications = false + @State private var navTarget: DashNavTarget? private var contacts: [Contact] { allContacts.filter { !$0.isHidden && !$0.isMergedAway } } private var contactsByID: [UUID: Contact] { @@ -35,9 +38,39 @@ struct DashboardView: View { } } .navigationTitle("Overview") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showNotifications = true } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + if !notifications.isEmpty { + Text("\(notifications.count)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(AppConstants.UI.fadingRed, in: Capsule()) + .offset(x: 11, y: -9) + } + } + } + .accessibilityLabel("Notifications, \(notifications.count) active") + } + } .navigationDestination(for: UUID.self) { id in if let c = contactsByID[id] { ContactDetailView(contact: c) } } + .navigationDestination(item: $navTarget) { target in + if let c = contactsByID[target.id] { ContactDetailView(contact: c) } + } + .sheet(isPresented: $showNotifications) { + NotificationsView( + notifications: notifications, + onOpen: openNotification, + onDismiss: dismissNotification, + onArchive: archiveFromNotification + ) + } .task { // Defer the first fetch briefly so any in-flight pull-apply finishes saving // before we touch SwiftData on the main thread. @@ -72,9 +105,44 @@ struct DashboardView: View { if let reminders = try? modelContext.fetch(reminderDescriptor) { self.reminders = reminders } + let notifDescriptor = FetchDescriptor( + predicate: #Predicate { !$0.isDismissed }, + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + if let notifs = try? modelContext.fetch(notifDescriptor) { + notifications = notifs + } weeklyStats = viewModel.computeWeeklyStats(context: modelContext) } + // MARK: - Notification actions + + private func openNotification(_ n: AppNotification) { + if !n.isRead { n.isRead = true; n.markLocallyEdited() } + try? modelContext.save() + showNotifications = false + if let cid = n.contactId { navTarget = DashNavTarget(id: cid) } + Task { await refreshFromStore() } + } + + private func dismissNotification(_ n: AppNotification) { + n.isDismissed = true + n.markLocallyEdited() + try? modelContext.save() + Task { await refreshFromStore() } + } + + private func archiveFromNotification(_ n: AppNotification) { + if let cid = n.contactId, let c = contactsByID[cid] { + c.isHidden = true + c.markLocallyEdited() + } + n.isDismissed = true + n.markLocallyEdited() + try? modelContext.save() + Task { await refreshFromStore() } + } + private var weeklyStatsCard: some View { DashboardCard(title: "This Week", icon: "chart.bar.fill") { HStack(spacing: 24) { StatBubble(value: "\(weeklyStats.totalInteractions)", label: "Interactions"); StatBubble(value: "\(weeklyStats.uniqueContacts)", label: "People") } @@ -344,3 +412,98 @@ struct PrioritizeContactPicker: View { #endif } } + +// MARK: - Notifications + +/// Hashable navigation target for opening a contact from a notification (distinct from the +/// value-based `UUID` destination so the two `navigationDestination`s don't collide). +struct DashNavTarget: Hashable, Identifiable { + let id: UUID +} + +/// The Notifications / suggested-actions list presented from the Overview bell chiclet. Sorted +/// newest-first. Tap or swipe-leading opens the related contact; swipe-trailing dismisses; archive +/// suggestions get an inline "Archive" action. +struct NotificationsView: View { + @Environment(\.dismiss) private var dismiss + let notifications: [AppNotification] + let onOpen: (AppNotification) -> Void + let onDismiss: (AppNotification) -> Void + let onArchive: (AppNotification) -> Void + + var body: some View { + NavigationStack { + SwiftUI.Group { + if notifications.isEmpty { + ContentUnavailableView { + Label("All caught up", systemImage: "bell.slash") + } description: { + Text("Suggested actions will appear here as your relationships change.") + } + } else { + List { + ForEach(notifications) { notification in + Button { onOpen(notification) } label: { + NotificationRow(notification: notification) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { onOpen(notification) } label: { Label("Open", systemImage: "arrow.right.circle") } + .tint(AppConstants.UI.accentGold) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { onDismiss(notification) } label: { + Label("Dismiss", systemImage: "xmark") + } + if notification.kind == .archiveSuggestion { + Button { onArchive(notification) } label: { Label("Archive", systemImage: "archivebox") } + .tint(.gray) + } + } + } + } + } + } + .navigationTitle("Notifications") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + #if os(macOS) + .frame(minWidth: 380, idealWidth: 440, minHeight: 360, idealHeight: 500) + #endif + } +} + +struct NotificationRow: View { + let notification: AppNotification + + var body: some View { + HStack(spacing: 12) { + Image(systemName: notification.kind.icon) + .font(.body) + .foregroundStyle(notification.kind == .fadingRelationship ? AppConstants.UI.fadingRed : AppConstants.UI.accentGold) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(notification.title) + .font(.body.weight(notification.isRead ? .regular : .semibold)) + .lineLimit(1) + Text(notification.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + Text(notification.createdAt.relativeDescription) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } +} diff --git a/BlackbookServer/App/BackupServer.swift b/BlackbookServer/App/BackupServer.swift index f26b06f..14681df 100644 --- a/BlackbookServer/App/BackupServer.swift +++ b/BlackbookServer/App/BackupServer.swift @@ -674,6 +674,10 @@ final class BackupServer: @unchecked Sendable { let relPred = #Predicate { $0.updatedAt > since } var relDesc = FetchDescriptor(predicate: relPred); relDesc.fetchLimit = 2000 json["contactRelationships"] = try context.fetch(relDesc).map { ModelSyncApply.contactRelationshipToDict($0) } + + let notifPred = #Predicate { $0.updatedAt > since } + var notifDesc = FetchDescriptor(predicate: notifPred); notifDesc.fetchLimit = 2000 + json["appNotifications"] = try context.fetch(notifDesc).map { ModelSyncApply.appNotificationToDict($0) } } catch { logger.error("Sync pull fetch failed: \(error.localizedDescription)") } @@ -738,6 +742,9 @@ final class BackupServer: @unchecked Sendable { if let rels = top["contactRelationships"] as? [[String: Any]] { for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: context) } } + if let appNotifications = top["appNotifications"] as? [[String: Any]] { + for dict in appNotifications { try ModelSyncApply.applyRemoteAppNotification(dict, to: context) } + } // Deletes (structured per model type) if let deletes = top["deletes"] as? [String: Any] { @@ -825,6 +832,12 @@ final class BackupServer: @unchecked Sendable { try context.delete(model: RejectedCalendarEvent.self, where: #Predicate { $0.id == id }) } } + if let ids = deletes["appNotifications"] as? [String] { + for idStr in ids { + guard let id = UUID(uuidString: idStr) else { continue } + try context.delete(model: AppNotification.self, where: #Predicate { $0.id == id }) + } + } } // MARK: - Logs Storage diff --git a/BlackbookServer/App/ServerModelContainer.swift b/BlackbookServer/App/ServerModelContainer.swift index 928cf51..e659ab5 100644 --- a/BlackbookServer/App/ServerModelContainer.swift +++ b/BlackbookServer/App/ServerModelContainer.swift @@ -56,7 +56,8 @@ enum ServerModelContainer { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) } 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"]) + } +} diff --git a/BlackbookTests/NotificationsTests.swift b/BlackbookTests/NotificationsTests.swift new file mode 100644 index 0000000..baeb1ac --- /dev/null +++ b/BlackbookTests/NotificationsTests.swift @@ -0,0 +1,120 @@ +import XCTest +import SwiftData +@testable import Blackbook + +/// Tests for the notifications subsystem: `NotificationService` generation/dedup and the +/// `AppNotification` sync round-trip + conflict resolution (mirrors `SyncApplyTests`). +@MainActor +final class NotificationsTests: 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 + } + + // MARK: - Fading generation + + func testGeneratesFadingForCoolingContactsOnly() throws { + TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) // 0 < 15 < 30 → yes + TestHelpers.makeContact(firstName: "Dormant", lastName: "B", score: 0, in: context) // score 0 → no + TestHelpers.makeContact(firstName: "Healthy", lastName: "C", score: 80, in: context) // >= 30 → no + TestHelpers.makeContact(firstName: "Hidden", lastName: "D", score: 12, isHidden: true, in: context) // hidden → no + try context.save() + + let created = NotificationService.generateFadingNotifications(context: context) + XCTAssertEqual(created, 1) + + let notifs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(notifs.count, 1) + XCTAssertEqual(notifs.first?.kind, .fadingRelationship) + XCTAssertEqual(notifs.first?.title.contains("Cooling"), true) + } + + func testFadingGenerationIsIdempotent() throws { + TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) + try context.save() + + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 1) + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 0, "must not recreate an existing notification") + XCTAssertEqual(try context.fetch(FetchDescriptor()).count, 1) + } + + func testDismissedFadingNotificationIsNotRecreated() throws { + let c = TestHelpers.makeContact(firstName: "Cooling", lastName: "A", score: 15, in: context) + try context.save() + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 1) + + let notif = try XCTUnwrap(try context.fetch(FetchDescriptor()).first) + XCTAssertEqual(notif.contactId, c.id) + notif.isDismissed = true + try context.save() + + XCTAssertEqual(NotificationService.generateFadingNotifications(context: context), 0, "dismissed suggestion stays dismissed") + } + + // MARK: - Archive suggestion + + func testSuggestArchiveCreatesOnceThenDedups() throws { + let id = UUID() + XCTAssertTrue(NotificationService.suggestArchive(contactId: id, displayName: "Gone Person", context: context)) + try context.save() + XCTAssertFalse(NotificationService.suggestArchive(contactId: id, displayName: "Gone Person", context: context)) + + let notifs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(notifs.count, 1) + XCTAssertEqual(notifs.first?.kind, .archiveSuggestion) + } + + // MARK: - Sync round-trip + + func testAppNotificationRoundTrips() throws { + let contactId = UUID() + let original = AppNotification(kind: .archiveSuggestion, title: "Archive Bob?", message: "Gone", contactId: contactId) + context.insert(original) + try context.save() + + let dict = ModelSyncApply.appNotificationToDict(original) + + // Apply into a fresh store to prove the payload reconstructs the record from scratch. + let freshContainer = try TestHelpers.makeContainer() + let other = ModelContext(freshContainer) + try ModelSyncApply.applyRemoteAppNotification(dict, to: other) + try other.save() + + let fetched = try XCTUnwrap(try other.fetch(FetchDescriptor()).first { $0.id == original.id }) + XCTAssertEqual(fetched.kind, .archiveSuggestion) + XCTAssertEqual(fetched.title, "Archive Bob?") + XCTAssertEqual(fetched.contactId, contactId) + XCTAssertEqual(fetched.syncStatus, SyncStatus.synced.rawValue) + } + + func testApplyRemoteRespectsConflictResolution() throws { + let n = AppNotification(kind: .fadingRelationship, title: "Local", message: "local") + context.insert(n) + n.markLocallyEdited() // newer + pending → protected from an older remote + try context.save() + + // Older remote payload for the same id should be ignored while local is pending. + var olderDict = ModelSyncApply.appNotificationToDict(n) + olderDict["title"] = "Stale Remote" + let iso = ISO8601DateFormatter() + olderDict["updatedAt"] = iso.string(from: n.updatedAt.addingTimeInterval(-3600)) + try ModelSyncApply.applyRemoteAppNotification(olderDict, to: context) + XCTAssertEqual(n.title, "Local", "older remote must not clobber a newer pending local record") + + // A newer remote payload wins. + var newerDict = ModelSyncApply.appNotificationToDict(n) + newerDict["title"] = "Fresh Remote" + newerDict["updatedAt"] = iso.string(from: n.updatedAt.addingTimeInterval(3600)) + try ModelSyncApply.applyRemoteAppNotification(newerDict, to: context) + XCTAssertEqual(n.title, "Fresh Remote", "newer remote should win") + } +} 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) + } } diff --git a/BlackbookTests/TestHelpers.swift b/BlackbookTests/TestHelpers.swift index 0d79e2b..977cb98 100644 --- a/BlackbookTests/TestHelpers.swift +++ b/BlackbookTests/TestHelpers.swift @@ -20,7 +20,8 @@ enum TestHelpers { ContactRelationship.self, Reminder.self, Activity.self, - RejectedCalendarEvent.self + RejectedCalendarEvent.self, + AppNotification.self ]) let config = ModelConfiguration(isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) diff --git a/docs/test-scenarios/TEST_SCENARIOS.md b/docs/test-scenarios/TEST_SCENARIOS.md index 0b3a78c..56e44f1 100644 --- a/docs/test-scenarios/TEST_SCENARIOS.md +++ b/docs/test-scenarios/TEST_SCENARIOS.md @@ -469,3 +469,32 @@ Server-side one-time SQL heal applied directly to the master store while the dae - 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. + +--- + +## 2026-06-03 — Import-crash hotfix + 5 features (PRs #48 → #49 → #50) + +### Hotfix (#48) +- **Import All no longer crashes:** Settings → Import from Contacts → **Import All**. The import completes; the app does not crash and relaunching works (no crash loop). +- **Recent interactions raise the score:** for a priority contact stuck at 20/"Fading" with recent texts (e.g. Hugo Dooner), after the next sync the score rises above 20 to reflect the recency. Works on whichever tab is visible (recalc runs after each sync pull). + +### Suggested records (#49) +- Open a contact → **Introduced to** (or **Met via**) edit. With the search box empty, a **Suggested** section shows 3 contacts that share tags/groups/locations with the subject; the rest appear under **All Contacts**. Typing switches to a plain filtered search. + +### Click-to-sort columns (#49) +- In Contacts (macOS / iPad regular width), click a column header (Name, Groups, Locations, Tags, Met via, Introduced to, Score). The list sorts by that column; the active column shows ▲/▼. Click the same header again to reverse. String columns put blanks last; Score sorts highest-first on first click. + +### Hidden contacts everywhere (#49) +- Hide a contact (swipe → Hide, or Settings → Hidden Contacts). It must not appear in any picker: Introduced to, Met via, Merge, Met-via in the contact form, group/tag/location "add contact", network graph. (If it still shows on a *different* device, that's sync propagation — re-touch it or wait for the next sync.) + +### Notifications chiclet (#50) +- Overview shows a **bell** chiclet (top-right) with a red count badge when there are active notifications. +- Tap it → a sheet lists suggestions newest-first. Tap a row (or swipe right/leading) → navigates to the related contact. Swipe left/trailing → **Dismiss** (it disappears and stays gone, across relaunch and sync). +- Fading suggestions appear for contacts whose score dropped into the fading band (0 < score < 30). + +### Archive-on-import (#50) +- Remove a previously-imported contact from the system address book, then run **Import All**. An **Archive** suggestion appears in the bell list for that contact. Swipe → **Archive** hides them. Dismiss instead to keep them. + +### Notes / required steps +- iOS Simulator couldn't run in the dev env (CoreSimulator drift); suite run on the macOS destination (232 tests green) — CI runs the iOS path. +- **After #50 merges:** rebuild + reinstall `/Applications/BlackbookServer.app` (Release) so the server relays `appNotifications`. The new `AppNotification` schema is additive (no store wipe).