From 4761e2e5c86dcd008c78d5f142eca0708054e661 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 11 Feb 2026 23:37:11 +0100 Subject: [PATCH] 1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews Co-Authored-By: Claude Opus 4.6 --- .../CoreData/Snapshot+CoreDataClass.swift | 2 +- .../Services/ReviewPromptService.swift | 35 +++ PortfolioJournal/Services/ShareService.swift | 170 +++++++++++++++ .../Utilities/MonthlyCheckInStore.swift | 51 ++++- .../ViewModels/ChartsViewModel.swift | 202 ++++++++++++++---- .../ViewModels/GoalsViewModel.swift | 31 +++ .../Views/Charts/AllocationPieChart.swift | 8 +- .../Views/Charts/ChartsContainerView.swift | 111 +++++++++- .../Views/Charts/PerformanceBarChart.swift | 3 +- .../Views/Dashboard/DashboardView.swift | 55 +++-- .../Views/Dashboard/MonthlyCheckInView.swift | 72 +++++-- .../Views/Dashboard/ShareCards.swift | 143 +++++++++++++ PortfolioJournal/Views/Goals/GoalsView.swift | 96 ++++++++- .../Services/ReviewPromptServiceTests.swift | 36 ++++ .../Services/ShareServiceTests.swift | 18 ++ .../Utilities/MonthlyCheckInStoreTests.swift | 63 ++++++ .../ChartsViewModelDisplayRulesTests.swift | 26 +++ .../ViewModels/GoalsDisplayRulesTests.swift | 50 +++++ .../Views/MonthlyCheckInViewTests.swift | 36 ++++ 19 files changed, 1118 insertions(+), 90 deletions(-) create mode 100644 PortfolioJournal/Views/Dashboard/ShareCards.swift create mode 100644 PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift create mode 100644 PortfolioJournalTests/ViewModels/ChartsViewModelDisplayRulesTests.swift create mode 100644 PortfolioJournalTests/ViewModels/GoalsDisplayRulesTests.swift create mode 100644 PortfolioJournalTests/Views/MonthlyCheckInViewTests.swift diff --git a/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift index 351d7f5..67f13dd 100644 --- a/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift +++ b/PortfolioJournal/Models/CoreData/Snapshot+CoreDataClass.swift @@ -40,7 +40,7 @@ extension Snapshot { } let newId = UUID() setPrimitiveValue(newId, forKey: "id") - return newId + return newId } var safeDate: Date { diff --git a/PortfolioJournal/Services/ReviewPromptService.swift b/PortfolioJournal/Services/ReviewPromptService.swift index 9b255df..62cdf6a 100644 --- a/PortfolioJournal/Services/ReviewPromptService.swift +++ b/PortfolioJournal/Services/ReviewPromptService.swift @@ -6,6 +6,8 @@ final class ReviewPromptService { private let lastPromptKey = "reviewPromptLastDate" private let checkInCountKey = "reviewPromptCheckinCount" + private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview" + private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys" private let minCheckInsBetweenPrompts = 3 private let minDaysBetweenPrompts = 90 private let userDefaults: UserDefaults @@ -34,7 +36,40 @@ final class ReviewPromptService { requestReviewIfEligible() } + func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set) -> Bool { + guard !newlyUnlockedAchievementKeys.isEmpty else { return false } + guard !hasCompletedStoreReview else { return false } + + let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? []) + let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys) + guard !unpromptedKeys.isEmpty else { return false } + + let mergedKeys = promptedKeys.union(unpromptedKeys).sorted() + userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey) + return true + } + + var hasCompletedStoreReview: Bool { + userDefaults.bool(forKey: hasCompletedStoreReviewKey) + } + + func markStoreReviewCompleted() { + userDefaults.set(true, forKey: hasCompletedStoreReviewKey) + } + + static func appStoreWriteReviewURL() -> URL { + guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else { + return GoalShareService.appStoreURL + } + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "action" } + queryItems.append(URLQueryItem(name: "action", value: "write-review")) + components.queryItems = queryItems + return components.url ?? GoalShareService.appStoreURL + } + private func requestReviewIfEligible() { + guard !hasCompletedStoreReview else { return } let now = dateProvider() if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date { let daysSince = now.timeIntervalSince(lastPrompt) / 86_400 diff --git a/PortfolioJournal/Services/ShareService.swift b/PortfolioJournal/Services/ShareService.swift index 47a7c8c..837cb5a 100644 --- a/PortfolioJournal/Services/ShareService.swift +++ b/PortfolioJournal/Services/ShareService.swift @@ -1,5 +1,7 @@ import Foundation import UIKit +import SwiftUI +import LinkPresentation class ShareService { static let shared = ShareService() @@ -18,6 +20,81 @@ class ShareService { """ } + static func buildPortfolioValueShareText( + totalValue: String, + changeText: String, + changeLabel: String, + yearChange: String?, + sinceInceptionChange: String?, + appName: String + ) -> String { + var lines = [ + "Total Portfolio Value", + totalValue, + "\(changeText) \(changeLabel)" + ] + + if let yearChange { + lines.append("YoY: \(yearChange)") + } + if let sinceInceptionChange { + lines.append("Since inception: \(sinceInceptionChange)") + } + + lines.append("") + lines.append("Shared from \(appName)") + return lines.joined(separator: "\n") + } + + @MainActor + func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) { + let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName) + shareCard( + cardTitle: "\(summary.periodLabel) Check-in", + fallbackText: text + ) { + MonthlyCheckInShareCardView( + summary: summary, + appName: appName, + qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200) + ) + } + } + + @MainActor + func sharePortfolioValue( + totalValue: String, + changeText: String, + changeLabel: String, + yearChange: String?, + sinceInceptionChange: String? + ) { + let appName = Self.appDisplayName + let text = Self.buildPortfolioValueShareText( + totalValue: totalValue, + changeText: changeText, + changeLabel: changeLabel, + yearChange: yearChange, + sinceInceptionChange: sinceInceptionChange, + appName: appName + ) + + shareCard( + cardTitle: "Portfolio Snapshot", + fallbackText: text + ) { + PortfolioValueShareCardView( + totalValue: totalValue, + changeText: changeText, + changeLabel: changeLabel, + yearChange: yearChange, + sinceInceptionChange: sinceInceptionChange, + appName: appName, + qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200) + ) + } + } + func shareText(_ content: String) { guard let viewController = ShareService.topViewController() else { return } @@ -41,6 +118,36 @@ class ShareService { } } + @MainActor + private func shareCard( + cardTitle: String, + fallbackText: String, + @ViewBuilder card: () -> Content + ) { + guard let viewController = ShareService.topViewController() else { return } + let shareURL = GoalShareService.appStoreURL + + if #available(iOS 16.0, *) { + let renderer = ImageRenderer(content: card()) + let scale = viewController.view.window?.windowScene?.screen.scale + ?? viewController.traitCollection.displayScale + renderer.scale = scale + + if let image = renderer.uiImage { + let item = CardShareItem( + image: image, + title: cardTitle, + text: fallbackText, + url: shareURL + ) + presentShareSheet(items: [item], from: viewController) + return + } + } + + presentShareSheet(items: [fallbackText], from: viewController) + } + func shareTextFile(content: String, fileName: String) { guard let viewController = ShareService.topViewController() else { return } @@ -144,4 +251,67 @@ class ShareService { .replacingOccurrences(of: ";", with: "\\;") .replacingOccurrences(of: ",", with: "\\,") } + + private func presentShareSheet(items: [Any], from viewController: UIViewController) { + let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) + if let popover = activityVC.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect( + x: viewController.view.bounds.midX, + y: viewController.view.bounds.midY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + viewController.present(activityVC, animated: true) + } + + private static var appDisplayName: String { + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { + return name + } + if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String { + return name + } + return "Portfolio Journal" + } +} + +private class CardShareItem: NSObject, UIActivityItemSource { + let image: UIImage + let title: String + let text: String + let url: URL + + init(image: UIImage, title: String, text: String, url: URL) { + self.image = image + self.title = title + self.text = text + self.url = url + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + image + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + image + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + title + } + + func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { + let metadata = LPLinkMetadata() + metadata.title = title + metadata.originalURL = url + metadata.url = url + metadata.imageProvider = NSItemProvider(object: image) + if let appIcon = UIImage(named: "BrandMark") { + metadata.iconProvider = NSItemProvider(object: appIcon) + } + return metadata + } } diff --git a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift index abf7a9a..a0a34a4 100644 --- a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift +++ b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift @@ -5,6 +5,7 @@ enum MonthlyCheckInStore { private static let completionsKey = "monthlyCheckInCompletions" private static let legacyLastCheckInKey = "lastCheckInDate" private static let entriesKey = "monthlyCheckInEntries" + private static let graceDays = 20 // MARK: - Public Accessors @@ -44,7 +45,7 @@ enum MonthlyCheckInStore { } static func monthKey(for date: Date) -> String { - Self.monthFormatter.string(from: date) + Self.monthFormatter.string(from: effectiveMonth(for: date)) } static func allNotes() -> [(date: Date, note: String)] { @@ -74,11 +75,42 @@ enum MonthlyCheckInStore { } static func setCompletionDate(_ completionDate: Date, for month: Date) { - let targetMonth = month.startOfMonth + let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays) let targetKey = monthKey(for: targetMonth) var entries = loadEntries() var didChange = false + let calendar = Calendar.current + if calendar.isDate(month, inSameDayAs: completionDate), + calendar.component(.day, from: completionDate) > graceDays { + let completedEntries = entries.compactMap { key, entry -> (month: Date, entry: MonthlyCheckInEntry)? in + guard let monthDate = monthFormatter.date(from: key)?.startOfMonth else { return nil } + guard entry.completionTime != nil else { return nil } + guard monthDate < targetMonth else { return nil } + return (month: monthDate, entry: entry) + } + + if let lastCompleted = completedEntries.max(by: { $0.month < $1.month }) { + var cursor = lastCompleted.month.adding(months: 1).startOfMonth + while cursor < targetMonth { + let key = monthFormatter.string(from: cursor) + if entries[key] == nil { + let fallbackCompletion = min(cursor.endOfMonth, completionDate) + let entry = MonthlyCheckInEntry( + note: lastCompleted.entry.note, + rating: lastCompleted.entry.rating, + mood: lastCompleted.entry.mood, + completionTime: fallbackCompletion.timeIntervalSince1970, + createdAt: Date().timeIntervalSince1970 + ) + entries[key] = entry + didChange = true + } + cursor = cursor.adding(months: 1).startOfMonth + } + } + } + // Ensure the target month entry exists. var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry( note: nil, @@ -123,6 +155,21 @@ enum MonthlyCheckInStore { return Date(timeIntervalSince1970: legacy) } + static func effectiveMonth( + for date: Date, + relativeTo referenceDate: Date = Date(), + graceDays: Int = 20 + ) -> Date { + let calendar = Calendar.current + if calendar.isDate(date, inSameDayAs: referenceDate) { + let day = calendar.component(.day, from: referenceDate) + if day <= graceDays { + return referenceDate.adding(months: -1).startOfMonth + } + } + return date.startOfMonth + } + static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats { let cutoff = referenceDate.endOfMonth let entries = allEntries().filter { $0.date <= cutoff } diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 47bd0d9..f6f20ae 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -85,9 +85,11 @@ class ChartsViewModel: ObservableObject { @Published var selectedChartType: ChartType = .evolution @Published var selectedCategory: Category? + @Published var selectedSource: InvestmentSource? @Published var selectedTimeRange: TimeRange = .year @Published var selectedAccount: Account? @Published var showAllAccounts = true + @Published var selectedBreakdown: BreakdownMode = .category @Published var evolutionData: [(date: Date, value: Decimal)] = [] @Published var categoryEvolutionData: [CategoryEvolutionPoint] = [] @@ -111,7 +113,8 @@ class ChartsViewModel: ObservableObject { case month = "1M" case quarter = "3M" case halfYear = "6M" - case year = "1Y" + case year = "12M" + case yearToDate = "YTD" case all = "All" var id: String { rawValue } @@ -122,9 +125,33 @@ class ChartsViewModel: ObservableObject { case .quarter: return 3 case .halfYear: return 6 case .year: return 12 + case .yearToDate: return nil case .all: return nil } } + + func startDate(referenceDate: Date = Date()) -> Date? { + switch self { + case .month, .quarter, .halfYear, .year: + guard let months else { return nil } + return referenceDate.adding(months: -months).startOfDay + case .yearToDate: + return referenceDate.startOfYear + case .all: + return nil + } + } + } + + enum BreakdownMode: String, CaseIterable, Identifiable { + case category = "By Category" + case source = "By Source" + + var id: String { rawValue } + } + + static func supportsAllocationTargets(for breakdown: BreakdownMode) -> Bool { + breakdown == .category } // MARK: - Dependencies @@ -147,8 +174,10 @@ class ChartsViewModel: ObservableObject { private var lastChartType: ChartType? private var lastTimeRange: TimeRange? private var lastCategoryId: UUID? + private var lastSourceId: UUID? private var lastAccountId: UUID? private var lastShowAllAccounts: Bool = true + private var lastBreakdown: BreakdownMode = .category private var cachedSnapshots: [Snapshot]? private var isUpdateInProgress = false @@ -179,9 +208,9 @@ class ChartsViewModel: ObservableObject { // Performance: Combine all selection changes into a single debounced stream // This prevents multiple rapid updates when switching between views Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount) - .combineLatest($showAllAccounts) + .combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts) .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) - .sink { [weak self] combined, showAll in + .sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in guard let self else { return } let (chartType, category, timeRange, _) = combined @@ -190,15 +219,19 @@ class ChartsViewModel: ObservableObject { let hasChanges = self.lastChartType != chartType || self.lastTimeRange != timeRange || self.lastCategoryId != category?.id || + self.lastSourceId != selectedSource?.id || self.lastAccountId != safeSelectedAccountId || - self.lastShowAllAccounts != showAll + self.lastShowAllAccounts != showAll || + self.lastBreakdown != selectedBreakdown if hasChanges { self.lastChartType = chartType self.lastTimeRange = timeRange self.lastCategoryId = category?.id + self.lastSourceId = selectedSource?.id self.lastAccountId = safeSelectedAccountId self.lastShowAllAccounts = showAll + self.lastBreakdown = selectedBreakdown self.cachedSnapshots = nil // Invalidate cache on meaningful changes self.updateChartData(chartType: chartType, category: category, timeRange: timeRange) } @@ -226,12 +259,25 @@ class ChartsViewModel: ObservableObject { } selectedChartType = chartType + let allowedRanges = availableTimeRanges(for: chartType) + if !allowedRanges.contains(selectedTimeRange) { + selectedTimeRange = allowedRanges.first ?? .year + } FirebaseService.shared.logChartViewed( chartType: chartType.rawValue, isPremium: chartType.isPremium ) } + func availableTimeRanges(for chartType: ChartType) -> [TimeRange] { + switch chartType { + case .evolution, .performance: + return [.all, .yearToDate, .year, .quarter] + default: + return [.month, .quarter, .halfYear, .year, .all] + } + } + private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) { // Performance: Prevent re-entrancy guard !isUpdateInProgress else { return } @@ -244,7 +290,9 @@ class ChartsViewModel: ObservableObject { } let sources: [InvestmentSource] - if let category = category { + if let selectedSource { + sources = sourceRepository.sources.filter { $0.id == selectedSource.id && shouldIncludeSource($0) } + } else if let category = category { sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) } } else { sources = sourceRepository.sources.filter { shouldIncludeSource($0) } @@ -266,6 +314,10 @@ class ChartsViewModel: ObservableObject { cachedSnapshots = snapshots } + if let cutoffDate = timeRange.startDate() { + snapshots = snapshots.filter { $0.date >= cutoffDate } + } + let completedSnapshots = filterSnapshotsForCharts( sources: sources, snapshots: snapshots @@ -281,10 +333,14 @@ class ChartsViewModel: ObservableObject { ) calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart) case .allocation: - calculateAllocationData(for: sources) + calculateAllocationData(for: sources, breakdown: selectedBreakdown) case .performance: let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) - calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource) + calculatePerformanceData( + for: sources, + snapshotsBySource: completedSnapshotsBySource, + breakdown: selectedBreakdown + ) case .contributions: calculateContributionsData(from: completedSnapshots) case .rollingReturn: @@ -306,6 +362,10 @@ class ChartsViewModel: ObservableObject { !availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { selectedCategory = nil } + if let selected = selectedSource, + !availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { + selectedSource = nil + } } func availableCategories( @@ -324,6 +384,19 @@ class ChartsViewModel: ObservableObject { } } + func availableSources( + for chartType: ChartType, + sources: [InvestmentSource]? = nil + ) -> [InvestmentSource] { + let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) } + switch chartType { + case .evolution, .allocation, .performance: + return relevantSources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + default: + return [] + } + } + private func shouldIncludeSource(_ source: InvestmentSource) -> Bool { if showAllAccounts || selectedAccount == nil { return true @@ -479,21 +552,33 @@ class ChartsViewModel: ObservableObject { categoryEvolutionData = points } - private func calculateAllocationData(for sources: [InvestmentSource]) { - let categories = categoryRepository.categories - let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } + private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) { + switch breakdown { + case .category: + let categories = categoryRepository.categories + let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } - allocationData = categories.compactMap { category in - let categorySources = valuesByCategory[category.id] ?? [] - let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } - guard categoryValue > 0 else { return nil } + allocationData = categories.compactMap { category in + let categorySources = valuesByCategory[category.id] ?? [] + let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } + guard categoryValue > 0 else { return nil } - return ( - category: category.name, - value: categoryValue, - color: category.colorHex - ) - }.sorted { $0.value > $1.value } + return ( + category: category.name, + value: categoryValue, + color: category.colorHex + ) + }.sorted { $0.value > $1.value } + case .source: + allocationData = sources.compactMap { source in + guard source.latestValue > 0 else { return nil } + return ( + category: source.name, + value: source.latestValue, + color: source.category?.colorHex ?? "#6B7280" + ) + }.sorted { $0.value > $1.value } + } } private func completedMonthKeys( @@ -565,36 +650,61 @@ class ChartsViewModel: ObservableObject { private func calculatePerformanceData( for sources: [InvestmentSource], - snapshotsBySource: [UUID: [Snapshot]] + snapshotsBySource: [UUID: [Snapshot]], + breakdown: BreakdownMode ) { - let categories = categoryRepository.categories - let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } + switch breakdown { + case .category: + let categories = categoryRepository.categories + let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } - performanceData = categories.compactMap { category in - let categorySources = sourcesByCategory[category.id] ?? [] - let snapshots = categorySources.compactMap { source -> [Snapshot]? in - let id = source.id - return snapshotsBySource[id] - }.flatMap { $0 } - guard snapshots.count >= 2 else { return nil } + performanceData = categories.compactMap { category in + let categorySources = sourcesByCategory[category.id] ?? [] + let snapshots = categorySources.compactMap { source -> [Snapshot]? in + let id = source.id + return snapshotsBySource[id] + }.flatMap { $0 } + guard snapshots.count >= 2 else { return nil } - let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) - guard let first = monthlyTotals.first, - let last = monthlyTotals.last, - first.totalValue > 0 else { return nil } - let cagr = calculationService.calculateCAGR( - startValue: first.totalValue, - endValue: last.totalValue, - startDate: first.date, - endDate: last.date - ) + let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) + guard let first = monthlyTotals.first, + let last = monthlyTotals.last, + first.totalValue > 0 else { return nil } + let cagr = calculationService.calculateCAGR( + startValue: first.totalValue, + endValue: last.totalValue, + startDate: first.date, + endDate: last.date + ) - return ( - category: category.name, - cagr: cagr, - color: category.colorHex - ) - }.sorted { $0.cagr > $1.cagr } + return ( + category: category.name, + cagr: cagr, + color: category.colorHex + ) + }.sorted { $0.cagr > $1.cagr } + case .source: + performanceData = sources.compactMap { source in + guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil } + let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) + guard let first = monthlyTotals.first, + let last = monthlyTotals.last, + first.totalValue > 0 else { return nil } + + let cagr = calculationService.calculateCAGR( + startValue: first.totalValue, + endValue: last.totalValue, + startDate: first.date, + endDate: last.date + ) + + return ( + category: source.name, + cagr: cagr, + color: source.category?.colorHex ?? "#6B7280" + ) + }.sorted { $0.cagr > $1.cagr } + } } private func calculateContributionsData(from snapshots: [Snapshot]) { diff --git a/PortfolioJournal/ViewModels/GoalsViewModel.swift b/PortfolioJournal/ViewModels/GoalsViewModel.swift index dd3be04..e7186fb 100644 --- a/PortfolioJournal/ViewModels/GoalsViewModel.swift +++ b/PortfolioJournal/ViewModels/GoalsViewModel.swift @@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject { return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue } + func isAchieved(_ goal: Goal) -> Bool { + Self.isAchieved(progress: progress(for: goal)) + } + + static func isAchieved(progress: Double) -> Bool { + progress >= 0.999 + } + + static func urgencyLevel( + targetDate: Date?, + isBehind: Bool, + isAchieved: Bool, + referenceDate: Date = Date() + ) -> GoalUrgencyLevel { + guard let targetDate else { return .normal } + guard !isAchieved else { return .normal } + guard isBehind else { return .normal } + + let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay) + if daysUntilTarget < 0 { + return .critical + } + return .warning + } + func totalValue(for goal: Goal) -> Decimal { if let accountId = goal.account?.safeId { return sourceRepository.sources @@ -288,3 +313,9 @@ struct GoalPaceStatus { let isBehind: Bool let statusText: String } + +enum GoalUrgencyLevel: Equatable { + case normal + case warning + case critical +} diff --git a/PortfolioJournal/Views/Charts/AllocationPieChart.swift b/PortfolioJournal/Views/Charts/AllocationPieChart.swift index 4e0c8b9..df6271c 100644 --- a/PortfolioJournal/Views/Charts/AllocationPieChart.swift +++ b/PortfolioJournal/Views/Charts/AllocationPieChart.swift @@ -3,6 +3,8 @@ import Charts struct AllocationPieChart: View { let data: [(category: String, value: Decimal, color: String)] + var title: String = "Asset Allocation" + var showsTargetsComparison: Bool = true @State private var selectedSlice: String? @@ -12,7 +14,7 @@ struct AllocationPieChart: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Asset Allocation") + Text(title) .font(.headline) if !data.isEmpty { @@ -98,7 +100,9 @@ struct AllocationPieChart: View { .frame(maxWidth: .infinity, alignment: .leading) } - AllocationTargetsComparisonChart(data: data) + if showsTargetsComparison { + AllocationTargetsComparisonChart(data: data) + } } else { Text("No allocation data available") .foregroundColor(.secondary) diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 787aa81..f329fad 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -23,7 +23,6 @@ struct ChartsContainerView: View { // Time Range Selector if viewModel.selectedChartType != .allocation && - viewModel.selectedChartType != .performance && viewModel.selectedChartType != .riskReturn { timeRangeSelector } @@ -34,6 +33,17 @@ struct ChartsContainerView: View { categoryFilter } + // Source Filter + if viewModel.selectedChartType == .evolution { + sourceFilter + } + + // Breakdown Selector + if viewModel.selectedChartType == .allocation || + viewModel.selectedChartType == .performance { + breakdownSelector + } + // Chart Content chartContent } @@ -101,7 +111,7 @@ struct ChartsContainerView: View { private var timeRangeSelector: some View { HStack(spacing: 8) { - ForEach(ChartsViewModel.TimeRange.allCases) { range in + ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in Button { viewModel.selectedTimeRange = range } label: { @@ -158,6 +168,7 @@ struct ChartsContainerView: View { ForEach(availableCategories) { category in Button { viewModel.selectedCategory = category + viewModel.selectedSource = nil } label: { HStack(spacing: 4) { Circle() @@ -186,6 +197,91 @@ struct ChartsContainerView: View { } } + // MARK: - Source Filter + + @ViewBuilder + private var sourceFilter: some View { + let availableSources = viewModel.availableSources(for: viewModel.selectedChartType) + if !availableSources.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if availableSources.count > 1 { + Button { + viewModel.selectedSource = nil + } label: { + Text("All Sources") + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + viewModel.selectedSource == nil + ? Color.appPrimary + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedSource == nil + ? .white + : .primary + ) + .cornerRadius(16) + } + } + + ForEach(availableSources, id: \.id) { source in + Button { + viewModel.selectedSource = source + viewModel.selectedCategory = nil + } label: { + Text(source.name) + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + viewModel.selectedSource?.id == source.id + ? Color.appPrimary + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedSource?.id == source.id + ? .white + : .primary + ) + .cornerRadius(16) + } + } + } + } + } + } + + // MARK: - Breakdown Selector + + private var breakdownSelector: some View { + HStack(spacing: 8) { + ForEach(ChartsViewModel.BreakdownMode.allCases) { mode in + Button { + viewModel.selectedBreakdown = mode + } label: { + Text(mode.rawValue) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + viewModel.selectedBreakdown == mode + ? Color.appPrimary + : Color.gray.opacity(0.1) + ) + .foregroundColor( + viewModel.selectedBreakdown == mode + ? .white + : .primary + ) + .cornerRadius(18) + } + } + } + } + // MARK: - Chart Content @ViewBuilder @@ -204,9 +300,16 @@ struct ChartsContainerView: View { goals: goalsViewModel.goals ) case .allocation: - AllocationPieChart(data: viewModel.allocationData) + AllocationPieChart( + data: viewModel.allocationData, + title: viewModel.selectedBreakdown == .source ? "Allocation by Source" : "Asset Allocation", + showsTargetsComparison: ChartsViewModel.supportsAllocationTargets(for: viewModel.selectedBreakdown) + ) case .performance: - PerformanceBarChart(data: viewModel.performanceData) + PerformanceBarChart( + data: viewModel.performanceData, + title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category" + ) case .contributions: ContributionsChartView(data: viewModel.contributionsData) case .rollingReturn: diff --git a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift index f5eced4..44dfb69 100644 --- a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift +++ b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift @@ -3,10 +3,11 @@ import Charts struct PerformanceBarChart: View { let data: [(category: String, cagr: Double, color: String)] + var title: String = "Performance by Category" var body: some View { VStack(alignment: .leading, spacing: 16) { - Text("Performance by Category") + Text(title) .font(.headline) Text("Compound Annual Growth Rate (CAGR)") diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index e8d31ae..28b901f 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -160,7 +160,18 @@ struct DashboardView: View { yearChange: viewModel.portfolioSummary.formattedYearChange, sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn, isYearPositive: viewModel.isYearChangePositive, - isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0 + isSinceInceptionPositive: viewModel.portfolioSummary.allTimeReturn >= 0, + onShareTap: { + ShareService.shared.sharePortfolioValue( + totalValue: viewModel.portfolioSummary.formattedTotalValue, + changeText: calmModeEnabled + ? "\(viewModel.latestPortfolioChange.formattedAbsolute) (\(viewModel.latestPortfolioChange.formattedPercentage))" + : viewModel.portfolioSummary.formattedDayChange, + changeLabel: calmModeEnabled ? "since last check-in" : "today", + yearChange: viewModel.portfolioSummary.formattedYearChange, + sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn + ) + } ) } case .monthlyCheckIn: @@ -210,11 +221,12 @@ struct DashboardView: View { CategoryBreakdownCard(categories: viewModel.topCategories) } case .goals: + let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) } if config.isCollapsed { - CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active") + CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active") } else { GoalsSummaryCard( - goals: goalsViewModel.goals, + goals: homeGoals, progressProvider: goalsViewModel.progress(for:), currentValueProvider: goalsViewModel.totalValue(for:), paceStatusProvider: goalsViewModel.paceStatus(for:), @@ -266,12 +278,24 @@ struct TotalValueCard: View { var sinceInceptionChange: String? var isYearPositive: Bool = true var isSinceInceptionPositive: Bool = true + var onShareTap: (() -> Void)? var body: some View { VStack(spacing: 8) { - Text("Total Portfolio Value") - .font(.subheadline) - .foregroundColor(.white.opacity(0.85)) + ZStack(alignment: .trailing) { + Text("Total Portfolio Value") + .font(.headline.weight(.semibold)) + .foregroundColor(.white.opacity(0.85)) + + if let onShareTap { + Button(action: onShareTap) { + Image(systemName: "square.and.arrow.up") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white.opacity(0.9)) + } + .padding(.trailing, 8) + } + } Text(totalValue) .font(.system(size: 42, weight: .bold, design: .rounded)) @@ -423,13 +447,6 @@ struct MonthlyCheckInCard: View { Spacer() - NavigationLink { - AchievementsView(referenceDate: Date()) - } label: { - Image(systemName: "trophy.fill") - } - .font(.subheadline.weight(.semibold)) - if let reminderDate { Button { let title = String( @@ -1053,6 +1070,12 @@ struct GoalsSummaryCard: View { ForEach(goals.prefix(2)) { goal in let currentValue = currentValueProvider(goal) let paceStatus = paceStatusProvider(goal) + let isAchieved = GoalsViewModel.isAchieved(progress: progressProvider(goal)) + let targetUrgency = GoalsViewModel.urgencyLevel( + targetDate: goal.targetDate, + isBehind: paceStatus?.isBehind ?? false, + isAchieved: isAchieved + ) VStack(alignment: .leading, spacing: 6) { HStack { Text(goal.name) @@ -1083,6 +1106,12 @@ struct GoalsSummaryCard: View { .foregroundColor(.secondary) } + if let targetDate = goal.targetDate { + Text("Target: \(targetDate.mediumDateString)") + .font(.caption2.weight(.semibold)) + .foregroundColor(targetUrgency == .critical ? .negativeRed : (targetUrgency == .warning ? .appWarning : .secondary)) + } + if let etaText = etaProvider(goal) { Text(etaText) .font(.caption2) diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index b54d5e7..a6e657f 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -1,6 +1,7 @@ import SwiftUI struct MonthlyCheckInView: View { + @Environment(\.openURL) private var openURL @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel = MonthlyCheckInViewModel() let referenceDate: Date @@ -13,6 +14,8 @@ struct MonthlyCheckInView: View { @State private var editingSnapshot: Snapshot? @State private var addingSource: InvestmentSource? @State private var didApplyDuplicate = false + @State private var showAchievementSatisfactionDialog = false + @State private var showAppStoreReviewAlert = false init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) { self.referenceDate = referenceDate @@ -56,7 +59,7 @@ struct MonthlyCheckInView: View { } private var canAddNewCheckIn: Bool { - lastCompletionDate == nil || checkInProgress >= 0.7 + true } var body: some View { @@ -119,11 +122,36 @@ struct MonthlyCheckInView: View { .onChange(of: selectedMood) { _, newValue in MonthlyCheckInStore.setMood(newValue, for: referenceDate) } + .confirmationDialog( + "How much are you enjoying Portfolio Journal?", + isPresented: $showAchievementSatisfactionDialog, + titleVisibility: .visible + ) { + ForEach(1...5, id: \.self) { value in + Button("\(value) Star\(value > 1 ? "s" : "")") { + if value == 5 { + showAppStoreReviewAlert = true + } + } + } + Button("Not Now", role: .cancel) {} + } message: { + Text("Congrats on your new achievement! Your feedback helps us improve.") + } + .alert("Would you like to leave an App Store review?", isPresented: $showAppStoreReviewAlert) { + Button("Not Now", role: .cancel) {} + Button("Write a Review") { + ReviewPromptService.shared.markStoreReviewCompleted() + openURL(ReviewPromptService.appStoreWriteReviewURL()) + } + } message: { + Text("Thanks for the 5 stars. It really helps other investors discover the app.") + } } private var headerCard: some View { VStack(alignment: .leading, spacing: 8) { - Text("This Month") + Text(monthLabel) .font(.headline) if let date = lastCompletionDate { @@ -162,6 +190,7 @@ struct MonthlyCheckInView: View { } Button { + let previousUnlockedAchievementKeys = unlockedAchievementKeys() let now = Date() let completionDate = referenceDate.isSameMonth(as: now) ? now @@ -169,6 +198,12 @@ struct MonthlyCheckInView: View { MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate) ReviewPromptService.shared.recordMonthlyCheckInCompleted() viewModel.refresh() + let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys) + if ReviewPromptService.shared.shouldAskForAchievementSatisfaction( + newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys + ) { + showAchievementSatisfactionDialog = true + } } label: { Text("Mark Check-in Complete") .font(.subheadline.weight(.semibold)) @@ -178,12 +213,6 @@ struct MonthlyCheckInView: View { .cornerRadius(AppConstants.UI.cornerRadius) } .disabled(!canAddNewCheckIn) - - if !canAddNewCheckIn { - Text("Editing stays open. New check-ins unlock after 70% of the month.") - .font(.caption) - .foregroundColor(.secondary) - } } .padding() .background(Color(.systemBackground)) @@ -191,6 +220,27 @@ struct MonthlyCheckInView: View { .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + private var monthLabel: String { + Self.monthLabel(for: referenceDate, relativeTo: Date(), locale: .current) + } + + private func unlockedAchievementKeys() -> Set { + Set( + MonthlyCheckInStore + .achievementStatuses(referenceDate: referenceDate) + .filter(\.isUnlocked) + .map(\.id) + ) + } + + static func monthLabel(for date: Date, relativeTo referenceDate: Date, locale: Locale) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "LLLL yyyy" + formatter.locale = locale + let effectiveMonth = MonthlyCheckInStore.effectiveMonth(for: date, relativeTo: referenceDate) + return formatter.string(from: effectiveMonth) + } + private var reflectionCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -457,11 +507,7 @@ struct MonthlyCheckInView: View { private func shareMonthlyCheckIn() { let summary = viewModel.monthlySummary - let text = ShareService.buildMonthlyCheckInShareText( - summary: summary, - appName: appDisplayName - ) - ShareService.shared.shareText(text) + ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName) } private var appDisplayName: String { diff --git a/PortfolioJournal/Views/Dashboard/ShareCards.swift b/PortfolioJournal/Views/Dashboard/ShareCards.swift new file mode 100644 index 0000000..5ee7430 --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/ShareCards.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct MonthlyCheckInShareCardView: View { + let summary: MonthlySummary + let appName: String + var qrCodeImage: UIImage? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(summary.periodLabel) + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.85)) + + Text("Monthly Check-in") + .font(.title2.weight(.bold)) + .foregroundColor(.white) + + metricRow("Starting", summary.formattedStartingValue) + metricRow("Ending", summary.formattedEndingValue) + metricRow("Contributions", summary.formattedContributions) + metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))") + + Spacer(minLength: 0) + shareFooter(appName: appName, qrCodeImage: qrCodeImage) + } + .padding(20) + .frame(width: 320, height: 300) + .background(LinearGradient.appPrimaryGradient) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } +} + +struct PortfolioValueShareCardView: View { + let totalValue: String + let changeText: String + let changeLabel: String + let yearChange: String? + let sinceInceptionChange: String? + let appName: String + var qrCodeImage: UIImage? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Portfolio Snapshot") + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.85)) + + Text("Total Portfolio Value") + .font(.headline.weight(.semibold)) + .foregroundColor(.white.opacity(0.9)) + + Text(totalValue) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + Text("\(changeText) \(changeLabel)") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white.opacity(0.9)) + + if let yearChange { + metricRow("YoY", yearChange) + } + + if let sinceInceptionChange { + metricRow("Since inception", sinceInceptionChange) + } + + Spacer(minLength: 0) + shareFooter(appName: appName, qrCodeImage: qrCodeImage) + } + .padding(20) + .frame(width: 320, height: 290) + .background( + LinearGradient( + colors: [Color.appSecondary, Color.appPrimary], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } +} + +private func metricRow(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundColor(.white.opacity(0.8)) + Spacer() + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white) + .multilineTextAlignment(.trailing) + } +} + +private func shareFooter(appName: String, qrCodeImage: UIImage?) -> some View { + VStack(spacing: 10) { + Rectangle() + .fill(Color.white.opacity(0.2)) + .frame(height: 1) + + HStack(spacing: 12) { + Image("BrandMark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text("Powered by") + .font(.caption2.weight(.medium)) + .foregroundColor(.white.opacity(0.7)) + Text(appName) + .font(.subheadline.weight(.bold)) + .foregroundColor(.white) + } + + Spacer() + + if let qrCodeImage { + Image(uiImage: qrCodeImage) + .interpolation(.none) + .resizable() + .frame(width: 48, height: 48) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } else { + Image(systemName: "qrcode") + .font(.title3) + .foregroundColor(.white.opacity(0.9)) + } + } + } +} diff --git a/PortfolioJournal/Views/Goals/GoalsView.swift b/PortfolioJournal/Views/Goals/GoalsView.swift index f3b2f70..99d701b 100644 --- a/PortfolioJournal/Views/Goals/GoalsView.swift +++ b/PortfolioJournal/Views/Goals/GoalsView.swift @@ -5,6 +5,7 @@ struct GoalsView: View { @StateObject private var viewModel = GoalsViewModel() @State private var showingAddGoal = false @State private var editingGoal: Goal? + @State private var showAchievedGoals = false var body: some View { NavigationStack { @@ -12,11 +13,15 @@ struct GoalsView: View { AppBackground() List { - if viewModel.goals.isEmpty { - emptyState + if filteredGoals.isEmpty { + if viewModel.goals.isEmpty { + emptyState + } else { + hiddenAchievedState + } } else { Section { - ForEach(viewModel.goals) { goal in + ForEach(filteredGoals) { goal in GoalRowView( goal: goal, progress: viewModel.progress(for: goal), @@ -48,6 +53,12 @@ struct GoalsView: View { } .navigationTitle("Goals") .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") { + showAchievedGoals.toggle() + } + .font(.caption.weight(.semibold)) + } ToolbarItem(placement: .navigationBarTrailing) { Button { showingAddGoal = true @@ -78,6 +89,11 @@ struct GoalsView: View { } } + private var filteredGoals: [Goal] { + guard !showAchievedGoals else { return viewModel.goals } + return viewModel.goals.filter { !viewModel.isAchieved($0) } + } + private var emptyState: some View { VStack(spacing: 16) { Image(systemName: "target") @@ -93,6 +109,22 @@ struct GoalsView: View { .frame(maxWidth: .infinity) .padding(.vertical, 32) } + + private var hiddenAchievedState: some View { + VStack(spacing: 12) { + Image(systemName: "party.popper") + .font(.system(size: 40)) + .foregroundColor(.appSecondary) + Text("All visible goals are achieved") + .font(.headline) + Text("Tap \"Show Achieved\" to review completed goals.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } } struct GoalRowView: View { @@ -105,14 +137,53 @@ struct GoalRowView: View { @State private var showingShareOptions = false + private var isAchieved: Bool { + GoalsViewModel.isAchieved(progress: progress) + } + + private var targetUrgency: GoalUrgencyLevel { + GoalsViewModel.urgencyLevel( + targetDate: goal.targetDate, + isBehind: paceStatus?.isBehind ?? false, + isAchieved: isAchieved + ) + } + + private var targetDateColor: Color { + switch targetUrgency { + case .normal: + return .secondary + case .warning: + return .appWarning + case .critical: + return .negativeRed + } + } + var body: some View { ZStack(alignment: .topTrailing) { Button(action: onEdit) { VStack(alignment: .leading, spacing: 12) { - Text(goal.name) - .font(.headline) + HStack { + Text(goal.name) + .font(.headline) + .foregroundColor(isAchieved ? .appSecondary : .primary) + if isAchieved { + Text("Achieved") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.appSecondary) + .clipShape(Capsule()) + } + } - GoalProgressBar(progress: progress, tint: .appSecondary, iconColor: .appSecondary) + GoalProgressBar( + progress: progress, + tint: isAchieved ? .appSuccess : .appSecondary, + iconColor: isAchieved ? .appSuccess : .appSecondary + ) HStack { Text(totalValue.currencyString) @@ -126,16 +197,25 @@ struct GoalRowView: View { if let targetDate = goal.targetDate { Text("Target date: \(targetDate.mediumDateString)") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(targetDateColor) } if let paceStatus { Text(paceStatus.statusText) .font(.caption.weight(.semibold)) - .foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen) + .foregroundColor( + isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen) + ) } } .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(isAchieved ? Color.appSuccess.opacity(0.10) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isAchieved ? Color.appSuccess.opacity(0.35) : Color.clear, lineWidth: 1) + ) + .cornerRadius(12) } .buttonStyle(.plain) diff --git a/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift b/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift index 6c3c93e..7daca29 100644 --- a/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift +++ b/PortfolioJournalTests/Services/ReviewPromptServiceTests.swift @@ -51,4 +51,40 @@ final class ReviewPromptServiceTests: XCTestCase { XCTAssertFalse(didRequest) } + + func testAchievementPromptTriggersForNewUnlockWhenNotReviewed() { + let service = ReviewPromptService( + userDefaults: defaults, + dateProvider: Date.init, + reviewRequestHandler: {} + ) + + let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"]) + + XCTAssertTrue(shouldAsk) + } + + func testAchievementPromptDoesNotTriggerTwiceForSameAchievement() { + let service = ReviewPromptService( + userDefaults: defaults, + dateProvider: Date.init, + reviewRequestHandler: {} + ) + + XCTAssertTrue(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"])) + XCTAssertFalse(service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_3"])) + } + + func testAchievementPromptDoesNotTriggerAfterStoreReviewCompleted() { + let service = ReviewPromptService( + userDefaults: defaults, + dateProvider: Date.init, + reviewRequestHandler: {} + ) + service.markStoreReviewCompleted() + + let shouldAsk = service.shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: ["streak_6"]) + + XCTAssertFalse(shouldAsk) + } } diff --git a/PortfolioJournalTests/Services/ShareServiceTests.swift b/PortfolioJournalTests/Services/ShareServiceTests.swift index 8832d4f..5aeee59 100644 --- a/PortfolioJournalTests/Services/ShareServiceTests.swift +++ b/PortfolioJournalTests/Services/ShareServiceTests.swift @@ -25,4 +25,22 @@ final class ShareServiceTests: XCTestCase { XCTAssertTrue(text.contains("Net performance:")) XCTAssertTrue(text.contains("Shared from Portfolio Journal")) } + + func testBuildPortfolioValueShareText() { + let text = ShareService.buildPortfolioValueShareText( + totalValue: "€120,000", + changeText: "+€2,500 (+2.1%)", + changeLabel: "since last check-in", + yearChange: "+€8,000 (+7.1%)", + sinceInceptionChange: "+€24,000 (+25.0%)", + appName: "Portfolio Journal" + ) + + XCTAssertTrue(text.contains("Total Portfolio Value")) + XCTAssertTrue(text.contains("€120,000")) + XCTAssertTrue(text.contains("+€2,500 (+2.1%) since last check-in")) + XCTAssertTrue(text.contains("YoY: +€8,000 (+7.1%)")) + XCTAssertTrue(text.contains("Since inception: +€24,000 (+25.0%)")) + XCTAssertTrue(text.contains("Shared from Portfolio Journal")) + } } diff --git a/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift b/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift new file mode 100644 index 0000000..a37462b --- /dev/null +++ b/PortfolioJournalTests/Utilities/MonthlyCheckInStoreTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import PortfolioJournal + +final class MonthlyCheckInStoreTests: XCTestCase { + override func setUp() { + super.setUp() + MonthlyCheckInStore.clearAll() + } + + override func tearDown() { + MonthlyCheckInStore.clearAll() + super.tearDown() + } + + func testEffectiveMonthUsesPreviousMonthWithinGracePeriod() { + let calendar = Calendar(identifier: .gregorian) + let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 20))! + let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20) + + let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))! + XCTAssertEqual(effective, expected) + } + + func testEffectiveMonthUsesCurrentMonthAfterGracePeriod() { + let calendar = Calendar(identifier: .gregorian) + let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))! + let effective = MonthlyCheckInStore.effectiveMonth(for: referenceDate, relativeTo: referenceDate, graceDays: 20) + + let expected = calendar.date(from: DateComponents(year: 2026, month: 2, day: 1))! + XCTAssertEqual(effective, expected) + } + + func testCompletionAfterGraceAutoFillsMissingMonths() { + let calendar = Calendar(identifier: .gregorian) + let decemberDate = calendar.date(from: DateComponents(year: 2025, month: 12, day: 15))! + let decemberCompletion = calendar.date(from: DateComponents(year: 2025, month: 12, day: 31))! + MonthlyCheckInStore.setNote("Carry", for: decemberDate) + MonthlyCheckInStore.setRating(4, for: decemberDate) + MonthlyCheckInStore.setMood(.balanced, for: decemberDate) + MonthlyCheckInStore.setCompletionDate(decemberCompletion, for: decemberDate) + + let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 21))! + MonthlyCheckInStore.setCompletionDate(completionDate, for: completionDate) + + let januaryDate = calendar.date(from: DateComponents(year: 2026, month: 1, day: 5))! + let januaryEntry = MonthlyCheckInStore.entry(for: januaryDate) + XCTAssertEqual(januaryEntry?.note, "Carry") + XCTAssertEqual(januaryEntry?.rating, 4) + XCTAssertEqual(januaryEntry?.mood, .balanced) + XCTAssertNotNil(januaryEntry?.completionDate) + } + + func testSetCompletionDateForVeryOldMonthStillCompletesEntry() { + let calendar = Calendar(identifier: .gregorian) + let oldMonthDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 10))! + let completionDate = calendar.date(from: DateComponents(year: 2026, month: 2, day: 22))! + + MonthlyCheckInStore.setCompletionDate(completionDate, for: oldMonthDate) + + let entry = MonthlyCheckInStore.entry(for: oldMonthDate) + XCTAssertNotNil(entry?.completionDate) + } +} diff --git a/PortfolioJournalTests/ViewModels/ChartsViewModelDisplayRulesTests.swift b/PortfolioJournalTests/ViewModels/ChartsViewModelDisplayRulesTests.swift new file mode 100644 index 0000000..433ef1e --- /dev/null +++ b/PortfolioJournalTests/ViewModels/ChartsViewModelDisplayRulesTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import PortfolioJournal + +@MainActor +final class ChartsViewModelDisplayRulesTests: XCTestCase { + func testAllocationTargetsSupportedOnlyForCategoryBreakdown() { + XCTAssertTrue(ChartsViewModel.supportsAllocationTargets(for: .category)) + XCTAssertFalse(ChartsViewModel.supportsAllocationTargets(for: .source)) + } + + func testEvolutionTimeRangesIncludeRequestedOptions() { + let viewModel = ChartsViewModel(iapService: IAPService()) + let ranges = viewModel.availableTimeRanges(for: .evolution) + + XCTAssertEqual(ranges, [.all, .yearToDate, .year, .quarter]) + } + + func testYearToDateRangeStartsAtBeginningOfYear() { + let calendar = Calendar(identifier: .gregorian) + let referenceDate = calendar.date(from: DateComponents(year: 2026, month: 8, day: 15))! + let startDate = ChartsViewModel.TimeRange.yearToDate.startDate(referenceDate: referenceDate) + let expected = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))! + + XCTAssertEqual(startDate, expected) + } +} diff --git a/PortfolioJournalTests/ViewModels/GoalsDisplayRulesTests.swift b/PortfolioJournalTests/ViewModels/GoalsDisplayRulesTests.swift new file mode 100644 index 0000000..68a0160 --- /dev/null +++ b/PortfolioJournalTests/ViewModels/GoalsDisplayRulesTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import PortfolioJournal + +@MainActor +final class GoalsDisplayRulesTests: XCTestCase { + func testIsAchievedProgressThreshold() { + XCTAssertFalse(GoalsViewModel.isAchieved(progress: 0.998)) + XCTAssertTrue(GoalsViewModel.isAchieved(progress: 0.999)) + XCTAssertTrue(GoalsViewModel.isAchieved(progress: 1.0)) + } + + func testUrgencyLevelIsCriticalWhenBehindAndPastTargetDate() { + let targetDate = Date(timeIntervalSince1970: 1_000_000) + let referenceDate = Date(timeIntervalSince1970: 1_100_000) + + let level = GoalsViewModel.urgencyLevel( + targetDate: targetDate, + isBehind: true, + isAchieved: false, + referenceDate: referenceDate + ) + + XCTAssertEqual(level, .critical) + } + + func testUrgencyLevelIsWarningWhenBehindBeforeTargetDate() { + let targetDate = Date(timeIntervalSince1970: 1_100_000) + let referenceDate = Date(timeIntervalSince1970: 1_000_000) + + let level = GoalsViewModel.urgencyLevel( + targetDate: targetDate, + isBehind: true, + isAchieved: false, + referenceDate: referenceDate + ) + + XCTAssertEqual(level, .warning) + } + + func testUrgencyLevelIsNormalForAchievedGoals() { + let level = GoalsViewModel.urgencyLevel( + targetDate: Date(), + isBehind: true, + isAchieved: true, + referenceDate: Date().addingTimeInterval(86_400) + ) + + XCTAssertEqual(level, .normal) + } +} diff --git a/PortfolioJournalTests/Views/MonthlyCheckInViewTests.swift b/PortfolioJournalTests/Views/MonthlyCheckInViewTests.swift new file mode 100644 index 0000000..5df0496 --- /dev/null +++ b/PortfolioJournalTests/Views/MonthlyCheckInViewTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import PortfolioJournal + +final class MonthlyCheckInViewTests: XCTestCase { + func testMonthLabelUsesMonthAndYear() { + var components = DateComponents() + components.year = 2026 + components.month = 2 + components.day = 1 + let date = Calendar(identifier: .gregorian).date(from: components)! + + let label = MonthlyCheckInView.monthLabel( + for: date, + relativeTo: date, + locale: Locale(identifier: "en_US_POSIX") + ) + + XCTAssertEqual(label, "February 2026") + } + + func testMonthLabelUsesPreviousMonthDuringGracePeriod() { + var components = DateComponents() + components.year = 2026 + components.month = 2 + components.day = 20 + let date = Calendar(identifier: .gregorian).date(from: components)! + + let label = MonthlyCheckInView.monthLabel( + for: date, + relativeTo: date, + locale: Locale(identifier: "en_US_POSIX") + ) + + XCTAssertEqual(label, "January 2026") + } +}