initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -0,0 +1,12 @@
import Foundation
struct CategoryEvolutionPoint: Identifiable {
let date: Date
let categoryName: String
let colorHex: String
let value: Decimal
var id: String {
"\(categoryName)-\(date.timeIntervalSince1970)"
}
}
@@ -0,0 +1,54 @@
import Foundation
import CoreData
@objc(Account)
public class Account: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Account> {
return NSFetchRequest<Account>(entityName: "Account")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var createdAt: Date
@NSManaged public var currency: String?
@NSManaged public var inputMode: String
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var sortOrder: Int16
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = "Account"
inputMode = InputMode.simple.rawValue
notificationFrequency = NotificationFrequency.monthly.rawValue
customFrequencyMonths = 1
sortOrder = 0
}
}
// MARK: - Computed Properties
extension Account {
var sourcesArray: [InvestmentSource] {
let set = sources as? Set<InvestmentSource> ?? []
return set.sorted { $0.name < $1.name }
}
var goalsArray: [Goal] {
let set = goals as? Set<Goal> ?? []
return set.sorted { $0.createdAt < $1.createdAt }
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
var currencyCode: String? {
currency?.isEmpty == false ? currency : nil
}
}
@@ -0,0 +1,81 @@
import Foundation
import CoreData
@objc(AppSettings)
public class AppSettings: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<AppSettings> {
return NSFetchRequest<AppSettings>(entityName: "AppSettings")
}
@NSManaged public var id: UUID
@NSManaged public var currency: String
@NSManaged public var defaultNotificationTime: Date?
@NSManaged public var enableAnalytics: Bool
@NSManaged public var onboardingCompleted: Bool
@NSManaged public var lastSyncDate: Date?
@NSManaged public var createdAt: Date
@NSManaged public var inputMode: String
@NSManaged public var selectedAccountId: UUID?
@NSManaged public var showAllAccounts: Bool
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
let localeCode = Locale.current.currency?.identifier
currency = CurrencyPicker.commonCodes.contains(localeCode ?? "")
? (localeCode ?? "EUR")
: "EUR"
enableAnalytics = true
onboardingCompleted = false
inputMode = InputMode.simple.rawValue
showAllAccounts = true
createdAt = Date()
// Default notification time: 9:00 AM
var components = DateComponents()
components.hour = 9
components.minute = 0
defaultNotificationTime = Calendar.current.date(from: components)
}
}
// MARK: - Computed Properties
extension AppSettings {
var currencySymbol: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
return formatter.currencySymbol ?? ""
}
var notificationTimeString: String {
guard let time = defaultNotificationTime else { return "9:00 AM" }
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: time)
}
}
// MARK: - Static Helpers
extension AppSettings {
static func getOrCreate(in context: NSManagedObjectContext) -> AppSettings {
let request: NSFetchRequest<AppSettings> = AppSettings.fetchRequest()
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
let new = AppSettings(context: context)
try? context.save()
return new
}
static func markOnboardingComplete(in context: NSManagedObjectContext) {
let settings = getOrCreate(in: context)
settings.onboardingCompleted = true
try? context.save()
}
}
@@ -0,0 +1,50 @@
import Foundation
import CoreData
@objc(Asset)
public class Asset: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Asset> {
return NSFetchRequest<Asset>(entityName: "Asset")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var symbol: String?
@NSManaged public var type: String
@NSManaged public var currency: String?
@NSManaged public var createdAt: Date
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = ""
type = AssetType.stock.rawValue
}
}
enum AssetType: String, CaseIterable, Identifiable {
case stock
case etf
case crypto
case fund
case cash
case realEstate
case other
var id: String { rawValue }
var displayName: String {
switch self {
case .stock: return "Stock"
case .etf: return "ETF"
case .crypto: return "Crypto"
case .fund: return "Fund"
case .cash: return "Cash"
case .realEstate: return "Real Estate"
case .other: return "Other"
}
}
}
@@ -0,0 +1,92 @@
import Foundation
import CoreData
import SwiftUI
@objc(Category)
public class Category: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Category> {
return NSFetchRequest<Category>(entityName: "Category")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var colorHex: String
@NSManaged public var icon: String
@NSManaged public var sortOrder: Int16
@NSManaged public var createdAt: Date
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
sortOrder = 0
colorHex = "#3B82F6"
icon = "chart.pie.fill"
name = ""
}
}
// MARK: - Computed Properties
extension Category {
var color: Color {
Color(hex: colorHex) ?? .blue
}
var sourcesArray: [InvestmentSource] {
let set = sources as? Set<InvestmentSource> ?? []
return set.sorted { $0.name < $1.name }
}
var sourceCount: Int {
sources?.count ?? 0
}
var totalValue: Decimal {
sourcesArray.reduce(Decimal.zero) { $0 + $1.latestValue }
}
}
// MARK: - Generated accessors for sources
extension Category {
@objc(addSourcesObject:)
@NSManaged public func addToSources(_ value: InvestmentSource)
@objc(removeSourcesObject:)
@NSManaged public func removeFromSources(_ value: InvestmentSource)
@objc(addSources:)
@NSManaged public func addToSources(_ values: NSSet)
@objc(removeSources:)
@NSManaged public func removeFromSources(_ values: NSSet)
}
// MARK: - Default Categories
extension Category {
static let defaultCategories: [(name: String, colorHex: String, icon: String)] = [
("Stocks", "#10B981", "chart.line.uptrend.xyaxis"),
("Bonds", "#3B82F6", "building.columns.fill"),
("Real Estate", "#F59E0B", "house.fill"),
("Crypto", "#8B5CF6", "bitcoinsign.circle.fill"),
("Cash", "#6B7280", "banknote.fill"),
("ETFs", "#EC4899", "chart.bar.fill"),
("Retirement", "#14B8A6", "person.fill"),
("Other", "#64748B", "ellipsis.circle.fill")
]
static func createDefaultCategories(in context: NSManagedObjectContext) {
for (index, categoryData) in defaultCategories.enumerated() {
let category = Category(context: context)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
category.sortOrder = Int16(index)
}
try? context.save()
}
}
@@ -0,0 +1,34 @@
import Foundation
import CoreData
@objc(Goal)
public class Goal: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Goal> {
return NSFetchRequest<Goal>(entityName: "Goal")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var targetAmount: NSDecimalNumber?
@NSManaged public var targetDate: Date?
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
@NSManaged public var account: Account?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
name = ""
isActive = true
}
}
// MARK: - Computed Properties
extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
}
@@ -0,0 +1,239 @@
import Foundation
import CoreData
@objc(InvestmentSource)
public class InvestmentSource: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<InvestmentSource> {
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
@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() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
isActive = true
notificationFrequency = NotificationFrequency.monthly.rawValue
customFrequencyMonths = 1
name = ""
}
}
// MARK: - Notification Frequency
enum NotificationFrequency: String, CaseIterable, Identifiable {
case monthly = "monthly"
case quarterly = "quarterly"
case semiannual = "semiannual"
case annual = "annual"
case custom = "custom"
case never = "never"
var id: String { rawValue }
var displayName: String {
switch self {
case .monthly: return "Monthly"
case .quarterly: return "Quarterly"
case .semiannual: return "Semi-Annual"
case .annual: return "Annual"
case .custom: return "Custom"
case .never: return "Never"
}
}
var months: Int {
switch self {
case .monthly: return 1
case .quarterly: return 3
case .semiannual: return 6
case .annual: return 12
case .custom, .never: return 0
}
}
}
// MARK: - Computed Properties
extension InvestmentSource {
/// Returns snapshots sorted by date descending (most recent first)
/// Performance note: This sorts on every call. For repeated access, cache the result.
var snapshotsArray: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date > $1.date }
}
/// Returns snapshots sorted by date ascending (oldest first)
/// Performance note: This sorts on every call. For repeated access, cache the result.
var sortedSnapshotsByDateAscending: [Snapshot] {
let set = snapshots as? Set<Snapshot> ?? []
return set.sorted { $0.date < $1.date }
}
/// Returns the most recent snapshot without sorting all snapshots
/// Performance: O(n) instead of O(n log n) for sorting
var latestSnapshot: Snapshot? {
guard let set = snapshots as? Set<Snapshot>, !set.isEmpty else { return nil }
return set.max(by: { $0.date < $1.date })
}
var latestValue: Decimal {
latestSnapshot?.value?.decimalValue ?? Decimal.zero
}
var snapshotCount: Int {
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<Transaction> ?? []
return set.sorted { $0.date > $1.date }
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
var nextReminderDate: Date? {
if let account = account {
return account.nextReminderDate
}
guard frequency != .never else { return nil }
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
guard let lastSnapshot = latestSnapshot else {
return Date() // Remind now if no snapshots
}
return Calendar.current.date(byAdding: .month, value: months, to: lastSnapshot.date)
}
var needsUpdate: Bool {
if let account = account {
guard let nextDate = account.nextReminderDate else { return false }
return Date() >= nextDate
}
guard let nextDate = nextReminderDate else { return false }
return Date() >= nextDate
}
// MARK: - Performance Metrics
/// Calculates total return percentage. Performance: Uses O(n) min/max instead of sorting.
var totalReturn: Decimal {
guard let set = snapshots as? Set<Snapshot>, set.count >= 2 else {
return Decimal.zero
}
// Performance: Find min and max by date in one pass instead of sorting twice
guard let first = set.min(by: { $0.date < $1.date }),
let last = set.max(by: { $0.date < $1.date }),
let firstValue = first.value?.decimalValue,
firstValue != Decimal.zero else {
return Decimal.zero
}
let lastValue = last.value?.decimalValue ?? Decimal.zero
return ((lastValue - firstValue) / firstValue) * 100
}
/// Performance: Iterates snapshots directly without sorting
var totalContributions: Decimal {
guard let set = snapshots as? Set<Snapshot> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, snapshot in
result + (snapshot.contribution?.decimalValue ?? Decimal.zero)
}
}
/// Performance: Iterates transactions directly without sorting
var totalInvested: Decimal {
guard let set = transactions as? Set<Transaction> 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<Transaction> 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<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
}
}
}
// MARK: - Account Scheduling
extension Account {
var nextReminderDate: Date? {
guard frequency != .never else { return nil }
let months = frequency == .custom ? Int(customFrequencyMonths) : frequency.months
guard let latestSnapshotDate = sourcesArray
.compactMap({ $0.latestSnapshot?.date })
.max() else {
return Date()
}
return Calendar.current.date(byAdding: .month, value: months, to: latestSnapshotDate)
}
}
// MARK: - Generated accessors for snapshots
extension InvestmentSource {
@objc(addSnapshotsObject:)
@NSManaged public func addToSnapshots(_ value: Snapshot)
@objc(removeSnapshotsObject:)
@NSManaged public func removeFromSnapshots(_ value: Snapshot)
@objc(addSnapshots:)
@NSManaged public func addToSnapshots(_ values: NSSet)
@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)
}
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AppSettings" representedClassName="AppSettings" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" attributeType="String" defaultValueString="EUR"/>
<attribute name="defaultNotificationTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="enableAnalytics" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
<attribute name="lastSyncDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="onboardingCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="selectedAccountId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="showAllAccounts" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
</entity>
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" optional="YES" attributeType="String"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="inputMode" attributeType="String" defaultValueString="simple"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
<relationship name="asset" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Asset" inverseName="sources" inverseEntity="Asset"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="sources" inverseEntity="Category"/>
<relationship name="snapshots" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Snapshot" inverseName="source" inverseEntity="Snapshot"/>
<relationship name="transactions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Transaction" inverseName="source" inverseEntity="Transaction"/>
</entity>
<entity name="Asset" representedClassName="Asset" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="currency" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="symbol" optional="YES" attributeType="String"/>
<attribute name="type" attributeType="String" defaultValueString="stock"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="asset" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Transaction" representedClassName="Transaction" syncable="YES">
<attribute name="amount" optional="YES" attributeType="Decimal"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="price" optional="YES" attributeType="Decimal"/>
<attribute name="shares" optional="YES" attributeType="Decimal"/>
<attribute name="type" attributeType="String" defaultValueString="buy"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="transactions" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Goal" representedClassName="Goal" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="predictionData" optional="YES" attributeType="Binary"/>
<attribute name="sourceId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="validUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="PremiumStatus" representedClassName="PremiumStatus" syncable="YES">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isFamilyShared" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isPremium" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastVerificationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="originalPurchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="productIdentifier" optional="YES" attributeType="String"/>
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="transactionId" optional="YES" attributeType="String"/>
</entity>
<entity name="Snapshot" representedClassName="Snapshot" syncable="YES">
<attribute name="contribution" optional="YES" attributeType="Decimal"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
</entity>
</model>
@@ -0,0 +1,151 @@
import Foundation
import CoreData
@objc(PredictionCache)
public class PredictionCache: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PredictionCache> {
return NSFetchRequest<PredictionCache>(entityName: "PredictionCache")
}
@NSManaged public var id: UUID
@NSManaged public var sourceId: UUID?
@NSManaged public var algorithm: String
@NSManaged public var predictionData: Data?
@NSManaged public var calculatedAt: Date
@NSManaged public var validUntil: Date
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
calculatedAt = Date()
// Cache valid for 24 hours
validUntil = Calendar.current.date(byAdding: .hour, value: 24, to: Date()) ?? Date()
algorithm = PredictionAlgorithm.linear.rawValue
}
}
// MARK: - Prediction Algorithm
enum PredictionAlgorithm: String, CaseIterable, Identifiable {
case linear = "linear"
case exponentialSmoothing = "exponential_smoothing"
case movingAverage = "moving_average"
case holtTrend = "holt_trend"
var id: String { rawValue }
var displayName: String {
switch self {
case .linear: return "Linear Regression"
case .exponentialSmoothing: return "Exponential Smoothing"
case .movingAverage: return "Moving Average"
case .holtTrend: return "Holt Trend"
}
}
var description: String {
switch self {
case .linear:
return "Projects future values based on historical trend line"
case .exponentialSmoothing:
return "Gives more weight to recent data points"
case .movingAverage:
return "Smooths out short-term fluctuations"
case .holtTrend:
return "Captures level and trend changes over time"
}
}
}
// MARK: - Computed Properties
extension PredictionCache {
var isValid: Bool {
Date() < validUntil
}
var algorithmType: PredictionAlgorithm {
PredictionAlgorithm(rawValue: algorithm) ?? .linear
}
var predictions: [Prediction]? {
guard let data = predictionData else { return nil }
return try? JSONDecoder().decode([Prediction].self, from: data)
}
}
// MARK: - Static Helpers
extension PredictionCache {
static func getCache(
for sourceId: UUID?,
algorithm: PredictionAlgorithm,
in context: NSManagedObjectContext
) -> PredictionCache? {
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let sourceId = sourceId {
request.predicate = NSPredicate(
format: "sourceId == %@ AND algorithm == %@",
sourceId as CVarArg,
algorithm.rawValue
)
} else {
request.predicate = NSPredicate(
format: "sourceId == nil AND algorithm == %@",
algorithm.rawValue
)
}
request.fetchLimit = 1
guard let cache = try? context.fetch(request).first,
cache.isValid else {
return nil
}
return cache
}
static func saveCache(
for sourceId: UUID?,
algorithm: PredictionAlgorithm,
predictions: [Prediction],
in context: NSManagedObjectContext
) {
// Remove old cache
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let sourceId = sourceId {
request.predicate = NSPredicate(
format: "sourceId == %@ AND algorithm == %@",
sourceId as CVarArg,
algorithm.rawValue
)
} else {
request.predicate = NSPredicate(
format: "sourceId == nil AND algorithm == %@",
algorithm.rawValue
)
}
if let oldCache = try? context.fetch(request) {
oldCache.forEach { context.delete($0) }
}
// Create new cache
let cache = PredictionCache(context: context)
cache.sourceId = sourceId
cache.algorithm = algorithm.rawValue
cache.predictionData = try? JSONEncoder().encode(predictions)
try? context.save()
}
static func invalidateAll(in context: NSManagedObjectContext) {
let request: NSFetchRequest<PredictionCache> = PredictionCache.fetchRequest()
if let caches = try? context.fetch(request) {
caches.forEach { context.delete($0) }
}
try? context.save()
}
}
@@ -0,0 +1,82 @@
import Foundation
import CoreData
@objc(PremiumStatus)
public class PremiumStatus: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<PremiumStatus> {
return NSFetchRequest<PremiumStatus>(entityName: "PremiumStatus")
}
@NSManaged public var id: UUID
@NSManaged public var isPremium: Bool
@NSManaged public var purchaseDate: Date?
@NSManaged public var productIdentifier: String?
@NSManaged public var transactionId: String?
@NSManaged public var isFamilyShared: Bool
@NSManaged public var lastVerificationDate: Date?
@NSManaged public var originalPurchaseDate: Date?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
isPremium = false
isFamilyShared = false
}
}
// MARK: - Computed Properties
extension PremiumStatus {
var daysSincePurchase: Int? {
guard let purchaseDate = purchaseDate else { return nil }
return Calendar.current.dateComponents([.day], from: purchaseDate, to: Date()).day
}
var needsVerification: Bool {
guard let lastVerification = lastVerificationDate else { return true }
let hoursSinceVerification = Calendar.current.dateComponents(
[.hour],
from: lastVerification,
to: Date()
).hour ?? 0
return hoursSinceVerification >= 24
}
}
// MARK: - Static Helpers
extension PremiumStatus {
static func getOrCreate(in context: NSManagedObjectContext) -> PremiumStatus {
let request: NSFetchRequest<PremiumStatus> = PremiumStatus.fetchRequest()
request.fetchLimit = 1
if let existing = try? context.fetch(request).first {
return existing
}
let new = PremiumStatus(context: context)
try? context.save()
return new
}
static func updateStatus(
isPremium: Bool,
productIdentifier: String?,
transactionId: String?,
isFamilyShared: Bool,
in context: NSManagedObjectContext
) {
let status = getOrCreate(in: context)
status.isPremium = isPremium
status.productIdentifier = productIdentifier
status.transactionId = transactionId
status.isFamilyShared = isFamilyShared
status.lastVerificationDate = Date()
if isPremium && status.purchaseDate == nil {
status.purchaseDate = Date()
}
try? context.save()
}
}
@@ -0,0 +1,79 @@
import Foundation
import CoreData
@objc(Snapshot)
public class Snapshot: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Snapshot> {
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@NSManaged public var value: NSDecimalNumber?
@NSManaged public var contribution: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
date = Date()
createdAt = Date()
}
}
// MARK: - Computed Properties
extension Snapshot {
var decimalValue: Decimal {
value?.decimalValue ?? Decimal.zero
}
var decimalContribution: Decimal {
contribution?.decimalValue ?? Decimal.zero
}
var formattedValue: String {
CurrencyFormatter.format(value?.decimalValue ?? Decimal.zero, style: .currency, maximumFractionDigits: 2)
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter.string(from: date)
}
var monthYearString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
}
// MARK: - Comparison with Previous
extension Snapshot {
func change(from previousSnapshot: Snapshot?) -> Decimal {
guard let previous = previousSnapshot,
let previousValue = previous.value?.decimalValue,
previousValue != Decimal.zero else {
return Decimal.zero
}
let currentValue = value?.decimalValue ?? Decimal.zero
return currentValue - previousValue
}
func percentageChange(from previousSnapshot: Snapshot?) -> Decimal {
guard let previous = previousSnapshot,
let previousValue = previous.value?.decimalValue,
previousValue != Decimal.zero else {
return Decimal.zero
}
let currentValue = value?.decimalValue ?? Decimal.zero
return ((currentValue - previousValue) / previousValue) * 100
}
}
@@ -0,0 +1,75 @@
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
return NSFetchRequest<Transaction>(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
}
}
+286
View File
@@ -0,0 +1,286 @@
import CoreData
import CloudKit
import Combine
import WidgetKit
class CoreDataStack: ObservableObject {
static let shared = CoreDataStack()
static let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
static let cloudKitContainerIdentifier = "iCloud.com.alexandrevazquez.portfoliojournal"
private static var cloudKitEnabled: Bool {
UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
}
private static var appGroupEnabled: Bool {
true
}
private static func migrateStoreIfNeeded(from sourceURL: URL, to destinationURL: URL) {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: sourceURL.path) else {
return
}
let sourceHasData = storeHasData(at: sourceURL)
let destinationHasData = storeHasData(at: destinationURL)
guard sourceHasData, !destinationHasData else { return }
removeStoreFilesIfNeeded(at: destinationURL)
let relatedSuffixes = ["", "-wal", "-shm"]
for suffix in relatedSuffixes {
let source = URL(fileURLWithPath: sourceURL.path + suffix)
let destination = URL(fileURLWithPath: destinationURL.path + suffix)
guard fileManager.fileExists(atPath: source.path),
!fileManager.fileExists(atPath: destination.path) else {
continue
}
do {
try fileManager.copyItem(at: source, to: destination)
} catch {
print("Core Data store migration failed for \(source.lastPathComponent): \(error)")
}
}
}
private static func removeStoreFilesIfNeeded(at url: URL) {
let fileManager = FileManager.default
let relatedSuffixes = ["", "-wal", "-shm"]
for suffix in relatedSuffixes {
let fileURL = URL(fileURLWithPath: url.path + suffix)
guard fileManager.fileExists(atPath: fileURL.path) else { continue }
do {
try fileManager.removeItem(at: fileURL)
} catch {
print("Failed to remove existing store file \(fileURL.lastPathComponent): \(error)")
}
}
}
private static func storeHasData(at url: URL) -> Bool {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: url.path),
let model = NSManagedObjectModel.mergedModel(from: [Bundle.main]) else {
return false
}
let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
do {
try coordinator.addPersistentStore(
ofType: NSSQLiteStoreType,
configurationName: nil,
at: url,
options: [NSReadOnlyPersistentStoreOption: true]
)
} catch {
print("Failed to open store for data check: \(error)")
return false
}
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = coordinator
let snapshotRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
snapshotRequest.resultType = .countResultType
let snapshotCount = (try? context.count(for: snapshotRequest)) ?? 0
if snapshotCount > 0 {
return true
}
let sourceRequest = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
sourceRequest.resultType = .countResultType
let sourceCount = (try? context.count(for: sourceRequest)) ?? 0
return sourceCount > 0
}
private static func resolveStoreURL() -> URL {
let defaultURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
guard appGroupEnabled,
let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent("PortfolioJournal.sqlite") else {
if appGroupEnabled {
print("App Group unavailable; using default store URL: \(defaultURL)")
}
return defaultURL
}
migrateStoreIfNeeded(from: defaultURL, to: appGroupURL)
return appGroupURL
}
lazy var persistentContainer: NSPersistentContainer = {
let container: NSPersistentContainer
if Self.cloudKitEnabled {
container = NSPersistentCloudKitContainer(name: "PortfolioJournal")
} else {
container = NSPersistentContainer(name: "PortfolioJournal")
}
// App Group store URL for sharing with widgets. Fall back if not entitled.
let storeURL = Self.resolveStoreURL()
let description = NSPersistentStoreDescription(url: storeURL)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
if Self.cloudKitEnabled {
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
if let error = error as NSError? {
// In production, handle this error appropriately
print("Core Data failed to load: \(error), \(error.userInfo)")
} else {
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
}
}
// Merge policy - remote changes win
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Performance optimizations
container.viewContext.undoManager = nil
container.viewContext.shouldDeleteInaccessibleFaults = true
if Self.cloudKitEnabled {
NotificationCenter.default.addObserver(
self,
selector: #selector(processRemoteChanges),
name: .NSPersistentStoreRemoteChange,
object: container.persistentStoreCoordinator
)
}
return container
}()
var viewContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
private init() {}
// MARK: - Save Context
func save() {
let context = viewContext
guard context.hasChanges else { return }
do {
try context.save()
} catch {
let nsError = error as NSError
print("Core Data save error: \(nsError), \(nsError.userInfo)")
}
}
// MARK: - Background Context
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.undoManager = nil
return context
}
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
persistentContainer.performBackgroundTask(block)
}
// MARK: - Remote Change Handling
@objc private func processRemoteChanges(_ notification: Notification) {
// Process remote changes on main context
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
// Ensure changes are persisted to disk before refreshing widget
self?.save()
self?.refreshWidgetData()
}
}
// MARK: - Widget Data Refresh
func refreshWidgetData() {
if #available(iOS 14.0, *) {
if Self.appGroupEnabled {
checkpointWAL()
WidgetCenter.shared.reloadAllTimelines()
}
}
}
/// Forces SQLite to checkpoint the WAL file, ensuring all changes are written to the main database file.
/// This is necessary for the widget to see changes, as it opens the database read-only.
private func checkpointWAL() {
if viewContext.hasChanges {
try? viewContext.save()
}
let coordinator = persistentContainer.persistentStoreCoordinator
coordinator.performAndWait {
viewContext.refreshAllObjects()
}
}
}
// MARK: - Shared Container for Widgets
extension CoreDataStack {
static var sharedStoreURL: URL? {
guard appGroupEnabled else {
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
return fallbackURL
}
if let appGroupURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent("PortfolioJournal.sqlite") {
return appGroupURL
}
let fallbackURL = NSPersistentContainer.defaultDirectoryURL()
.appendingPathComponent("PortfolioJournal.sqlite")
print("App Group unavailable for widgets; using default store URL: \(fallbackURL)")
return fallbackURL
}
/// Creates a lightweight Core Data stack for widgets (read-only)
static func createWidgetContainer() -> NSPersistentContainer {
let container = NSPersistentContainer(name: "PortfolioJournal")
guard let storeURL = sharedStoreURL else {
fatalError("Unable to get shared store URL")
}
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
print("Widget Core Data failed: \(error)")
}
}
return container
}
}
+25
View File
@@ -0,0 +1,25 @@
import Foundation
enum InputMode: String, CaseIterable, Identifiable {
case simple
case detailed
var id: String { rawValue }
var title: String {
switch self {
case .simple: return "Simple"
case .detailed: return "Detailed"
}
}
var description: String {
switch self {
case .simple:
return "Enter a single total value per snapshot."
case .detailed:
return "Enter invested amount and gains to track additional metrics."
}
}
}
@@ -0,0 +1,203 @@
import Foundation
struct InvestmentMetrics {
// Basic Metrics
let totalValue: Decimal
let totalContributions: Decimal
let absoluteReturn: Decimal
let percentageReturn: Decimal
// Advanced Metrics
let cagr: Double // Compound Annual Growth Rate
let twr: Double // Time-Weighted Return
let volatility: Double // Standard deviation annualized
let maxDrawdown: Double // Maximum peak-to-trough decline
let sharpeRatio: Double // Risk-adjusted return
let bestMonth: MonthlyReturn?
let worstMonth: MonthlyReturn?
let winRate: Double // Percentage of positive months
let averageMonthlyReturn: Double
// Time period
let startDate: Date?
let endDate: Date?
let totalMonths: Int
struct MonthlyReturn: Identifiable {
let id = UUID()
let date: Date
let returnPercentage: Double
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
var formattedReturn: String {
String(format: "%.1f%%", returnPercentage)
}
}
}
// MARK: - Formatting Helpers
extension InvestmentMetrics {
var formattedTotalValue: String {
formatCurrency(totalValue)
}
var formattedAbsoluteReturn: String {
let prefix = absoluteReturn >= 0 ? "+" : ""
return prefix + formatCurrency(absoluteReturn)
}
var formattedPercentageReturn: String {
let prefix = percentageReturn >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", NSDecimalNumber(decimal: percentageReturn).doubleValue)
}
var formattedCAGR: String {
String(format: "%.2f%%", cagr)
}
var formattedTWR: String {
String(format: "%.2f%%", twr)
}
var formattedVolatility: String {
String(format: "%.2f%%", volatility)
}
var formattedMaxDrawdown: String {
String(format: "%.2f%%", maxDrawdown)
}
var formattedSharpeRatio: String {
String(format: "%.2f", sharpeRatio)
}
var formattedWinRate: String {
String(format: "%.0f%%", winRate)
}
var formattedAverageMonthlyReturn: String {
let prefix = averageMonthlyReturn >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", averageMonthlyReturn)
}
private func formatCurrency(_ value: Decimal) -> String {
CurrencyFormatter.format(value, style: .currency, maximumFractionDigits: 2)
}
}
// MARK: - Empty State
extension InvestmentMetrics {
static var empty: InvestmentMetrics {
InvestmentMetrics(
totalValue: 0,
totalContributions: 0,
absoluteReturn: 0,
percentageReturn: 0,
cagr: 0,
twr: 0,
volatility: 0,
maxDrawdown: 0,
sharpeRatio: 0,
bestMonth: nil,
worstMonth: nil,
winRate: 0,
averageMonthlyReturn: 0,
startDate: nil,
endDate: nil,
totalMonths: 0
)
}
}
// MARK: - Category Metrics
struct CategoryMetrics: Identifiable {
let id: UUID
let categoryName: String
let colorHex: String
let icon: String
let totalValue: Decimal
let percentageOfPortfolio: Double
let metrics: InvestmentMetrics
var formattedTotalValue: String {
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedPercentage: String {
String(format: "%.1f%%", percentageOfPortfolio)
}
}
// MARK: - Portfolio Summary
struct PortfolioSummary {
let totalValue: Decimal
let totalContributions: Decimal
let dayChange: Decimal
let dayChangePercentage: Double
let weekChange: Decimal
let weekChangePercentage: Double
let monthChange: Decimal
let monthChangePercentage: Double
let yearChange: Decimal
let yearChangePercentage: Double
let allTimeReturn: Decimal
let allTimeReturnPercentage: Double
let sourceCount: Int
let lastUpdated: Date?
var formattedTotalValue: String {
CurrencyFormatter.format(totalValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDayChange: String {
formatChange(dayChange, dayChangePercentage)
}
var formattedMonthChange: String {
formatChange(monthChange, monthChangePercentage)
}
var formattedYearChange: String {
formatChange(yearChange, yearChangePercentage)
}
var formattedAllTimeReturn: String {
formatChange(allTimeReturn, allTimeReturnPercentage)
}
private func formatChange(_ absolute: Decimal, _ percentage: Double) -> String {
let prefix = absolute >= 0 ? "+" : ""
let absString = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
let pctString = String(format: "%.2f%%", percentage)
return "\(prefix)\(absString) (\(prefix)\(pctString))"
}
static var empty: PortfolioSummary {
PortfolioSummary(
totalValue: 0,
totalContributions: 0,
dayChange: 0,
dayChangePercentage: 0,
weekChange: 0,
weekChangePercentage: 0,
monthChange: 0,
monthChangePercentage: 0,
yearChange: 0,
yearChangePercentage: 0,
allTimeReturn: 0,
allTimeReturnPercentage: 0,
sourceCount: 0,
lastUpdated: nil
)
}
}
@@ -0,0 +1,98 @@
import Foundation
struct MonthlyCheckInEntry: Codable, Equatable {
var note: String?
var rating: Int?
var mood: MonthlyCheckInMood?
var completionTime: Double?
var createdAt: Double
var completionDate: Date? {
completionTime.map { Date(timeIntervalSince1970: $0) }
}
}
enum MonthlyCheckInMood: String, Codable, CaseIterable, Identifiable {
case energized
case confident
case balanced
case cautious
case stressed
var id: String { rawValue }
var title: String {
switch self {
case .energized: return String(localized: "mood_energized_title")
case .confident: return String(localized: "mood_confident_title")
case .balanced: return String(localized: "mood_balanced_title")
case .cautious: return String(localized: "mood_cautious_title")
case .stressed: return String(localized: "mood_stressed_title")
}
}
var iconName: String {
switch self {
case .energized: return "flame.fill"
case .confident: return "hand.thumbsup.fill"
case .balanced: return "leaf.fill"
case .cautious: return "exclamationmark.triangle.fill"
case .stressed: return "exclamationmark.octagon.fill"
}
}
var detail: String {
switch self {
case .energized: return String(localized: "mood_energized_detail")
case .confident: return String(localized: "mood_confident_detail")
case .balanced: return String(localized: "mood_balanced_detail")
case .cautious: return String(localized: "mood_cautious_detail")
case .stressed: return String(localized: "mood_stressed_detail")
}
}
}
struct MonthlyCheckInAchievement: Identifiable, Equatable {
let key: String
let title: String
let detail: String
let icon: String
var id: String { key }
}
struct MonthlyCheckInAchievementStatus: Identifiable, Equatable {
let achievement: MonthlyCheckInAchievement
let isUnlocked: Bool
var id: String { achievement.key }
}
struct MonthlyCheckInStats: Equatable {
let currentStreak: Int
let bestStreak: Int
let onTimeCount: Int
let totalCheckIns: Int
let averageDaysBeforeDeadline: Double?
let closestCutoffDays: Double?
let recentMood: MonthlyCheckInMood?
let achievements: [MonthlyCheckInAchievement]
static var empty: MonthlyCheckInStats {
MonthlyCheckInStats(
currentStreak: 0,
bestStreak: 0,
onTimeCount: 0,
totalCheckIns: 0,
averageDaysBeforeDeadline: nil,
closestCutoffDays: nil,
recentMood: nil,
achievements: []
)
}
var onTimeRate: Double {
guard totalCheckIns > 0 else { return 0 }
return Double(onTimeCount) / Double(totalCheckIns)
}
}
@@ -0,0 +1,73 @@
import Foundation
struct MonthlySummary {
let periodLabel: String
let startDate: Date
let endDate: Date
let startingValue: Decimal
let endingValue: Decimal
let contributions: Decimal
let netPerformance: Decimal
var netPerformancePercentage: Double {
let base = startingValue + contributions
guard base > 0 else { return 0 }
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
}
var formattedStartingValue: String {
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
}
var formattedEndingValue: String {
CurrencyFormatter.format(endingValue, style: .currency, maximumFractionDigits: 0)
}
var formattedContributions: String {
CurrencyFormatter.format(contributions, style: .currency, maximumFractionDigits: 0)
}
var formattedNetPerformance: String {
let prefix = netPerformance >= 0 ? "+" : ""
let value = CurrencyFormatter.format(netPerformance, style: .currency, maximumFractionDigits: 0)
return prefix + value
}
var formattedNetPerformancePercentage: String {
let prefix = netPerformance >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", netPerformancePercentage)
}
static var empty: MonthlySummary {
MonthlySummary(
periodLabel: "This Month",
startDate: Date(),
endDate: Date(),
startingValue: 0,
endingValue: 0,
contributions: 0,
netPerformance: 0
)
}
}
struct PortfolioChange {
let absolute: Decimal
let percentage: Double
let label: String
var formattedAbsolute: String {
let prefix = absolute >= 0 ? "+" : ""
let value = CurrencyFormatter.format(absolute, style: .currency, maximumFractionDigits: 0)
return prefix + value
}
var formattedPercentage: String {
let prefix = absolute >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", percentage)
}
static var empty: PortfolioChange {
PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
}
}
+105
View File
@@ -0,0 +1,105 @@
import Foundation
struct Prediction: Codable, Identifiable {
let id: UUID
let date: Date
let predictedValue: Decimal
let algorithm: PredictionAlgorithm
let confidenceInterval: ConfidenceInterval
init(
id: UUID = UUID(),
date: Date,
predictedValue: Decimal,
algorithm: PredictionAlgorithm,
confidenceInterval: ConfidenceInterval
) {
self.id = id
self.date = date
self.predictedValue = predictedValue
self.algorithm = algorithm
self.confidenceInterval = confidenceInterval
}
struct ConfidenceInterval: Codable {
let lower: Decimal
let upper: Decimal
var range: Decimal {
upper - lower
}
var percentageWidth: Decimal {
guard upper != 0 else { return 0 }
return (range / upper) * 100
}
}
}
// MARK: - Prediction Algorithm Codable
extension PredictionAlgorithm: Codable {}
// MARK: - Prediction Helpers
extension Prediction {
var formattedValue: String {
CurrencyFormatter.format(predictedValue, style: .currency, maximumFractionDigits: 0)
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM yyyy"
return formatter.string(from: date)
}
var formattedConfidenceRange: String {
let lower = CurrencyFormatter.format(
confidenceInterval.lower,
style: .currency,
maximumFractionDigits: 0
)
let upper = CurrencyFormatter.format(
confidenceInterval.upper,
style: .currency,
maximumFractionDigits: 0
)
return "\(lower) - \(upper)"
}
}
// MARK: - Prediction Result
struct PredictionResult {
let predictions: [Prediction]
let algorithm: PredictionAlgorithm
let accuracy: Double // R-squared or similar metric
let volatility: Double
var isHighConfidence: Bool {
accuracy >= 0.7
}
var confidenceLevel: ConfidenceLevel {
switch accuracy {
case 0.8...: return .high
case 0.5..<0.8: return .medium
default: return .low
}
}
enum ConfidenceLevel: String {
case high = "High"
case medium = "Medium"
case low = "Low"
var color: String {
switch self {
case .high: return "#10B981"
case .medium: return "#F59E0B"
case .low: return "#EF4444"
}
}
}
}