1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-02-11 23:37:11 +01:00
parent daaca95913
commit 4761e2e5c8
19 changed files with 1118 additions and 90 deletions
@@ -40,7 +40,7 @@ extension Snapshot {
} }
let newId = UUID() let newId = UUID()
setPrimitiveValue(newId, forKey: "id") setPrimitiveValue(newId, forKey: "id")
return newId return newId
} }
var safeDate: Date { var safeDate: Date {
@@ -6,6 +6,8 @@ final class ReviewPromptService {
private let lastPromptKey = "reviewPromptLastDate" private let lastPromptKey = "reviewPromptLastDate"
private let checkInCountKey = "reviewPromptCheckinCount" private let checkInCountKey = "reviewPromptCheckinCount"
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
private let minCheckInsBetweenPrompts = 3 private let minCheckInsBetweenPrompts = 3
private let minDaysBetweenPrompts = 90 private let minDaysBetweenPrompts = 90
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
@@ -34,7 +36,40 @@ final class ReviewPromptService {
requestReviewIfEligible() requestReviewIfEligible()
} }
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> 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() { private func requestReviewIfEligible() {
guard !hasCompletedStoreReview else { return }
let now = dateProvider() let now = dateProvider()
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date { if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400 let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
@@ -1,5 +1,7 @@
import Foundation import Foundation
import UIKit import UIKit
import SwiftUI
import LinkPresentation
class ShareService { class ShareService {
static let shared = 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) { func shareText(_ content: String) {
guard let viewController = ShareService.topViewController() else { return } guard let viewController = ShareService.topViewController() else { return }
@@ -41,6 +118,36 @@ class ShareService {
} }
} }
@MainActor
private func shareCard<Content: View>(
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) { func shareTextFile(content: String, fileName: String) {
guard let viewController = ShareService.topViewController() else { return } guard let viewController = ShareService.topViewController() else { return }
@@ -144,4 +251,67 @@ class ShareService {
.replacingOccurrences(of: ";", with: "\\;") .replacingOccurrences(of: ";", with: "\\;")
.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
}
} }
@@ -5,6 +5,7 @@ enum MonthlyCheckInStore {
private static let completionsKey = "monthlyCheckInCompletions" private static let completionsKey = "monthlyCheckInCompletions"
private static let legacyLastCheckInKey = "lastCheckInDate" private static let legacyLastCheckInKey = "lastCheckInDate"
private static let entriesKey = "monthlyCheckInEntries" private static let entriesKey = "monthlyCheckInEntries"
private static let graceDays = 20
// MARK: - Public Accessors // MARK: - Public Accessors
@@ -44,7 +45,7 @@ enum MonthlyCheckInStore {
} }
static func monthKey(for date: Date) -> String { 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)] { static func allNotes() -> [(date: Date, note: String)] {
@@ -74,11 +75,42 @@ enum MonthlyCheckInStore {
} }
static func setCompletionDate(_ completionDate: Date, for month: Date) { 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) let targetKey = monthKey(for: targetMonth)
var entries = loadEntries() var entries = loadEntries()
var didChange = false 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. // Ensure the target month entry exists.
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry( var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry(
note: nil, note: nil,
@@ -123,6 +155,21 @@ enum MonthlyCheckInStore {
return Date(timeIntervalSince1970: legacy) 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 { static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
let cutoff = referenceDate.endOfMonth let cutoff = referenceDate.endOfMonth
let entries = allEntries().filter { $0.date <= cutoff } let entries = allEntries().filter { $0.date <= cutoff }
+156 -46
View File
@@ -85,9 +85,11 @@ class ChartsViewModel: ObservableObject {
@Published var selectedChartType: ChartType = .evolution @Published var selectedChartType: ChartType = .evolution
@Published var selectedCategory: Category? @Published var selectedCategory: Category?
@Published var selectedSource: InvestmentSource?
@Published var selectedTimeRange: TimeRange = .year @Published var selectedTimeRange: TimeRange = .year
@Published var selectedAccount: Account? @Published var selectedAccount: Account?
@Published var showAllAccounts = true @Published var showAllAccounts = true
@Published var selectedBreakdown: BreakdownMode = .category
@Published var evolutionData: [(date: Date, value: Decimal)] = [] @Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = [] @Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
@@ -111,7 +113,8 @@ class ChartsViewModel: ObservableObject {
case month = "1M" case month = "1M"
case quarter = "3M" case quarter = "3M"
case halfYear = "6M" case halfYear = "6M"
case year = "1Y" case year = "12M"
case yearToDate = "YTD"
case all = "All" case all = "All"
var id: String { rawValue } var id: String { rawValue }
@@ -122,9 +125,33 @@ class ChartsViewModel: ObservableObject {
case .quarter: return 3 case .quarter: return 3
case .halfYear: return 6 case .halfYear: return 6
case .year: return 12 case .year: return 12
case .yearToDate: return nil
case .all: 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 // MARK: - Dependencies
@@ -147,8 +174,10 @@ class ChartsViewModel: ObservableObject {
private var lastChartType: ChartType? private var lastChartType: ChartType?
private var lastTimeRange: TimeRange? private var lastTimeRange: TimeRange?
private var lastCategoryId: UUID? private var lastCategoryId: UUID?
private var lastSourceId: UUID?
private var lastAccountId: UUID? private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true private var lastShowAllAccounts: Bool = true
private var lastBreakdown: BreakdownMode = .category
private var cachedSnapshots: [Snapshot]? private var cachedSnapshots: [Snapshot]?
private var isUpdateInProgress = false private var isUpdateInProgress = false
@@ -179,9 +208,9 @@ class ChartsViewModel: ObservableObject {
// Performance: Combine all selection changes into a single debounced stream // Performance: Combine all selection changes into a single debounced stream
// This prevents multiple rapid updates when switching between views // This prevents multiple rapid updates when switching between views
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount) Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
.combineLatest($showAllAccounts) .combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) .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 } guard let self else { return }
let (chartType, category, timeRange, _) = combined let (chartType, category, timeRange, _) = combined
@@ -190,15 +219,19 @@ class ChartsViewModel: ObservableObject {
let hasChanges = self.lastChartType != chartType || let hasChanges = self.lastChartType != chartType ||
self.lastTimeRange != timeRange || self.lastTimeRange != timeRange ||
self.lastCategoryId != category?.id || self.lastCategoryId != category?.id ||
self.lastSourceId != selectedSource?.id ||
self.lastAccountId != safeSelectedAccountId || self.lastAccountId != safeSelectedAccountId ||
self.lastShowAllAccounts != showAll self.lastShowAllAccounts != showAll ||
self.lastBreakdown != selectedBreakdown
if hasChanges { if hasChanges {
self.lastChartType = chartType self.lastChartType = chartType
self.lastTimeRange = timeRange self.lastTimeRange = timeRange
self.lastCategoryId = category?.id self.lastCategoryId = category?.id
self.lastSourceId = selectedSource?.id
self.lastAccountId = safeSelectedAccountId self.lastAccountId = safeSelectedAccountId
self.lastShowAllAccounts = showAll self.lastShowAllAccounts = showAll
self.lastBreakdown = selectedBreakdown
self.cachedSnapshots = nil // Invalidate cache on meaningful changes self.cachedSnapshots = nil // Invalidate cache on meaningful changes
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange) self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
} }
@@ -226,12 +259,25 @@ class ChartsViewModel: ObservableObject {
} }
selectedChartType = chartType selectedChartType = chartType
let allowedRanges = availableTimeRanges(for: chartType)
if !allowedRanges.contains(selectedTimeRange) {
selectedTimeRange = allowedRanges.first ?? .year
}
FirebaseService.shared.logChartViewed( FirebaseService.shared.logChartViewed(
chartType: chartType.rawValue, chartType: chartType.rawValue,
isPremium: chartType.isPremium 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) { private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
// Performance: Prevent re-entrancy // Performance: Prevent re-entrancy
guard !isUpdateInProgress else { return } guard !isUpdateInProgress else { return }
@@ -244,7 +290,9 @@ class ChartsViewModel: ObservableObject {
} }
let sources: [InvestmentSource] 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) } sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
} else { } else {
sources = sourceRepository.sources.filter { shouldIncludeSource($0) } sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
@@ -266,6 +314,10 @@ class ChartsViewModel: ObservableObject {
cachedSnapshots = snapshots cachedSnapshots = snapshots
} }
if let cutoffDate = timeRange.startDate() {
snapshots = snapshots.filter { $0.date >= cutoffDate }
}
let completedSnapshots = filterSnapshotsForCharts( let completedSnapshots = filterSnapshotsForCharts(
sources: sources, sources: sources,
snapshots: snapshots snapshots: snapshots
@@ -281,10 +333,14 @@ class ChartsViewModel: ObservableObject {
) )
calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart) calculateCategoryEvolutionData(from: completedSnapshots, categories: categoriesForChart)
case .allocation: case .allocation:
calculateAllocationData(for: sources) calculateAllocationData(for: sources, breakdown: selectedBreakdown)
case .performance: case .performance:
let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots)
calculatePerformanceData(for: sources, snapshotsBySource: completedSnapshotsBySource) calculatePerformanceData(
for: sources,
snapshotsBySource: completedSnapshotsBySource,
breakdown: selectedBreakdown
)
case .contributions: case .contributions:
calculateContributionsData(from: completedSnapshots) calculateContributionsData(from: completedSnapshots)
case .rollingReturn: case .rollingReturn:
@@ -306,6 +362,10 @@ class ChartsViewModel: ObservableObject {
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) { !availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
selectedCategory = nil selectedCategory = nil
} }
if let selected = selectedSource,
!availableSources(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
selectedSource = nil
}
} }
func availableCategories( 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 { private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
if showAllAccounts || selectedAccount == nil { if showAllAccounts || selectedAccount == nil {
return true return true
@@ -479,21 +552,33 @@ class ChartsViewModel: ObservableObject {
categoryEvolutionData = points categoryEvolutionData = points
} }
private func calculateAllocationData(for sources: [InvestmentSource]) { private func calculateAllocationData(for sources: [InvestmentSource], breakdown: BreakdownMode) {
let categories = categoryRepository.categories switch breakdown {
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } case .category:
let categories = categoryRepository.categories
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
allocationData = categories.compactMap { category in allocationData = categories.compactMap { category in
let categorySources = valuesByCategory[category.id] ?? [] let categorySources = valuesByCategory[category.id] ?? []
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue } let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
guard categoryValue > 0 else { return nil } guard categoryValue > 0 else { return nil }
return ( return (
category: category.name, category: category.name,
value: categoryValue, value: categoryValue,
color: category.colorHex color: category.colorHex
) )
}.sorted { $0.value > $1.value } }.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( private func completedMonthKeys(
@@ -565,36 +650,61 @@ class ChartsViewModel: ObservableObject {
private func calculatePerformanceData( private func calculatePerformanceData(
for sources: [InvestmentSource], for sources: [InvestmentSource],
snapshotsBySource: [UUID: [Snapshot]] snapshotsBySource: [UUID: [Snapshot]],
breakdown: BreakdownMode
) { ) {
let categories = categoryRepository.categories switch breakdown {
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() } case .category:
let categories = categoryRepository.categories
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
performanceData = categories.compactMap { category in performanceData = categories.compactMap { category in
let categorySources = sourcesByCategory[category.id] ?? [] let categorySources = sourcesByCategory[category.id] ?? []
let snapshots = categorySources.compactMap { source -> [Snapshot]? in let snapshots = categorySources.compactMap { source -> [Snapshot]? in
let id = source.id let id = source.id
return snapshotsBySource[id] return snapshotsBySource[id]
}.flatMap { $0 } }.flatMap { $0 }
guard snapshots.count >= 2 else { return nil } guard snapshots.count >= 2 else { return nil }
let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots)
guard let first = monthlyTotals.first, guard let first = monthlyTotals.first,
let last = monthlyTotals.last, let last = monthlyTotals.last,
first.totalValue > 0 else { return nil } first.totalValue > 0 else { return nil }
let cagr = calculationService.calculateCAGR( let cagr = calculationService.calculateCAGR(
startValue: first.totalValue, startValue: first.totalValue,
endValue: last.totalValue, endValue: last.totalValue,
startDate: first.date, startDate: first.date,
endDate: last.date endDate: last.date
) )
return ( return (
category: category.name, category: category.name,
cagr: cagr, cagr: cagr,
color: category.colorHex color: category.colorHex
) )
}.sorted { $0.cagr > $1.cagr } }.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]) { private func calculateContributionsData(from snapshots: [Snapshot]) {
@@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject {
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue 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 { func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId { if let accountId = goal.account?.safeId {
return sourceRepository.sources return sourceRepository.sources
@@ -288,3 +313,9 @@ struct GoalPaceStatus {
let isBehind: Bool let isBehind: Bool
let statusText: String let statusText: String
} }
enum GoalUrgencyLevel: Equatable {
case normal
case warning
case critical
}
@@ -3,6 +3,8 @@ import Charts
struct AllocationPieChart: View { struct AllocationPieChart: View {
let data: [(category: String, value: Decimal, color: String)] let data: [(category: String, value: Decimal, color: String)]
var title: String = "Asset Allocation"
var showsTargetsComparison: Bool = true
@State private var selectedSlice: String? @State private var selectedSlice: String?
@@ -12,7 +14,7 @@ struct AllocationPieChart: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Asset Allocation") Text(title)
.font(.headline) .font(.headline)
if !data.isEmpty { if !data.isEmpty {
@@ -98,7 +100,9 @@ struct AllocationPieChart: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
AllocationTargetsComparisonChart(data: data) if showsTargetsComparison {
AllocationTargetsComparisonChart(data: data)
}
} else { } else {
Text("No allocation data available") Text("No allocation data available")
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -23,7 +23,6 @@ struct ChartsContainerView: View {
// Time Range Selector // Time Range Selector
if viewModel.selectedChartType != .allocation && if viewModel.selectedChartType != .allocation &&
viewModel.selectedChartType != .performance &&
viewModel.selectedChartType != .riskReturn { viewModel.selectedChartType != .riskReturn {
timeRangeSelector timeRangeSelector
} }
@@ -34,6 +33,17 @@ struct ChartsContainerView: View {
categoryFilter categoryFilter
} }
// Source Filter
if viewModel.selectedChartType == .evolution {
sourceFilter
}
// Breakdown Selector
if viewModel.selectedChartType == .allocation ||
viewModel.selectedChartType == .performance {
breakdownSelector
}
// Chart Content // Chart Content
chartContent chartContent
} }
@@ -101,7 +111,7 @@ struct ChartsContainerView: View {
private var timeRangeSelector: some View { private var timeRangeSelector: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(ChartsViewModel.TimeRange.allCases) { range in ForEach(viewModel.availableTimeRanges(for: viewModel.selectedChartType)) { range in
Button { Button {
viewModel.selectedTimeRange = range viewModel.selectedTimeRange = range
} label: { } label: {
@@ -158,6 +168,7 @@ struct ChartsContainerView: View {
ForEach(availableCategories) { category in ForEach(availableCategories) { category in
Button { Button {
viewModel.selectedCategory = category viewModel.selectedCategory = category
viewModel.selectedSource = nil
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Circle() 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 // MARK: - Chart Content
@ViewBuilder @ViewBuilder
@@ -204,9 +300,16 @@ struct ChartsContainerView: View {
goals: goalsViewModel.goals goals: goalsViewModel.goals
) )
case .allocation: 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: case .performance:
PerformanceBarChart(data: viewModel.performanceData) PerformanceBarChart(
data: viewModel.performanceData,
title: viewModel.selectedBreakdown == .source ? "Performance by Source" : "Performance by Category"
)
case .contributions: case .contributions:
ContributionsChartView(data: viewModel.contributionsData) ContributionsChartView(data: viewModel.contributionsData)
case .rollingReturn: case .rollingReturn:
@@ -3,10 +3,11 @@ import Charts
struct PerformanceBarChart: View { struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)] let data: [(category: String, cagr: Double, color: String)]
var title: String = "Performance by Category"
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category") Text(title)
.font(.headline) .font(.headline)
Text("Compound Annual Growth Rate (CAGR)") Text("Compound Annual Growth Rate (CAGR)")
@@ -160,7 +160,18 @@ struct DashboardView: View {
yearChange: viewModel.portfolioSummary.formattedYearChange, yearChange: viewModel.portfolioSummary.formattedYearChange,
sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn, sinceInceptionChange: viewModel.portfolioSummary.formattedAllTimeReturn,
isYearPositive: viewModel.isYearChangePositive, 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: case .monthlyCheckIn:
@@ -210,11 +221,12 @@ struct DashboardView: View {
CategoryBreakdownCard(categories: viewModel.topCategories) CategoryBreakdownCard(categories: viewModel.topCategories)
} }
case .goals: case .goals:
let homeGoals = goalsViewModel.goals.filter { !GoalsViewModel.isAchieved(progress: goalsViewModel.progress(for: $0)) }
if config.isCollapsed { if config.isCollapsed {
CompactCard(title: "Goals", subtitle: "\(goalsViewModel.goals.count) active") CompactCard(title: "Goals", subtitle: "\(homeGoals.count) active")
} else { } else {
GoalsSummaryCard( GoalsSummaryCard(
goals: goalsViewModel.goals, goals: homeGoals,
progressProvider: goalsViewModel.progress(for:), progressProvider: goalsViewModel.progress(for:),
currentValueProvider: goalsViewModel.totalValue(for:), currentValueProvider: goalsViewModel.totalValue(for:),
paceStatusProvider: goalsViewModel.paceStatus(for:), paceStatusProvider: goalsViewModel.paceStatus(for:),
@@ -266,12 +278,24 @@ struct TotalValueCard: View {
var sinceInceptionChange: String? var sinceInceptionChange: String?
var isYearPositive: Bool = true var isYearPositive: Bool = true
var isSinceInceptionPositive: Bool = true var isSinceInceptionPositive: Bool = true
var onShareTap: (() -> Void)?
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Total Portfolio Value") ZStack(alignment: .trailing) {
.font(.subheadline) Text("Total Portfolio Value")
.foregroundColor(.white.opacity(0.85)) .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) Text(totalValue)
.font(.system(size: 42, weight: .bold, design: .rounded)) .font(.system(size: 42, weight: .bold, design: .rounded))
@@ -423,13 +447,6 @@ struct MonthlyCheckInCard: View {
Spacer() Spacer()
NavigationLink {
AchievementsView(referenceDate: Date())
} label: {
Image(systemName: "trophy.fill")
}
.font(.subheadline.weight(.semibold))
if let reminderDate { if let reminderDate {
Button { Button {
let title = String( let title = String(
@@ -1053,6 +1070,12 @@ struct GoalsSummaryCard: View {
ForEach(goals.prefix(2)) { goal in ForEach(goals.prefix(2)) { goal in
let currentValue = currentValueProvider(goal) let currentValue = currentValueProvider(goal)
let paceStatus = paceStatusProvider(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) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Text(goal.name) Text(goal.name)
@@ -1083,6 +1106,12 @@ struct GoalsSummaryCard: View {
.foregroundColor(.secondary) .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) { if let etaText = etaProvider(goal) {
Text(etaText) Text(etaText)
.font(.caption2) .font(.caption2)
@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct MonthlyCheckInView: View { struct MonthlyCheckInView: View {
@Environment(\.openURL) private var openURL
@EnvironmentObject var accountStore: AccountStore @EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel = MonthlyCheckInViewModel() @StateObject private var viewModel = MonthlyCheckInViewModel()
let referenceDate: Date let referenceDate: Date
@@ -13,6 +14,8 @@ struct MonthlyCheckInView: View {
@State private var editingSnapshot: Snapshot? @State private var editingSnapshot: Snapshot?
@State private var addingSource: InvestmentSource? @State private var addingSource: InvestmentSource?
@State private var didApplyDuplicate = false @State private var didApplyDuplicate = false
@State private var showAchievementSatisfactionDialog = false
@State private var showAppStoreReviewAlert = false
init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) { init(referenceDate: Date = Date(), duplicatePrevious: Bool = false) {
self.referenceDate = referenceDate self.referenceDate = referenceDate
@@ -56,7 +59,7 @@ struct MonthlyCheckInView: View {
} }
private var canAddNewCheckIn: Bool { private var canAddNewCheckIn: Bool {
lastCompletionDate == nil || checkInProgress >= 0.7 true
} }
var body: some View { var body: some View {
@@ -119,11 +122,36 @@ struct MonthlyCheckInView: View {
.onChange(of: selectedMood) { _, newValue in .onChange(of: selectedMood) { _, newValue in
MonthlyCheckInStore.setMood(newValue, for: referenceDate) 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 { private var headerCard: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("This Month") Text(monthLabel)
.font(.headline) .font(.headline)
if let date = lastCompletionDate { if let date = lastCompletionDate {
@@ -162,6 +190,7 @@ struct MonthlyCheckInView: View {
} }
Button { Button {
let previousUnlockedAchievementKeys = unlockedAchievementKeys()
let now = Date() let now = Date()
let completionDate = referenceDate.isSameMonth(as: now) let completionDate = referenceDate.isSameMonth(as: now)
? now ? now
@@ -169,6 +198,12 @@ struct MonthlyCheckInView: View {
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate) MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
ReviewPromptService.shared.recordMonthlyCheckInCompleted() ReviewPromptService.shared.recordMonthlyCheckInCompleted()
viewModel.refresh() viewModel.refresh()
let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys)
if ReviewPromptService.shared.shouldAskForAchievementSatisfaction(
newlyUnlockedAchievementKeys: newlyUnlockedAchievementKeys
) {
showAchievementSatisfactionDialog = true
}
} label: { } label: {
Text("Mark Check-in Complete") Text("Mark Check-in Complete")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@@ -178,12 +213,6 @@ struct MonthlyCheckInView: View {
.cornerRadius(AppConstants.UI.cornerRadius) .cornerRadius(AppConstants.UI.cornerRadius)
} }
.disabled(!canAddNewCheckIn) .disabled(!canAddNewCheckIn)
if !canAddNewCheckIn {
Text("Editing stays open. New check-ins unlock after 70% of the month.")
.font(.caption)
.foregroundColor(.secondary)
}
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color(.systemBackground))
@@ -191,6 +220,27 @@ struct MonthlyCheckInView: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .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<String> {
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 { private var reflectionCard: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack { HStack {
@@ -457,11 +507,7 @@ struct MonthlyCheckInView: View {
private func shareMonthlyCheckIn() { private func shareMonthlyCheckIn() {
let summary = viewModel.monthlySummary let summary = viewModel.monthlySummary
let text = ShareService.buildMonthlyCheckInShareText( ShareService.shared.shareMonthlyCheckIn(summary: summary, appName: appDisplayName)
summary: summary,
appName: appDisplayName
)
ShareService.shared.shareText(text)
} }
private var appDisplayName: String { private var appDisplayName: String {
@@ -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))
}
}
}
}
+88 -8
View File
@@ -5,6 +5,7 @@ struct GoalsView: View {
@StateObject private var viewModel = GoalsViewModel() @StateObject private var viewModel = GoalsViewModel()
@State private var showingAddGoal = false @State private var showingAddGoal = false
@State private var editingGoal: Goal? @State private var editingGoal: Goal?
@State private var showAchievedGoals = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -12,11 +13,15 @@ struct GoalsView: View {
AppBackground() AppBackground()
List { List {
if viewModel.goals.isEmpty { if filteredGoals.isEmpty {
emptyState if viewModel.goals.isEmpty {
emptyState
} else {
hiddenAchievedState
}
} else { } else {
Section { Section {
ForEach(viewModel.goals) { goal in ForEach(filteredGoals) { goal in
GoalRowView( GoalRowView(
goal: goal, goal: goal,
progress: viewModel.progress(for: goal), progress: viewModel.progress(for: goal),
@@ -48,6 +53,12 @@ struct GoalsView: View {
} }
.navigationTitle("Goals") .navigationTitle("Goals")
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(showAchievedGoals ? "Hide Achieved" : "Show Achieved") {
showAchievedGoals.toggle()
}
.font(.caption.weight(.semibold))
}
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
showingAddGoal = true 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 { private var emptyState: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "target") Image(systemName: "target")
@@ -93,6 +109,22 @@ struct GoalsView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 32) .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 { struct GoalRowView: View {
@@ -105,14 +137,53 @@ struct GoalRowView: View {
@State private var showingShareOptions = false @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 { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Button(action: onEdit) { Button(action: onEdit) {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(goal.name) HStack {
.font(.headline) 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 { HStack {
Text(totalValue.currencyString) Text(totalValue.currencyString)
@@ -126,16 +197,25 @@ struct GoalRowView: View {
if let targetDate = goal.targetDate { if let targetDate = goal.targetDate {
Text("Target date: \(targetDate.mediumDateString)") Text("Target date: \(targetDate.mediumDateString)")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(targetDateColor)
} }
if let paceStatus { if let paceStatus {
Text(paceStatus.statusText) Text(paceStatus.statusText)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundColor(paceStatus.isBehind ? .negativeRed : .positiveGreen) .foregroundColor(
isAchieved ? .appSuccess : (paceStatus.isBehind ? .negativeRed : .positiveGreen)
)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .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) .buttonStyle(.plain)
@@ -51,4 +51,40 @@ final class ReviewPromptServiceTests: XCTestCase {
XCTAssertFalse(didRequest) 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)
}
} }
@@ -25,4 +25,22 @@ final class ShareServiceTests: XCTestCase {
XCTAssertTrue(text.contains("Net performance:")) XCTAssertTrue(text.contains("Net performance:"))
XCTAssertTrue(text.contains("Shared from Portfolio Journal")) 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"))
}
} }
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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")
}
}