diff --git a/.DS_Store b/.DS_Store
index b391cc2..f9d4794 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/Changes_for_1.1.0.pdf b/Changes_for_1.1.0.pdf
new file mode 100644
index 0000000..032e3c5
Binary files /dev/null and b/Changes_for_1.1.0.pdf differ
diff --git a/ExportOptions.plist b/ExportOptions.plist
new file mode 100644
index 0000000..601de3e
--- /dev/null
+++ b/ExportOptions.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ method
+ app-store-connect
+ teamID
+ 2825Q76T7H
+ uploadSymbols
+
+ signingStyle
+ automatic
+
+
diff --git a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate
index 3bd7ec7..3376fbd 100644
Binary files a/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate and b/PortfolioJournal.xcodeproj/project.xcworkspace/xcuserdata/alexandrev.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
index 332c3fc..41a602f 100644
--- a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
+++ b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
@@ -16,7 +16,6 @@ public class InvestmentSource: NSManagedObject, Identifiable {
@NSManaged public var category: Category?
@NSManaged public var account: Account?
@NSManaged public var snapshots: NSSet?
- @NSManaged public var transactions: NSSet?
@NSManaged public var asset: Asset?
public override func awakeFromInsert() {
@@ -104,13 +103,6 @@ extension InvestmentSource {
snapshots?.count ?? 0
}
- /// Returns transactions sorted by date descending
- /// Performance note: This sorts on every call. For repeated access, cache the result.
- var transactionsArray: [Transaction] {
- let set = transactions as? Set ?? []
- return set.sorted { $0.date > $1.date }
- }
-
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
@@ -168,37 +160,6 @@ extension InvestmentSource {
}
}
- /// Performance: Iterates transactions directly without sorting
- var totalInvested: Decimal {
- guard let set = transactions as? Set else { return Decimal.zero }
- return set.reduce(Decimal.zero) { result, transaction in
- let amount = transaction.decimalAmount
- switch transaction.transactionType {
- case .buy:
- return result + amount
- case .sell:
- return result - amount
- default:
- return result
- }
- }
- }
-
- /// Performance: Iterates transactions directly without sorting
- var totalDividends: Decimal {
- guard let set = transactions as? Set else { return Decimal.zero }
- return set.reduce(Decimal.zero) { result, transaction in
- transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
- }
- }
-
- /// Performance: Iterates transactions directly without sorting
- var totalFees: Decimal {
- guard let set = transactions as? Set else { return Decimal.zero }
- return set.reduce(Decimal.zero) { result, transaction in
- transaction.transactionType == .fee ? result + transaction.decimalAmount : result
- }
- }
}
// MARK: - Account Scheduling
@@ -233,15 +194,4 @@ extension InvestmentSource {
@objc(removeSnapshots:)
@NSManaged public func removeFromSnapshots(_ values: NSSet)
- @objc(addTransactionsObject:)
- @NSManaged public func addToTransactions(_ value: Transaction)
-
- @objc(removeTransactionsObject:)
- @NSManaged public func removeFromTransactions(_ value: Transaction)
-
- @objc(addTransactions:)
- @NSManaged public func addToTransactions(_ values: NSSet)
-
- @objc(removeTransactions:)
- @NSManaged public func removeFromTransactions(_ values: NSSet)
}
diff --git a/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift
deleted file mode 100644
index 65a36dc..0000000
--- a/PortfolioJournal/Models/CoreData/Transaction+CoreDataClass.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-import Foundation
-import CoreData
-
-@objc(Transaction)
-public class Transaction: NSManagedObject, Identifiable {
- @nonobjc public class func fetchRequest() -> NSFetchRequest {
- return NSFetchRequest(entityName: "Transaction")
- }
-
- @NSManaged public var id: UUID
- @NSManaged public var date: Date
- @NSManaged public var type: String
- @NSManaged public var shares: NSDecimalNumber?
- @NSManaged public var price: NSDecimalNumber?
- @NSManaged public var amount: NSDecimalNumber?
- @NSManaged public var notes: String?
- @NSManaged public var createdAt: Date
- @NSManaged public var source: InvestmentSource?
-
- public override func awakeFromInsert() {
- super.awakeFromInsert()
- id = UUID()
- createdAt = Date()
- date = Date()
- type = TransactionType.buy.rawValue
- }
-}
-
-enum TransactionType: String, CaseIterable, Identifiable {
- case buy
- case sell
- case dividend
- case fee
- case transfer
-
- var id: String { rawValue }
-
- var displayName: String {
- switch self {
- case .buy: return "Buy"
- case .sell: return "Sell"
- case .dividend: return "Dividend"
- case .fee: return "Fee"
- case .transfer: return "Transfer"
- }
- }
-
- var isInvestmentFlow: Bool {
- self == .buy || self == .sell
- }
-}
-
-// MARK: - Computed Properties
-
-extension Transaction {
- var decimalShares: Decimal {
- shares?.decimalValue ?? Decimal.zero
- }
-
- var decimalPrice: Decimal {
- price?.decimalValue ?? Decimal.zero
- }
-
- var decimalAmount: Decimal {
- if let amount = amount?.decimalValue, amount != 0 {
- return amount
- }
- return decimalShares * decimalPrice
- }
-
- var transactionType: TransactionType {
- TransactionType(rawValue: type) ?? .buy
- }
-}
-
diff --git a/PortfolioJournal/Models/MonthlySummary.swift b/PortfolioJournal/Models/MonthlySummary.swift
index 338ba6e..7743f1c 100644
--- a/PortfolioJournal/Models/MonthlySummary.swift
+++ b/PortfolioJournal/Models/MonthlySummary.swift
@@ -15,6 +15,12 @@ struct MonthlySummary {
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
}
+ var formattedMonthYear: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMMM yyyy"
+ return formatter.string(from: startDate)
+ }
+
var formattedStartingValue: String {
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
}
diff --git a/PortfolioJournal/Repositories/TransactionRepository.swift b/PortfolioJournal/Repositories/TransactionRepository.swift
deleted file mode 100644
index 2ee5dff..0000000
--- a/PortfolioJournal/Repositories/TransactionRepository.swift
+++ /dev/null
@@ -1,62 +0,0 @@
-import Foundation
-import CoreData
-
-class TransactionRepository {
- private let context: NSManagedObjectContext
-
- init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
- self.context = context
- }
-
- func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
- let request: NSFetchRequest = Transaction.fetchRequest()
- request.predicate = NSPredicate(format: "source == %@", source)
- request.sortDescriptors = [
- NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
- ]
- return (try? context.fetch(request)) ?? []
- }
-
- @discardableResult
- func createTransaction(
- source: InvestmentSource,
- type: TransactionType,
- date: Date,
- shares: Decimal?,
- price: Decimal?,
- amount: Decimal?,
- notes: String?
- ) -> Transaction {
- let transaction = Transaction(context: context)
- transaction.source = source
- transaction.type = type.rawValue
- transaction.date = date
- if let shares = shares {
- transaction.shares = NSDecimalNumber(decimal: shares)
- }
- if let price = price {
- transaction.price = NSDecimalNumber(decimal: price)
- }
- if let amount = amount {
- transaction.amount = NSDecimalNumber(decimal: amount)
- }
- transaction.notes = notes
-
- save()
- return transaction
- }
-
- func deleteTransaction(_ transaction: Transaction) {
- context.delete(transaction)
- save()
- }
-
- private func save() {
- guard context.hasChanges else { return }
- do {
- try context.save()
- } catch {
- print("Failed to save transaction: \(error)")
- }
- }
-}
diff --git a/PortfolioJournal/Services/SampleDataService.swift b/PortfolioJournal/Services/SampleDataService.swift
index 5711604..689f11b 100644
--- a/PortfolioJournal/Services/SampleDataService.swift
+++ b/PortfolioJournal/Services/SampleDataService.swift
@@ -19,7 +19,6 @@ class SampleDataService {
let snapshotRepository = SnapshotRepository(context: context)
let goalRepository = GoalRepository(context: context)
- let transactionRepository = TransactionRepository(context: context)
let categories = fetchCategories(in: context)
guard let fallbackCategory = categories.first else { return }
@@ -70,16 +69,6 @@ class SampleDataService {
seedMonthlyNotes()
- transactionRepository.createTransaction(
- source: stocks,
- type: .buy,
- date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
- shares: 10,
- price: 400,
- amount: nil,
- notes: "Sample buy"
- )
-
_ = goalRepository.createGoal(
name: "1M Goal",
targetAmount: 1_000_000,
diff --git a/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift
index f5ea093..94ab1de 100644
--- a/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift
+++ b/PortfolioJournal/Utilities/Extensions/Color+Extensions.swift
@@ -105,6 +105,28 @@ extension Color {
return Color(hex: hex) ?? .blue
}
+ // MARK: - Source Colors (distinct per-source palette, different order from categories)
+
+ static let sourceColors: [String] = [
+ "#6366F1", // Indigo
+ "#F97316", // Orange
+ "#06B6D4", // Cyan
+ "#EF4444", // Red
+ "#84CC16", // Lime
+ "#EC4899", // Pink
+ "#14B8A6", // Teal
+ "#F59E0B", // Amber
+ "#8B5CF6", // Purple
+ "#3B82F6", // Blue
+ "#A855F7", // Violet
+ "#10B981", // Green
+ ]
+
+ static func sourceColor(at index: Int) -> Color {
+ let hex = sourceColors[index % sourceColors.count]
+ return Color(hex: hex) ?? .blue
+ }
+
// MARK: - Chart Colors
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
diff --git a/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift
index 7a2297b..df5ba5b 100644
--- a/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift
+++ b/PortfolioJournal/Utilities/Extensions/Date+Extensions.swift
@@ -136,6 +136,18 @@ extension Date {
return formatter.localizedString(for: self, relativeTo: Date())
}
+ var relativeDayDescription: String {
+ let calendar = Calendar.current
+ let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: self), to: calendar.startOfDay(for: Date())).day ?? 0
+ if days == 0 { return String(localized: "date_today") }
+ if days == 1 { return "1d ago" }
+ if days < 31 { return "\(days)d ago" }
+ let months = calendar.dateComponents([.month], from: self, to: Date()).month ?? 0
+ if months < 12 { return "\(max(1, months))mo ago" }
+ let years = calendar.dateComponents([.year], from: self, to: Date()).year ?? 0
+ return "\(max(1, years))y ago"
+ }
+
var friendlyDescription: String {
if isToday {
return String(localized: "date_today")
diff --git a/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift
index 1722356..00f9076 100644
--- a/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift
+++ b/PortfolioJournal/ViewModels/MonthlyCheckInViewModel.swift
@@ -89,8 +89,7 @@ class MonthlyCheckInViewModel: ObservableObject {
)
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
- let now = Date()
- let targetDate = targetRange.contains(now) ? now : targetRange.end
+ let targetDate = Date()
for source in sources {
let sourceId = source.id
diff --git a/PortfolioJournal/ViewModels/SettingsViewModel.swift b/PortfolioJournal/ViewModels/SettingsViewModel.swift
index b646eb0..0a49f74 100644
--- a/PortfolioJournal/ViewModels/SettingsViewModel.swift
+++ b/PortfolioJournal/ViewModels/SettingsViewModel.swift
@@ -316,7 +316,6 @@ class SettingsViewModel: ObservableObject {
"Asset",
"Account",
"Goal",
- "Transaction",
"PredictionCache"
]
diff --git a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift
index 46991fc..b1797e3 100644
--- a/PortfolioJournal/ViewModels/SourceDetailViewModel.swift
+++ b/PortfolioJournal/ViewModels/SourceDetailViewModel.swift
@@ -11,14 +11,12 @@ class SourceDetailViewModel: ObservableObject {
@Published var metrics: InvestmentMetrics = .empty
@Published var predictions: [Prediction] = []
@Published var predictionResult: PredictionResult?
- @Published var transactions: [Transaction] = []
@Published var isDeleted = false
@Published var isLoading = false
@Published var showingAddSnapshot = false
@Published var showingEditSource = false
@Published var showingPaywall = false
- @Published var showingAddTransaction = false
@Published var errorMessage: String?
// MARK: - Chart Data
@@ -29,7 +27,6 @@ class SourceDetailViewModel: ObservableObject {
private let snapshotRepository: SnapshotRepository
private let sourceRepository: InvestmentSourceRepository
- private let transactionRepository: TransactionRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
@@ -46,7 +43,6 @@ class SourceDetailViewModel: ObservableObject {
source: InvestmentSource,
snapshotRepository: SnapshotRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
- transactionRepository: TransactionRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
@@ -55,7 +51,6 @@ class SourceDetailViewModel: ObservableObject {
self.sourceName = source.name
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
- self.transactionRepository = transactionRepository ?? TransactionRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
@@ -147,8 +142,6 @@ class SourceDetailViewModel: ObservableObject {
}
}
- // Transactions update independently
- self.transactions = transactionRepository.fetchTransactions(for: source)
}
self.isRefreshing = false
}
@@ -191,33 +184,6 @@ class SourceDetailViewModel: ObservableObject {
refreshData()
}
- // MARK: - Transaction Actions
-
- func addTransaction(
- type: TransactionType,
- date: Date,
- shares: Decimal?,
- price: Decimal?,
- amount: Decimal?,
- notes: String?
- ) {
- transactionRepository.createTransaction(
- source: source,
- type: type,
- date: date,
- shares: shares,
- price: price,
- amount: amount,
- notes: notes
- )
- refreshData()
- }
-
- func deleteTransaction(_ transaction: Transaction) {
- transactionRepository.deleteTransaction(transaction)
- refreshData()
- }
-
// MARK: - Source Actions
func updateSource(
diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift
index 780ebc8..a0b97c1 100644
--- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift
+++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift
@@ -22,28 +22,8 @@ struct ChartsContainerView: View {
// Chart Type Selector
chartTypeSelector
- // Time Range Selector
- if viewModel.selectedChartType != .allocation &&
- viewModel.selectedChartType != .riskReturn {
- timeRangeSelector
- }
-
- // Category Filter
- if viewModel.selectedChartType == .evolution ||
- viewModel.selectedChartType == .prediction {
- categoryFilter
- }
-
- // Source Filter
- if viewModel.selectedChartType == .evolution {
- sourceFilter
- }
-
- // Breakdown Selector
- if viewModel.selectedChartType == .allocation ||
- viewModel.selectedChartType == .performance {
- breakdownSelector
- }
+ // Unified Filters
+ filtersSection
// Chart Content
chartContent
@@ -108,6 +88,74 @@ struct ChartsContainerView: View {
}
}
+ // MARK: - Unified Filters Section
+
+ private var hasAnyFilter: Bool {
+ let chartType = viewModel.selectedChartType
+ let hasTimeRange = chartType != .allocation && chartType != .riskReturn
+ let hasCategories = !viewModel.availableCategories(for: chartType).isEmpty
+ let hasSources = !viewModel.availableSources(for: chartType).isEmpty
+ let hasBreakdown = chartType == .allocation || chartType == .performance
+ return hasTimeRange || hasCategories || hasSources || hasBreakdown
+ }
+
+ @ViewBuilder
+ private var filtersSection: some View {
+ if hasAnyFilter {
+ VStack(alignment: .leading, spacing: 10) {
+ let chartType = viewModel.selectedChartType
+
+ // Time Range
+ if chartType != .allocation && chartType != .riskReturn {
+ filterRow(icon: "calendar", label: "Period") {
+ timeRangeSelector
+ }
+ }
+
+ // Breakdown (category vs source)
+ if chartType == .allocation || chartType == .performance {
+ filterRow(icon: "square.grid.2x2", label: "Group") {
+ breakdownSelector
+ }
+ }
+
+ // Category
+ let availableCategories = viewModel.availableCategories(for: chartType)
+ if !availableCategories.isEmpty {
+ filterRow(icon: "tag", label: "Category") {
+ categoryFilter
+ }
+ }
+
+ // Source
+ let availableSources = viewModel.availableSources(for: chartType)
+ if !availableSources.isEmpty {
+ filterRow(icon: "building.2", label: "Source") {
+ sourceFilter
+ }
+ }
+ }
+ .padding(12)
+ .background(Color(.systemBackground))
+ .cornerRadius(AppConstants.UI.cornerRadius)
+ .shadow(color: .black.opacity(0.05), radius: 4, y: 1)
+ }
+ }
+
+ private func filterRow(icon: String, label: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text(label)
+ .font(.caption.weight(.medium))
+ .foregroundColor(.secondary)
+ }
+ content()
+ }
+ }
+
// MARK: - Time Range Selector
private var timeRangeSelector: some View {
@@ -229,26 +277,31 @@ struct ChartsContainerView: View {
}
}
- ForEach(availableSources, id: \.id) { source in
- let sourceId = source.id ?? UUID()
+ ForEach(Array(availableSources.enumerated()), id: \.element.id) { index, source in
+ let sourceId = source.id
let isSelected = viewModel.selectedSourceIds.contains(sourceId)
+ let sourceColor = Color.sourceColor(at: index)
Button {
viewModel.selectedSource = nil
- viewModel.selectedCategory = nil
if isSelected {
viewModel.selectedSourceIds.remove(sourceId)
} else {
viewModel.selectedSourceIds.insert(sourceId)
}
} label: {
- Text(source.name)
- .font(.caption.weight(.medium))
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(
- isSelected
- ? Color.appPrimary
- : Color.gray.opacity(0.1)
+ HStack(spacing: 4) {
+ Circle()
+ .fill(sourceColor)
+ .frame(width: 8, height: 8)
+ Text(source.name)
+ }
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(
+ isSelected
+ ? sourceColor
+ : Color.gray.opacity(0.1)
)
.foregroundColor(
isSelected
@@ -975,15 +1028,36 @@ struct AllocationEvolutionChart: View {
.frame(height: 260)
}
+ /// Categories in stable order (preserving the ViewModel's sort: largest overall first)
+ private var stableCategoryNames: [String] {
+ var seen = Set()
+ var ordered: [String] = []
+ for item in data {
+ if seen.insert(item.category).inserted {
+ ordered.append(item.category)
+ }
+ }
+ return ordered
+ }
+
+ private var stableCategoryColors: [Color] {
+ stableCategoryNames.map { name in
+ if let hex = data.first(where: { $0.category == name })?.color {
+ return Color(hex: hex) ?? .gray
+ }
+ return .gray
+ }
+ }
+
private var chartView: some View {
Chart(identifiableData) { item in
- AreaMark(
- x: .value("Date", item.date),
+ BarMark(
+ x: .value("Date", item.date, unit: .month),
y: .value("Percentage", item.percentage)
)
.foregroundStyle(by: .value("Category", item.category))
}
- .chartForegroundStyleScale(domain: categoryNames, range: categoryColors)
+ .chartForegroundStyleScale(domain: stableCategoryNames, range: stableCategoryColors)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { _ in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
@@ -1002,19 +1076,6 @@ struct AllocationEvolutionChart: View {
.frame(height: 260)
.drawingGroup()
}
-
- private var categoryNames: [String] {
- Array(Set(data.map { $0.category })).sorted()
- }
-
- private var categoryColors: [Color] {
- categoryNames.map { name in
- if let hex = data.first(where: { $0.category == name })?.color {
- return Color(hex: hex) ?? .gray
- }
- return .gray
- }
- }
}
#Preview {
diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift
index 23c91ab..acd60d9 100644
--- a/PortfolioJournal/Views/Dashboard/DashboardView.swift
+++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift
@@ -416,9 +416,10 @@ struct MonthlyCheckInCard: View {
@State private var showingStartOptions = false
@State private var startDestinationActive = false
@State private var shouldDuplicatePrevious = false
+ @State private var cachedCompletionDate: Date?
private var effectiveLastCheckInDate: Date? {
- MonthlyCheckInStore.latestCompletionDate() ?? lastUpdatedDate
+ cachedCompletionDate ?? lastUpdatedDate
}
private var checkInProgress: Double {
@@ -432,7 +433,8 @@ struct MonthlyCheckInCard: View {
private var nextCheckInDate: Date? {
guard let last = effectiveLastCheckInDate else { return nil }
- return last.adding(months: 1)
+ let effective = MonthlyCheckInStore.effectiveMonth(for: last, relativeTo: last)
+ return effective.adding(months: 1).endOfMonth
}
private var isOverdue: Bool {
@@ -473,10 +475,10 @@ struct MonthlyCheckInCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
- Text(currentMonthLabel)
+ Text("Monthly Check-in")
.font(.headline)
- Text("Keep a calm, deliberate rhythm. Update your sources and add a short note.")
+ Text(currentMonthLabel)
.font(.subheadline)
.foregroundColor(.secondary)
@@ -583,6 +585,14 @@ struct MonthlyCheckInCard: View {
.padding(.horizontal, 24)
.presentationDetents([.height(280)])
}
+ .onAppear {
+ cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
+ }
+ .onChange(of: startDestinationActive) { _, isActive in
+ if !isActive {
+ cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate()
+ }
+ }
}
private var defaultReminderTime: Date {
diff --git a/PortfolioJournal/Views/Dashboard/ShareCards.swift b/PortfolioJournal/Views/Dashboard/ShareCards.swift
index 5ee7430..e882284 100644
--- a/PortfolioJournal/Views/Dashboard/ShareCards.swift
+++ b/PortfolioJournal/Views/Dashboard/ShareCards.swift
@@ -7,7 +7,7 @@ struct MonthlyCheckInShareCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 14) {
- Text(summary.periodLabel)
+ Text(summary.formattedMonthYear)
.font(.caption.weight(.semibold))
.foregroundColor(.white.opacity(0.85))
@@ -17,7 +17,9 @@ struct MonthlyCheckInShareCardView: View {
metricRow("Starting", summary.formattedStartingValue)
metricRow("Ending", summary.formattedEndingValue)
- metricRow("Contributions", summary.formattedContributions)
+ if summary.contributions != 0 {
+ metricRow("Contributions", summary.formattedContributions)
+ }
metricRow("Net performance", "\(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))")
Spacer(minLength: 0)
diff --git a/PortfolioJournal/Views/Goals/GoalEditorView.swift b/PortfolioJournal/Views/Goals/GoalEditorView.swift
index 97a094f..5aba342 100644
--- a/PortfolioJournal/Views/Goals/GoalEditorView.swift
+++ b/PortfolioJournal/Views/Goals/GoalEditorView.swift
@@ -106,13 +106,31 @@ struct GoalEditorView: View {
private func parseDecimal(_ value: String) -> Decimal? {
let locale = CurrencyFormatter.locale(for: currencyCode)
- let cleaned = value
+ let stripped = value
.replacingOccurrences(of: currencySymbol, with: "")
- .replacingOccurrences(of: locale.groupingSeparator ?? "", with: "")
.trimmingCharacters(in: .whitespaces)
- guard !cleaned.isEmpty else { return nil }
+ guard !stripped.isEmpty else { return nil }
+ let decimalSep = locale.decimalSeparator ?? "."
+ let groupingSep = locale.groupingSeparator ?? ""
+
+ // Detect alternate decimal BEFORE removing separators
+ let usesAlternateDecimal =
+ (decimalSep == "," && stripped.contains(".") && !stripped.contains(",")) ||
+ (decimalSep == "." && stripped.contains(",") && !stripped.contains("."))
+
+ if usesAlternateDecimal {
+ let normalized = stripped
+ .replacingOccurrences(of: groupingSep, with: "")
+ .replacingOccurrences(of: ",", with: ".")
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ return formatter.number(from: normalized)?.decimalValue
+ }
+
+ let cleaned = stripped.replacingOccurrences(of: groupingSep, with: "")
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale
@@ -123,7 +141,7 @@ struct GoalEditorView: View {
// Fallback for mixed locale input
let normalized = cleaned
- .replacingOccurrences(of: locale.decimalSeparator ?? ",", with: ".")
+ .replacingOccurrences(of: decimalSep, with: ".")
.replacingOccurrences(of: ",", with: ".")
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
diff --git a/PortfolioJournal/Views/Sources/AddTransactionView.swift b/PortfolioJournal/Views/Sources/AddTransactionView.swift
deleted file mode 100644
index c216f22..0000000
--- a/PortfolioJournal/Views/Sources/AddTransactionView.swift
+++ /dev/null
@@ -1,94 +0,0 @@
-import SwiftUI
-
-struct AddTransactionView: View {
- @Environment(\.dismiss) private var dismiss
- let onSave: (TransactionType, Date, Decimal?, Decimal?, Decimal?, String?) -> Void
-
- @State private var type: TransactionType = .buy
- @State private var date = Date()
- @State private var shares = ""
- @State private var price = ""
- @State private var amount = ""
- @State private var notes = ""
-
- var body: some View {
- NavigationStack {
- Form {
- Section {
- Picker("Type", selection: $type) {
- ForEach(TransactionType.allCases) { transactionType in
- Text(transactionType.displayName).tag(transactionType)
- }
- }
-
- DatePicker("Date", selection: $date, displayedComponents: .date)
- } header: {
- Text("Transaction")
- }
-
- Section {
- TextField("Shares", text: $shares)
- .keyboardType(.decimalPad)
-
- TextField("Price per share", text: $price)
- .keyboardType(.decimalPad)
-
- TextField("Total amount", text: $amount)
- .keyboardType(.decimalPad)
- } header: {
- Text("Amounts")
- } footer: {
- Text("Enter shares and price or just a total amount.")
- }
-
- Section {
- TextField("Notes", text: $notes, axis: .vertical)
- .lineLimit(2...4)
- } header: {
- Text("Notes (Optional)")
- }
- }
- .navigationTitle("Add Transaction")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button("Cancel") { dismiss() }
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- Button("Save") { save() }
- .disabled(!isValid)
- }
- }
- }
- .presentationDetents([.medium, .large])
- }
-
- private var isValid: Bool {
- parseDecimal(shares) != nil || parseDecimal(amount) != nil
- }
-
- private func save() {
- onSave(
- type,
- date,
- parseDecimal(shares),
- parseDecimal(price),
- parseDecimal(amount),
- notes.isEmpty ? nil : notes
- )
- dismiss()
- }
-
- private func parseDecimal(_ value: String) -> Decimal? {
- let cleaned = value
- .replacingOccurrences(of: ",", with: ".")
- .trimmingCharacters(in: .whitespaces)
- guard !cleaned.isEmpty else { return nil }
- return Decimal(string: cleaned)
- }
-}
-
-#Preview {
- AddTransactionView { _, _, _, _, _, _ in }
-}
-
diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift
index 2b703b2..640a71f 100644
--- a/PortfolioJournal/Views/Sources/SourceDetailView.swift
+++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift
@@ -55,9 +55,6 @@ struct SourceDetailView: View {
metricsSection
}
- // Transactions
- transactionsSection
-
// Snapshots List
snapshotsSection
}
@@ -95,18 +92,6 @@ struct SourceDetailView: View {
MissingSnapshotView()
}
}
- .sheet(isPresented: $viewModel.showingAddTransaction) {
- AddTransactionView { type, date, shares, price, amount, notes in
- viewModel.addTransaction(
- type: type,
- date: date,
- shares: shares,
- price: price,
- amount: amount,
- notes: notes
- )
- }
- }
.sheet(isPresented: $viewModel.showingEditSource) {
EditSourceView(source: viewModel.source)
}
@@ -187,30 +172,16 @@ struct SourceDetailView: View {
// MARK: - Quick Actions
private var quickActions: some View {
- HStack(spacing: 12) {
- Button {
- viewModel.showingAddSnapshot = true
- } label: {
- Label("Add Snapshot", systemImage: "plus.circle.fill")
- .font(.subheadline.weight(.semibold))
- .frame(maxWidth: .infinity)
- .padding()
- .background(Color.appPrimary)
- .foregroundColor(.white)
- .cornerRadius(AppConstants.UI.cornerRadius)
- }
-
- Button {
- viewModel.showingAddTransaction = true
- } label: {
- Label("Add Transaction", systemImage: "arrow.left.arrow.right.circle")
- .font(.subheadline.weight(.semibold))
- .frame(maxWidth: .infinity)
- .padding()
- .background(Color.appSecondary.opacity(0.15))
- .foregroundColor(.appSecondary)
- .cornerRadius(AppConstants.UI.cornerRadius)
- }
+ Button {
+ viewModel.showingAddSnapshot = true
+ } label: {
+ Label("Add Snapshot", systemImage: "plus.circle.fill")
+ .font(.subheadline.weight(.semibold))
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.appPrimary)
+ .foregroundColor(.white)
+ .cornerRadius(AppConstants.UI.cornerRadius)
}
}
@@ -362,56 +333,6 @@ struct SourceDetailView: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
- // MARK: - Transactions Section
-
- private var transactionsSection: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Text("Transactions")
- .font(.headline)
-
- Spacer()
-
- Text("\(viewModel.transactions.count)")
- .font(.subheadline)
- .foregroundColor(.secondary)
- }
-
- if viewModel.transactions.isEmpty {
- Text("Add buys, sells, dividends, and fees to track cashflows.")
- .font(.subheadline)
- .foregroundColor(.secondary)
- } else {
- HStack(spacing: 12) {
- MetricChip(title: "Invested", value: viewModel.source.totalInvested.compactCurrencyString)
- MetricChip(title: "Dividends", value: viewModel.source.totalDividends.compactCurrencyString)
- MetricChip(title: "Fees", value: viewModel.source.totalFees.compactCurrencyString)
- }
-
- ForEach(viewModel.transactions.prefix(5)) { transaction in
- TransactionRow(transaction: transaction)
- .contextMenu {
- Button(role: .destructive) {
- viewModel.deleteTransaction(transaction)
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- }
-
- if viewModel.transactions.count > 5 {
- Text("+ \(viewModel.transactions.count - 5) more")
- .font(.caption)
- .foregroundColor(.secondary)
- }
- }
- }
- .padding()
- .background(Color(.systemBackground))
- .cornerRadius(AppConstants.UI.cornerRadius)
- .shadow(color: .black.opacity(0.05), radius: 8, y: 2)
- }
-
// MARK: - Snapshots Section
private var snapshotsSection: some View {
@@ -567,32 +488,6 @@ struct SnapshotRowView: View {
}
}
-// MARK: - Transaction Row View
-
-struct TransactionRow: View {
- let transaction: Transaction
-
- var body: some View {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text(transaction.transactionType.displayName)
- .font(.subheadline.weight(.semibold))
-
- Text(transaction.date.friendlyDescription)
- .font(.caption)
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- Text(transaction.decimalAmount.compactCurrencyString)
- .font(.subheadline.weight(.semibold))
- .foregroundColor(transaction.transactionType == .fee ? .negativeRed : .primary)
- }
- .padding(.vertical, 4)
- }
-}
-
struct MetricChip: View {
let title: String
let value: String