diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index 2d7a413..d993050 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */; }; 26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */; }; 26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */; }; - 26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */; }; 26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */; }; 26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */; }; 26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */; }; @@ -112,7 +111,6 @@ 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitRatingFlowView.swift; sourceTree = ""; }; 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AftermathRatingFlowView.swift; sourceTree = ""; }; 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitSummaryView.swift; sourceTree = ""; }; - 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = ""; }; 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = ""; }; 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBadgeView.swift; sourceTree = ""; }; @@ -225,7 +223,6 @@ 26B2CAE62F93C03F0039BA3B /* VisitRatingFlowView.swift */, 26B2CAE82F93C0490039BA3B /* AftermathRatingFlowView.swift */, 26B2CAEA2F93C05A0039BA3B /* VisitSummaryView.swift */, - 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */, 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */, ); sourceTree = ""; @@ -440,7 +437,6 @@ 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */, 26EF66322F9112E700824F91 /* Models.swift in Sources */, 26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */, - 26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */, 26EF66332F9112E700824F91 /* TodayView.swift in Sources */, 26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */, 26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */, diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 5af515e..9a1d95e 100644 Binary files a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate and b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index 41e0d7c..4a0267a 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -278,7 +278,10 @@ struct AddMomentView: View { // Treffen: Callback für Rating-Flow + evtl. Kalendertermin if selectedType == .meeting { + // createdAt = Zeitpunkt des Treffens; wenn ein Zukunftstermin gewählt, + // bleibt der Moment zunächst unbewertet (kein sofortiger Rating-Flow) if addToCalendar { + moment.createdAt = eventDate let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute()) let calEntry = LogEntry( type: .calendarEvent, diff --git a/nahbar/nahbar/CallSuggestionView.swift b/nahbar/nahbar/CallSuggestionView.swift index 6801593..6f524b2 100644 --- a/nahbar/nahbar/CallSuggestionView.swift +++ b/nahbar/nahbar/CallSuggestionView.swift @@ -6,15 +6,12 @@ struct CallSuggestionView: View { @Bindable var person: Person let onConfirm: () -> Void - /// Zeigt PersonalityBadge wenn profil vorhanden und Person schon länger nicht besucht. + /// Zeigt PersonalityBadge wenn Profil vorhanden und letztes Treffen schon länger zurückliegt. private var showRecommendedBadge: Bool { guard let profile = PersonalityStore.shared.profile, profile.level(for: .agreeableness) == .high else { return false } - let lastVisit = person.visits? - .compactMap { $0.visitDate } - .max() - guard let lastVisit else { return true } - let days = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0 + guard let lastMeeting = person.lastMeetingDate else { return true } + let days = Calendar.current.dateComponents([.day], from: lastMeeting, to: Date()).day ?? 0 return days > 14 } @@ -63,12 +60,12 @@ struct CallSuggestionView: View { .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } - // Offener nächster Schritt - if let step = person.nextStep, !person.nextStepCompleted { + // Offenes Vorhaben (ersetzt nextStep) + if let intention = person.openIntentions.first { HStack(spacing: 8) { Image(systemName: "arrow.right.circle") .font(.system(size: 13)) - Text(step) + Text(intention.text) .font(.system(size: 14)) } .foregroundStyle(theme.accent.opacity(0.85)) diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index da5a61f..f69c4f4 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -9,6 +9,8 @@ struct ContentView: View { @AppStorage("callWindowOnboardingDone") private var onboardingDone = false @AppStorage("callSuggestionDate") private var suggestionDateStr = "" @AppStorage("photoRepairPassDone") private var photoRepairPassDone = false + @AppStorage("visitMigrationPassDone") private var visitMigrationPassDone = false + @AppStorage("nextStepMigrationPassDone") private var nextStepMigrationPassDone = false @EnvironmentObject private var callWindowManager: CallWindowManager @EnvironmentObject private var appLockManager: AppLockManager @@ -85,6 +87,8 @@ struct ContentView: View { syncPeopleCache() importPendingMoments() runPhotoRepairPass() + runVisitMigrationPass() + runNextStepMigrationPass() if !nahbarOnboardingDone { showingNahbarOnboarding = true } else if !onboardingDone { @@ -210,6 +214,114 @@ struct ContentView: View { AppEventLog.shared.record("Foto-Migration: \(personsNeedingRepair.count) Person(en) migriert", level: .success, category: "Migration") } + // MARK: - Visit Migration Pass (V4 → V5 Datenmigration) + + /// Überführt Visit-Objekte in Meeting-Momente. + /// Ratings und HealthSnapshot werden auf den neuen Moment umgehängt, BEVOR das Visit + /// gelöscht wird – sonst würde die Cascade-Delete-Rule die Ratings mitlöschen. + /// Läuft einmalig nach der Schema-V5-Migration. Danach: visitMigrationPassDone = true. + private func runVisitMigrationPass() { + guard !visitMigrationPassDone else { return } + + let descriptor = FetchDescriptor() + guard let visits = try? modelContext.fetch(descriptor) else { + visitMigrationPassDone = true + logger.info("Visit Migration Pass: Fetch fehlgeschlagen") + return + } + + guard !visits.isEmpty else { + visitMigrationPassDone = true + logger.info("Visit Migration Pass: nichts zu migrieren") + return + } + + logger.info("Visit Migration Pass: \(visits.count) Visit(s) werden migriert") + for visit in visits { + let moment = Moment( + text: visit.note ?? "", + type: .meeting, + source: nil, + person: visit.person + ) + moment.createdAt = visit.visitDate + moment.updatedAt = visit.visitDate + moment.statusRaw = visit.statusRaw + moment.aftermathNotificationScheduled = visit.aftermathNotificationScheduled + moment.aftermathCompletedAt = visit.aftermathCompletedAt + modelContext.insert(moment) + visit.person?.moments?.append(moment) + + // Ratings umhängen BEVOR Visit gelöscht wird (Cascade-Delete-Schutz) + for rating in (visit.ratings ?? []) { + rating.visit = nil + rating.moment = moment + } + + // HealthSnapshot umhängen + if let snapshot = visit.healthSnapshot { + snapshot.visit = nil + snapshot.moment = moment + } + + modelContext.delete(visit) + } + + save() + visitMigrationPassDone = true + logger.info("Visit Migration Pass abgeschlossen") + AppEventLog.shared.record( + "Treffen-Migration: \(visits.count) Visit(s) zu Momenten konvertiert", + level: .success, + category: "Migration" + ) + } + + // MARK: - Next Step Migration Pass (V4 → V5 Datenmigration) + + /// Überführt Person.nextStep-Felder in Vorhaben-Momente. + /// Läuft einmalig nach der Schema-V5-Migration. Danach: nextStepMigrationPassDone = true. + private func runNextStepMigrationPass() { + guard !nextStepMigrationPassDone else { return } + + let personsWithNextStep = persons.filter { + let step = $0.nextStep + return step != nil && !(step!.isEmpty) + } + + guard !personsWithNextStep.isEmpty else { + nextStepMigrationPassDone = true + logger.info("Next Step Migration Pass: nichts zu migrieren") + return + } + + logger.info("Next Step Migration Pass: \(personsWithNextStep.count) Person(en) werden migriert") + for person in personsWithNextStep { + guard let text = person.nextStep, !text.isEmpty else { continue } + + let moment = Moment(text: text, type: .intention, source: nil, person: person) + moment.reminderDate = person.nextStepReminderDate + moment.isCompleted = person.nextStepCompleted + modelContext.insert(moment) + person.moments?.append(moment) + + // Legacy-Felder leeren + person.nextStep = nil + person.nextStepCompleted = false + person.nextStepReminderDate = nil + person.touch() + } + + save() + nextStepMigrationPassDone = true + logger.info("Next Step Migration Pass abgeschlossen") + AppEventLog.shared.record( + "Vorhaben-Migration: \(personsWithNextStep.count) Nächste-Schritte zu Momenten konvertiert", + level: .success, + category: "Migration" + ) + } + // MARK: - Helpers private func save() { diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index c752374..7af5b8b 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -286,6 +286,7 @@ }, "Abgeschlossen" : { "comment" : "VisitHistorySection – visit status label", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -539,6 +540,7 @@ }, "Anstehende Termine" : { "comment" : "TodayView – section title for upcoming reminders", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -548,6 +550,17 @@ } } }, + "Anstehende Unternehmungen" : { + "comment" : "TodayView – section title for intention moments with reminder dates", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming Activities" + } + } + } + }, "App wirklich zurücksetzen?" : { }, @@ -664,6 +677,7 @@ }, "Besuch" : { "comment" : "PersonDetailView – button label to rate a new visit", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -675,6 +689,7 @@ }, "Besuch bewerten" : { "comment" : "VisitRatingFlowView – navigation title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -696,8 +711,20 @@ } } }, + "Bewerten" : { + "comment" : "PersonDetailView – button label to open rating flow for a meeting moment", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate" + } + } + } + }, "Bewertet" : { "comment" : "VisitHistorySection – visit status label for immediateCompleted", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1587,11 +1614,23 @@ } } }, + "Erfasse Treffen als Moment und bewerte sie mit einem kurzen Fragebogen." : { + "comment" : "OnboardingContainerView – feature tour card description for Treffen", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log meetings as a moment and rate them with a short questionnaire." + } + } + } + }, "Ergebnis bestätigen und fortfahren" : { }, "Erinnern" : { "comment" : "PersonDetailView – set reminder confirmation button", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1601,8 +1640,20 @@ } } }, + "Erinnerung setzen" : { + "comment" : "AddMomentView – toggle label to enable reminder for intention moments", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set reminder" + } + } + } + }, "Erinnerung setzen?" : { "comment" : "PersonDetailView – reminder prompt title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1966,6 +2017,17 @@ } } }, + "Geplante Treffen" : { + "comment" : "TodayView – section title for planned future meeting moments", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planned Meetings" + } + } + } + }, "Gesprächszeit" : { "comment" : "SettingsView section header / CallWindowSetupView nav title", "localizations" : { @@ -2053,9 +2115,6 @@ } } } - }, - "Halte fest, wen du getroffen hast – und wann." : { - }, "Hat sich deine Sicht auf die Person verändert?" : { "comment" : "RatingQuestion – aftermath question text", @@ -2486,8 +2545,20 @@ "Kontakte überspringen?" : { }, - "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : { + "Kontakte und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : { "comment" : "OnboardingPrivacyView – local storage privacy row text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contacts and moments stay local on your device – no cloud synchronisation." + } + } + } + }, + "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : { + "comment" : "OnboardingPrivacyView – local storage privacy row text (stale: replaced by V5 string)", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2680,6 +2751,7 @@ }, "Möchtest du die Notiz anpassen?" : { "comment" : "VisitEditFlowView – note step title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2703,6 +2775,7 @@ }, "Möchtest du noch etwas festhalten?" : { "comment" : "VisitRatingFlowView – note step title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2906,6 +2979,7 @@ }, "Nächster Schritt" : { "comment" : "PersonDetailView – next step section header", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2940,6 +3014,7 @@ }, "Nachwirkung ausstehend" : { "comment" : "VisitHistorySection – visit status label for awaitingAftermath", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3178,6 +3253,7 @@ }, "Noch keine Treffen bewertet" : { "comment" : "VisitHistorySection – empty state title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3208,6 +3284,9 @@ } } } + }, + "Notiz anpassen" : { + }, "Notizen" : { "comment" : "AddPersonView / PersonDetailView – notes field label (plural)", @@ -3257,6 +3336,7 @@ }, "Offene Schritte" : { "comment" : "TodayView – section title for open next steps", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3266,8 +3346,20 @@ } } }, + "Offene Unternehmungen" : { + "comment" : "TodayView – section title for open intention moments without reminder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Activities" + } + } + } + }, "Ok" : { "comment" : "PersonDetailView – next step confirmation button", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3388,8 +3480,28 @@ } } }, - "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt." : { - + "Plane Unternehmungen mit Erinnerung – nahbar erinnert dich zur richtigen Zeit." : { + "comment" : "OnboardingContainerView – feature tour card description for Unternehmung (intention type)", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan activities with a reminder – nahbar notifies you at the right time." + } + } + } + }, + "Plane Vorhaben mit Erinnerung – nahbar erinnert dich zur richtigen Zeit." : { + "comment" : "OnboardingContainerView – stale, replaced by Momente wording", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan intentions with a reminder – nahbar notifies you at the right time." + } + } + } }, "PRO" : { "comment" : "Badge label for Pro tier", @@ -3539,6 +3651,7 @@ }, "Schritt definieren" : { "comment" : "PersonDetailView – define next step button", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3803,6 +3916,7 @@ }, "Tippe auf + um loszulegen" : { "comment" : "VisitHistorySection – empty state subtitle", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3844,6 +3958,9 @@ } } } + }, + "Treffen bewerten" : { + }, "Treffen mit %@" : { "comment" : "AddMomentView – calendar event / LogEntry title with person name", @@ -3982,6 +4099,17 @@ }, "Uns fehlt noch was – wir würden gerne mehr von dir erfahren." : { + }, + "Unternehmung" : { + "comment" : "MomentType.intention displayName – shown in type picker and feature tour", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activity" + } + } + } }, "Unwohl" : { "comment" : "RatingQuestion – negative pole for comfort during meeting", @@ -4130,7 +4258,8 @@ } }, "Vorhaben" : { - "comment" : "MomentType.intention raw value", + "comment" : "MomentType.intention raw value (stale: displayName now returns 'Momente')", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4232,6 +4361,7 @@ }, "Was als Nächstes?" : { "comment" : "PersonDetailView – next step input placeholder", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4243,6 +4373,7 @@ }, "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" : { "comment" : "AddMomentView – text editor placeholder", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index 0c9be24..3751235 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -165,17 +165,34 @@ struct LogbuchView: View { private func logbuchRow(item: LogbuchItem) -> some View { HStack(alignment: .top, spacing: 12) { - Image(systemName: item.icon) - .font(.system(size: 14, weight: .light)) - .foregroundStyle(item.isLogEntry ? theme.accent : theme.contentTertiary) - .frame(width: 20) - .padding(.top, 2) + // Typ-Icon (bei Vorhaben: Checkbox-Status) + Group { + if case .moment(let m) = item, m.isIntention { + Image(systemName: m.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(m.isCompleted ? Color.green : theme.contentTertiary) + } else { + Image(systemName: item.icon) + .foregroundStyle(item.isLogEntry ? theme.accent : theme.contentTertiary) + } + } + .font(.system(size: 14, weight: .light)) + .frame(width: 20) + .padding(.top, 2) VStack(alignment: .leading, spacing: 3) { - Text(item.title) - .font(.system(size: 15, design: theme.displayDesign)) - .foregroundStyle(theme.contentPrimary) - .fixedSize(horizontal: false, vertical: true) + // Titel (Vorhaben: Durchgestrichen wenn erledigt) + if case .moment(let m) = item, m.isIntention, m.isCompleted { + Text(item.title) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(theme.contentTertiary) + .strikethrough(true, color: theme.contentTertiary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(item.title) + .font(.system(size: 15, design: theme.displayDesign)) + .foregroundStyle(theme.contentPrimary) + .fixedSize(horizontal: false, vertical: true) + } HStack(spacing: 6) { if case .moment(let m) = item, m.isImportant { @@ -196,11 +213,33 @@ struct LogbuchView: View { } Spacer() + + // Score-Badge für bewertete Treffen + if case .moment(let m) = item, m.isMeeting, let avg = m.immediateAverage { + ZStack { + Circle() + .fill(scoreColor(avg).opacity(0.15)) + .frame(width: 30, height: 30) + Text(String(format: "%.1f", avg)) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(scoreColor(avg)) + } + .padding(.top, 2) + } } .padding(.horizontal, 16) .padding(.vertical, 12) } + private func scoreColor(_ value: Double) -> Color { + switch value { + case ..<(-0.5): return .red + case (-0.5)..<(0.5): return Color(.systemGray3) + case (0.5)...: return .green + default: return .gray + } + } + // MARK: - Empty State private var emptyState: some View { diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index 3fa093e..10c8f8b 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -46,7 +46,12 @@ enum MomentType: String, CaseIterable, Codable { case intention = "Vorhaben" /// Anzeigename im UI — entkoppelt Persistenzschlüssel von der Darstellung. - var displayName: String { rawValue } + var displayName: String { + switch self { + case .intention: return "Unternehmung" + default: return rawValue + } + } var icon: String { switch self { @@ -233,6 +238,36 @@ class Person { (visits ?? []).count } + // MARK: Meeting-Momente (V5) + + var meetingMoments: [Moment] { + (moments ?? []).filter { $0.type == .meeting } + } + + var sortedMeetings: [Moment] { + meetingMoments.sorted { $0.createdAt > $1.createdAt } + } + + var lastMeeting: Moment? { + sortedMeetings.first + } + + var lastMeetingDate: Date? { + lastMeeting?.createdAt + } + + var meetingCount: Int { + meetingMoments.count + } + + // MARK: Offene Vorhaben (V5) + + /// Alle Vorhaben-Momente, die noch nicht abgehakt wurden. + var openIntentions: [Moment] { + (moments ?? []).filter { $0.isOpen } + .sorted { $0.createdAt < $1.createdAt } + } + /// Muss nach jeder inhaltlichen Änderung aufgerufen werden. func touch() { updatedAt = Date() @@ -302,6 +337,17 @@ class Moment { var isImportant: Bool = false // Vom Nutzer als wichtig markiert var person: Person? + // V5: Meeting-Bewertungsfelder (ehemals auf Visit) + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + var statusRaw: String? = nil // nil für Nicht-Treffen; VisitStatus-rawValue für Treffen + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + + // V5: Vorhaben-Felder (ehemals Person.nextStep / nextStepReminderDate) + var reminderDate: Date? = nil // Erinnerungstermin für Vorhaben + var isCompleted: Bool = false // Ob das Vorhaben abgehakt wurde + init(text: String, type: MomentType = .conversation, source: MomentSource? = nil, person: Person? = nil) { self.id = UUID() self.text = text @@ -311,6 +357,13 @@ class Moment { self.updatedAt = Date() self.isImportant = false self.person = person + self.ratings = [] + self.healthSnapshot = nil + self.statusRaw = nil + self.aftermathNotificationScheduled = false + self.aftermathCompletedAt = nil + self.reminderDate = nil + self.isCompleted = false } var type: MomentType { @@ -322,6 +375,51 @@ class Moment { get { sourceRaw.flatMap { MomentSource(rawValue: $0) } } set { sourceRaw = newValue?.rawValue } } + + // MARK: Meeting-Hilfseigenschaften (V5) + + var isMeeting: Bool { type == .meeting } + + var meetingStatus: VisitStatus? { + get { statusRaw.flatMap { VisitStatus(rawValue: $0) } } + set { statusRaw = newValue?.rawValue } + } + + var isMeetingComplete: Bool { + meetingStatus == .completed + } + + var sortedRatings: [Rating] { + (ratings ?? []).sorted { $0.questionIndex < $1.questionIndex } + } + + func averageForCategory(_ category: RatingCategory, aftermath: Bool) -> Double? { + let filtered = (ratings ?? []).filter { + $0.category == category && $0.isAftermath == aftermath + } + let valued = filtered.compactMap { $0.value } + guard !valued.isEmpty else { return nil } + return Double(valued.reduce(0, +)) / Double(valued.count) + } + + var immediateAverage: Double? { + let values = (ratings ?? []).filter { !$0.isAftermath }.compactMap { $0.value } + guard !values.isEmpty else { return nil } + return Double(values.reduce(0, +)) / Double(values.count) + } + + var aftermathAverage: Double? { + let values = (ratings ?? []).filter { $0.isAftermath }.compactMap { $0.value } + guard !values.isEmpty else { return nil } + return Double(values.reduce(0, +)) / Double(values.count) + } + + // MARK: Vorhaben-Hilfseigenschaften (V5) + + var isIntention: Bool { type == .intention } + + /// Ein Vorhaben das noch nicht abgehakt wurde. + var isOpen: Bool { isIntention && !isCompleted } } // MARK: - Visit Rating Enums @@ -497,15 +595,18 @@ class Rating { var questionIndex: Int = 0 var value: Int? = nil // nil = übersprungen; -2...+2 sonst var isAftermath: Bool = false - var visit: Visit? = nil + var visit: Visit? = nil // V4 legacy – nach Migration immer nil + var moment: Moment? = nil // V5 – primäre Referenz - init(category: RatingCategory, questionIndex: Int, value: Int?, isAftermath: Bool, visit: Visit? = nil) { + init(category: RatingCategory, questionIndex: Int, value: Int?, isAftermath: Bool, + visit: Visit? = nil, moment: Moment? = nil) { self.id = UUID() self.categoryRaw = category.rawValue self.questionIndex = questionIndex self.value = value self.isAftermath = isAftermath self.visit = visit + self.moment = moment } var category: RatingCategory { @@ -523,11 +624,13 @@ class HealthSnapshot { var hrvMs: Double? = nil var restingHR: Int? = nil var steps: Int? = nil - var visit: Visit? = nil + var visit: Visit? = nil // V4 legacy – nach Migration immer nil + var moment: Moment? = nil // V5 – primäre Referenz - init(visit: Visit? = nil) { + init(visit: Visit? = nil, moment: Moment? = nil) { self.id = UUID() self.visit = visit + self.moment = moment } var hasData: Bool { diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift index 0b04af7..722c209 100644 --- a/nahbar/nahbar/NahbarMigration.swift +++ b/nahbar/nahbar/NahbarMigration.swift @@ -186,18 +186,123 @@ enum NahbarSchemaV3: VersionedSchema { } } -// MARK: - Schema V4 (aktuelles Schema) -// Referenziert die Live-Typen aus Models.swift. -// Beim Hinzufügen von V5 muss V4 als eingefrorener Snapshot gesichert werden. +// MARK: - Schema V4 (eingefrorener Snapshot) +// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten +// Schema-Hash von V4-Datenbanken auf Nutzer-Geräten entsprechen. // -// V4 fügt hinzu: +// V4 fügte hinzu: // • Visit, Rating, HealthSnapshot: neue Modelle für Besuchs-Bewertungen // • Person: visits-Relationship enum NahbarSchemaV4: VersionedSchema { static var versionIdentifier = Schema.Version(4, 0, 0) static var models: [any PersistentModel.Type] { - [nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, nahbar.PersonPhoto.self, + [PersonPhoto.self, Person.self, Moment.self, LogEntry.self, + Visit.self, Rating.self, HealthSnapshot.self] + } + + @Model final class PersonPhoto { + var id: UUID = UUID() + @Attribute(.externalStorage) var imageData: Data = Data() + var createdAt: Date = Date() + init() {} + } + + @Model final class Person { + var id: UUID = UUID() + var name: String = "" + var tagRaw: String = "Andere" + var birthday: Date? = nil + var occupation: String? = nil + var location: String? = nil + var interests: String? = nil + var generalNotes: String? = nil + var nudgeFrequencyRaw: String = "Monatlich" + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isArchived: Bool = false + @Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil + var photoData: Data? = nil + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + @Relationship(deleteRule: .cascade) var visits: [Visit]? = [] + init() {} + } + + @Model final class Moment { + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = "Gespräch" + var sourceRaw: String? = nil + var createdAt: Date = Date() + var updatedAt: Date = Date() + var isImportant: Bool = false + var person: Person? = nil + init() {} + } + + @Model final class LogEntry { + var id: UUID = UUID() + var typeRaw: String = "Schritt abgeschlossen" + var title: String = "" + var loggedAt: Date = Date() + var updatedAt: Date = Date() + var person: Person? = nil + init() {} + } + + @Model final class Visit { + var id: UUID = UUID() + var visitDate: Date = Date() + var statusRaw: String = "sofort_abgeschlossen" + var note: String? = nil + var aftermathNotificationScheduled: Bool = false + var aftermathCompletedAt: Date? = nil + var person: Person? = nil + @Relationship(deleteRule: .cascade) var ratings: [Rating]? = [] + @Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil + init() {} + } + + @Model final class Rating { + var id: UUID = UUID() + var categoryRaw: String = "Selbst" + var questionIndex: Int = 0 + var value: Int? = nil + var isAftermath: Bool = false + var visit: Visit? = nil + init() {} + } + + @Model final class HealthSnapshot { + var id: UUID = UUID() + var sleepHours: Double? = nil + var hrvMs: Double? = nil + var restingHR: Int? = nil + var steps: Int? = nil + var visit: Visit? = nil + init() {} + } +} + +// MARK: - Schema V5 (aktuelles Schema) +// Referenziert die Live-Typen aus Models.swift. +// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden. +// +// V5 fügt hinzu: +// • Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled, +// aftermathCompletedAt, reminderDate, isCompleted +// • Rating: moment-Relationship (neben legacy visit) +// • HealthSnapshot: moment-Relationship (neben legacy visit) + +enum NahbarSchemaV5: VersionedSchema { + static var versionIdentifier = Schema.Version(5, 0, 0) + static var models: [any PersistentModel.Type] { + [nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self] } } @@ -206,7 +311,8 @@ enum NahbarSchemaV4: VersionedSchema { enum NahbarMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, NahbarSchemaV4.self] + [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, + NahbarSchemaV4.self, NahbarSchemaV5.self] } static var stages: [MigrationStage] { @@ -221,7 +327,12 @@ enum NahbarMigrationPlan: SchemaMigrationPlan { // V3 → V4: Visit/Rating/HealthSnapshot neu, Person bekommt visits-Relationship. // Alle neuen Felder haben Default-Werte → lightweight-Migration reicht aus. - .lightweight(fromVersion: NahbarSchemaV3.self, toVersion: NahbarSchemaV4.self) + .lightweight(fromVersion: NahbarSchemaV3.self, toVersion: NahbarSchemaV4.self), + + // V4 → V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot + // bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema + // (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte → lightweight. + .lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self) ] } } diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index c3443b8..d3b4a64 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -605,7 +605,7 @@ private struct OnboardingPrivacyView: View { VStack(alignment: .leading, spacing: 20) { privacyRow( icon: "iphone", - text: "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." + text: "Kontakte und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." ) privacyRow( icon: "person.slash", @@ -664,14 +664,14 @@ struct FeatureTourStep { static let all: [FeatureTourStep] = [ FeatureTourStep( icon: "checklist", - title: "Vorhaben", - description: "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt.", + title: "Unternehmung", + description: "Plane Unternehmungen mit Erinnerung – nahbar erinnert dich zur richtigen Zeit.", showPrivacySummary: false ), FeatureTourStep( icon: "figure.walk.arrival", title: "Treffen", - description: "Halte fest, wen du getroffen hast – und wann.", + description: "Erfasse Treffen als Moment und bewerte sie mit einem kurzen Fragebogen.", showPrivacySummary: false ), FeatureTourStep( diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 1429d47..4d59719 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -43,7 +43,8 @@ struct PersonDetailView: View { } .sheet(isPresented: $showingAddMoment) { AddMomentView(person: person) { meetingMoment in - // Nach Sheet-Dismiss kurz warten, dann Rating-Flow öffnen + // Rating-Flow nur für vergangene Treffen – zukünftige Termine überspringen + guard meetingMoment.createdAt <= Date() else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { momentForRating = meetingMoment } diff --git a/nahbar/nahbar/PersonalityEngine.swift b/nahbar/nahbar/PersonalityEngine.swift index 6ced43f..2d9811b 100644 --- a/nahbar/nahbar/PersonalityEngine.swift +++ b/nahbar/nahbar/PersonalityEngine.swift @@ -106,7 +106,7 @@ enum PersonalityEngine { static func sortedSuggestions( contacts: [NahbarContact], profile: PersonalityProfile?, - lastVisitDates: [UUID: Date] + lastMeetingDates: [UUID: Date] ) -> [ContactSuggestion] { guard let profile else { return contacts.map { ContactSuggestion(contact: $0, isRecommended: false, reason: nil) } @@ -127,16 +127,16 @@ enum PersonalityEngine { var sorted = contacts if a == .high { sorted = contacts.sorted { lhs, rhs in - let lDate = lastVisitDates[lhs.id] ?? .distantPast - let rDate = lastVisitDates[rhs.id] ?? .distantPast - return lDate < rDate // ältester Besuch zuerst + let lDate = lastMeetingDates[lhs.id] ?? .distantPast + let rDate = lastMeetingDates[rhs.id] ?? .distantPast + return lDate < rDate // ältestes Treffen zuerst } } return sorted.prefix(maxCount).map { contact in let longAgo: Bool - if let lastVisit = lastVisitDates[contact.id] { - let daysSince = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0 + if let lastMeeting = lastMeetingDates[contact.id] { + let daysSince = Calendar.current.dateComponents([.day], from: lastMeeting, to: Date()).day ?? 0 longAgo = daysSince > 14 } else { longAgo = true diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index b8bc6a0..9975d54 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -605,11 +605,13 @@ struct SettingsView: View { UserProfileStore.shared.reset() ContactStore.shared.reset() - // 3. Onboarding-Flags zurücksetzen + // 3. Onboarding- und Migrations-Flags zurücksetzen nahbarOnboardingDone = false callWindowOnboardingDone = false photoRepairPassDone = false callSuggestionDate = "" + UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone") + UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone") // 4. App neu starten damit alle States frisch initialisiert werden DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) } diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 321a151..6c3d3bd 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -4,11 +4,12 @@ import SwiftData struct TodayView: View { @Environment(\.nahbarTheme) var theme @Query private var people: [Person] - @Query(filter: #Predicate { $0.statusRaw == "warte_nachwirkung" }, - sort: \Visit.visitDate, order: .reverse) - private var pendingAftermaths: [Visit] - @State private var showingAftermathRating = false - @State private var selectedVisitForAftermath: Visit? = nil + // V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung" + @Query(filter: #Predicate { + $0.statusRaw == "warte_nachwirkung" && $0.typeRaw == "Treffen" + }, sort: \Moment.createdAt, order: .reverse) + private var pendingAftermaths: [Moment] + @State private var selectedMomentForAftermath: Moment? = nil @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 private var needsAttention: [Person] { @@ -21,28 +22,42 @@ struct TodayView: View { people.filter { $0.hasBirthdayWithin(days: daysAhead) } } - // People with a scheduled reminder within the look-ahead window - private var upcomingReminders: [Person] { - let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: Date()) ?? Date() - return people - .filter { p in - guard let reminder = p.nextStepReminderDate, !p.nextStepCompleted else { return false } - return reminder >= Date() && reminder <= horizon - } - .sorted { ($0.nextStepReminderDate ?? Date()) < ($1.nextStepReminderDate ?? Date()) } + // V5: Geplante Treffen (Datum in der Zukunft, noch nicht bewertet) + @Query(filter: #Predicate { + $0.typeRaw == "Treffen" && $0.statusRaw == nil + }, sort: \Moment.createdAt) + private var allUnratedMeetings: [Moment] + + private var plannedMeetings: [Moment] { + let now = Date() + let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now + return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon } } - // Open next steps NOT already shown under upcoming reminders - private var openNextSteps: [Person] { - let scheduledIDs = Set(upcomingReminders.map { $0.id }) - return people.filter { p in - p.nextStep != nil && !p.nextStepCompleted && !scheduledIDs.contains(p.id) + // V5: Vorhaben-Momente mit Erinnerung innerhalb des Zeitfensters + @Query(filter: #Predicate { + $0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil + }, sort: \Moment.reminderDate) + private var allIntentionsWithReminder: [Moment] + + private var upcomingReminders: [Moment] { + let now = Date() + let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now + return allIntentionsWithReminder.filter { m in + guard let r = m.reminderDate else { return false } + return r >= now && r <= horizon } } + // V5: Vorhaben ohne Erinnerung (offene Schritte) + @Query(filter: #Predicate { + $0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate == nil + }, sort: \Moment.createdAt) + private var openIntentions: [Moment] + private var isEmpty: Bool { - needsAttention.isEmpty && birthdayPeople.isEmpty && openNextSteps.isEmpty - && upcomingReminders.isEmpty && pendingAftermaths.isEmpty + needsAttention.isEmpty && birthdayPeople.isEmpty && openIntentions.isEmpty + && upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty } private var birthdaySectionTitle: LocalizedStringKey { @@ -103,28 +118,48 @@ struct TodayView: View { } } - if !upcomingReminders.isEmpty { - TodaySection(title: "Anstehende Termine", icon: "calendar") { - ForEach(upcomingReminders) { person in - NavigationLink(destination: PersonDetailView(person: person)) { - TodayRow(person: person, hint: reminderHint(for: person)) + if !plannedMeetings.isEmpty { + TodaySection(title: "Geplante Treffen", icon: "calendar.badge.clock") { + ForEach(plannedMeetings) { moment in + if let person = moment.person { + NavigationLink(destination: PersonDetailView(person: person)) { + TodayRow(person: person, hint: plannedMeetingHint(for: moment)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - if person.id != upcomingReminders.last?.id { + if moment.id != plannedMeetings.last?.id { RowDivider() } } } } - if !openNextSteps.isEmpty { - TodaySection(title: "Offene Schritte", icon: "arrow.right.circle") { - ForEach(openNextSteps) { person in - NavigationLink(destination: PersonDetailView(person: person)) { - TodayRow(person: person, hint: person.nextStep ?? "") + if !upcomingReminders.isEmpty { + TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") { + ForEach(upcomingReminders) { moment in + if let person = moment.person { + NavigationLink(destination: PersonDetailView(person: person)) { + TodayRow(person: person, hint: intentionReminderHint(for: moment)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) - if person.id != openNextSteps.last?.id { + if moment.id != upcomingReminders.last?.id { + RowDivider() + } + } + } + } + + if !openIntentions.isEmpty { + TodaySection(title: "Offene Unternehmungen", icon: "arrow.right.circle") { + ForEach(openIntentions) { moment in + if let person = moment.person { + NavigationLink(destination: PersonDetailView(person: person)) { + TodayRow(person: person, hint: moment.text) + } + .buttonStyle(.plain) + } + if moment.id != openIntentions.last?.id { RowDivider() } } @@ -133,20 +168,19 @@ struct TodayView: View { if !pendingAftermaths.isEmpty { TodaySection(title: "Nachwirkung fällig", icon: "moon.stars.fill") { - ForEach(pendingAftermaths) { visit in + ForEach(pendingAftermaths) { moment in Button { - selectedVisitForAftermath = visit - showingAftermathRating = true + selectedMomentForAftermath = moment } label: { HStack(spacing: 12) { - if let p = visit.person { + if let p = moment.person { PersonAvatar(person: p, size: 36) } VStack(alignment: .leading, spacing: 2) { - Text(visit.person?.name ?? "Unbekannt") + Text(moment.person?.name ?? "Unbekannt") .font(.system(size: 15, weight: .medium)) .foregroundStyle(theme.contentPrimary) - Text("Treffen \(visit.visitDate.formatted(date: .abbreviated, time: .omitted))") + Text("Treffen \(moment.createdAt.formatted(date: .abbreviated, time: .omitted))") .font(.system(size: 13)) .foregroundStyle(theme.contentTertiary) } @@ -159,7 +193,7 @@ struct TodayView: View { .padding(.vertical, 12) } .buttonStyle(.plain) - if visit.id != pendingAftermaths.last?.id { + if moment.id != pendingAftermaths.last?.id { RowDivider() } } @@ -186,8 +220,8 @@ struct TodayView: View { } .background(theme.backgroundPrimary.ignoresSafeArea()) .navigationBarHidden(true) - .sheet(item: $selectedVisitForAftermath) { visit in - AftermathRatingFlowView(visit: visit) + .sheet(item: $selectedMomentForAftermath) { moment in + AftermathRatingFlowView(moment: moment) } } } @@ -226,10 +260,15 @@ struct TodayView: View { return "" } - private func reminderHint(for person: Person) -> String { - guard let reminder = person.nextStepReminderDate else { return person.nextStep ?? "" } + private func plannedMeetingHint(for moment: Moment) -> String { + let dateStr = moment.createdAt.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)" + } + + private func intentionReminderHint(for moment: Moment) -> String { + guard let reminder = moment.reminderDate else { return moment.text } let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute()) - return "\(person.nextStep ?? "") · \(dateStr)" + return "\(moment.text) · \(dateStr)" } private func lastSeenHint(for person: Person) -> String { diff --git a/nahbar/nahbarTests/AppEventLogTests.swift b/nahbar/nahbarTests/AppEventLogTests.swift index 5ca1742..8e279c1 100644 --- a/nahbar/nahbarTests/AppEventLogTests.swift +++ b/nahbar/nahbarTests/AppEventLogTests.swift @@ -155,14 +155,14 @@ struct SchemaRegressionTests { #expect(NahbarSchemaV3.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 4 Schemas") - func migrationPlanHasFourSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 4) + @Test("Migrationsplan enthält genau 5 Schemas") + func migrationPlanHasFiveSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 5) } - @Test("Migrationsplan enthält genau 3 Stages") - func migrationPlanHasThreeStages() { - #expect(NahbarMigrationPlan.stages.count == 3) + @Test("Migrationsplan enthält genau 4 Stages") + func migrationPlanHasFourStages() { + #expect(NahbarMigrationPlan.stages.count == 4) } @Test("ContainerFallback-Gleichheit funktioniert korrekt") diff --git a/nahbar/nahbarTests/ModelTests.swift b/nahbar/nahbarTests/ModelTests.swift index 8f67894..f412faa 100644 --- a/nahbar/nahbarTests/ModelTests.swift +++ b/nahbar/nahbarTests/ModelTests.swift @@ -91,14 +91,20 @@ struct MomentTypeTests { } } - @Test("displayName ist für alle Types gleich rawValue") - func displayNameEqualsRawValueForAllTypes() { - for type_ in MomentType.allCases { + @Test("displayName entspricht rawValue außer für .intention") + func displayNameEqualsRawValueExceptIntention() { + for type_ in MomentType.allCases where type_ != .intention { #expect(type_.displayName == type_.rawValue, "displayName sollte rawValue sein für \(type_)") } } + @Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')") + func intentionDisplayNameIsUnternehmung() { + #expect(MomentType.intention.displayName == "Unternehmung") + #expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt + } + @Test("alle Types haben nicht-leeres displayName") func allTypesHaveNonEmptyDisplayName() { for type_ in MomentType.allCases { @@ -287,6 +293,67 @@ struct MomentComputedPropertyTests { let m = Moment(text: "Test") #expect(!m.isImportant) } + + // V5 – Meeting-Felder + @Test("V5: statusRaw startet als nil") + func statusRawDefaultsNil() { + let m = Moment(text: "Test", type: .meeting) + #expect(m.statusRaw == nil) + } + + @Test("V5: aftermathNotificationScheduled startet als false") + func aftermathNotificationScheduledDefaultsFalse() { + let m = Moment(text: "Test") + #expect(!m.aftermathNotificationScheduled) + } + + @Test("V5: isCompleted startet als false") + func isCompletedDefaultsFalse() { + let m = Moment(text: "Test") + #expect(!m.isCompleted) + } + + @Test("V5: reminderDate startet als nil") + func reminderDateDefaultsNil() { + let m = Moment(text: "Test", type: .intention) + #expect(m.reminderDate == nil) + } + + @Test("V5: isMeeting true nur für .meeting") + func isMeetingOnlyForMeetingType() { + let meeting = Moment(text: "Test", type: .meeting) + let other = Moment(text: "Test", type: .conversation) + #expect(meeting.isMeeting) + #expect(!other.isMeeting) + } + + @Test("V5: isIntention true nur für .intention") + func isIntentionOnlyForIntentionType() { + let intention = Moment(text: "Test", type: .intention) + let other = Moment(text: "Test", type: .thought) + #expect(intention.isIntention) + #expect(!other.isIntention) + } + + @Test("V5: isOpen true wenn intention && nicht erledigt") + func isOpenWhenNotCompleted() { + let m = Moment(text: "Test", type: .intention) + m.isCompleted = false + #expect(m.isOpen) + m.isCompleted = true + #expect(!m.isOpen) + } + + @Test("V5: meetingStatus round-trip via statusRaw") + func meetingStatusRoundTrip() { + let m = Moment(text: "Test", type: .meeting) + m.meetingStatus = .awaitingAftermath + #expect(m.meetingStatus == .awaitingAftermath) + #expect(m.statusRaw == VisitStatus.awaitingAftermath.rawValue) + m.meetingStatus = .completed + #expect(m.meetingStatus == .completed) + #expect(m.isMeetingComplete) + } } // MARK: - LogEntry Tests diff --git a/nahbar/nahbarTests/NahbarPersonalityTests.swift b/nahbar/nahbarTests/NahbarPersonalityTests.swift index 7bed747..e383d81 100644 --- a/nahbar/nahbarTests/NahbarPersonalityTests.swift +++ b/nahbar/nahbarTests/NahbarPersonalityTests.swift @@ -409,7 +409,7 @@ struct PersonalityEngineBehaviorTests { let suggestions = PersonalityEngine.sortedSuggestions( contacts: [], profile: nil, - lastVisitDates: [:] + lastMeetingDates: [:] ) for s in suggestions { #expect(!s.isRecommended) diff --git a/nahbar/nahbarTests/VisitRatingTests.swift b/nahbar/nahbarTests/VisitRatingTests.swift index 7c8d09e..4a8cb3e 100644 --- a/nahbar/nahbarTests/VisitRatingTests.swift +++ b/nahbar/nahbarTests/VisitRatingTests.swift @@ -323,9 +323,9 @@ struct AftermathNotificationManagerTests { #expect(AftermathNotificationManager.actionID == "RATE_NOW") } - @Test("visitIDKey ist unveränderlich") - func visitIDKey() { - #expect(AftermathNotificationManager.visitIDKey == "visitID") + @Test("momentIDKey ist unveränderlich") + func momentIDKey() { + #expect(AftermathNotificationManager.momentIDKey == "momentID") } @Test("personNameKey ist unveränderlich") @@ -345,14 +345,27 @@ struct SchemaV4RegressionTests { #expect(NahbarSchemaV4.versionIdentifier.minor == 0) #expect(NahbarSchemaV4.versionIdentifier.patch == 0) } +} - @Test("Migrationsplan enthält genau 4 Schemas") - func migrationPlanHasFourSchemas() { - #expect(NahbarMigrationPlan.schemas.count == 4) +// MARK: - Schema-Regressionswächter (V5) + +@Suite("Schema – Regressionswächter V5") +struct SchemaV5RegressionTests { + + @Test("NahbarSchemaV5 hat Version 5.0.0") + func schemaV5HasCorrectVersion() { + #expect(NahbarSchemaV5.versionIdentifier.major == 5) + #expect(NahbarSchemaV5.versionIdentifier.minor == 0) + #expect(NahbarSchemaV5.versionIdentifier.patch == 0) } - @Test("Migrationsplan enthält genau 3 Stages") - func migrationPlanHasThreeStages() { - #expect(NahbarMigrationPlan.stages.count == 3) + @Test("Migrationsplan enthält genau 5 Schemas") + func migrationPlanHasFiveSchemas() { + #expect(NahbarMigrationPlan.schemas.count == 5) + } + + @Test("Migrationsplan enthält genau 4 Stages") + func migrationPlanHasFourStages() { + #expect(NahbarMigrationPlan.stages.count == 4) } }