diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index c21caea..7a08b5a 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -459,7 +459,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -477,7 +477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -498,7 +498,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -516,7 +516,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -655,7 +655,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -668,7 +668,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -688,7 +688,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -701,7 +701,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -719,7 +719,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -728,7 +728,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -743,7 +743,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -752,7 +752,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -766,7 +766,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -775,7 +775,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -789,7 +789,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 35; + CURRENT_PROJECT_VERSION = 42; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -798,7 +798,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index a7b8bde..ddc746a 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -441,6 +441,7 @@ class CoreDataStack: ObservableObject { viewContext.refreshAllObjects() } } + } // MARK: - Shared Container for Widgets diff --git a/PortfolioJournal/Models/PortfolioInsight.swift b/PortfolioJournal/Models/PortfolioInsight.swift new file mode 100644 index 0000000..b5e2567 --- /dev/null +++ b/PortfolioJournal/Models/PortfolioInsight.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct PortfolioInsight: Identifiable { + let id: String + let systemImage: String + let title: String + let value: String + let accentColor: Color +} diff --git a/PortfolioJournal/Repositories/SnapshotRepository.swift b/PortfolioJournal/Repositories/SnapshotRepository.swift index 43b76ee..15cc4ad 100644 --- a/PortfolioJournal/Repositories/SnapshotRepository.swift +++ b/PortfolioJournal/Repositories/SnapshotRepository.swift @@ -148,6 +148,40 @@ class SnapshotRepository: ObservableObject { save() } + // MARK: - Contribution Propagation + + enum ContributionPropagation { + case forward // snapshots after the pivot + case backward // snapshots before the pivot + case all // every other snapshot of the source + } + + /// Applies `amount` as the contribution of other snapshots of the same source, + /// relative to `snapshot`'s date, according to `direction`. The pivot snapshot + /// itself is left untouched (it was already saved with its own value). + func propagateContribution( + _ amount: Decimal, + from snapshot: Snapshot, + direction: ContributionPropagation + ) { + guard let source = snapshot.source, + let set = source.snapshots as? Set else { return } + let pivot = snapshot.date + let targets = set.filter { other in + guard other.objectID != snapshot.objectID else { return false } + switch direction { + case .forward: return other.date > pivot + case .backward: return other.date < pivot + case .all: return true + } + } + let value = NSDecimalNumber(decimal: amount) + for target in targets { + target.contribution = value + } + save() + } + // MARK: - Delete func deleteSnapshot(_ snapshot: Snapshot) { diff --git a/PortfolioJournal/Resources/Info.plist b/PortfolioJournal/Resources/Info.plist index 180cadc..5172472 100644 --- a/PortfolioJournal/Resources/Info.plist +++ b/PortfolioJournal/Resources/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - $(MARKETING_VERSION) + 1.4.1 CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - $(CURRENT_PROJECT_VERSION) + 42 GADApplicationIdentifier ca-app-pub-1549720748100858~9632507420 GADDelayAppMeasurementInit diff --git a/PortfolioJournal/Resources/de.lproj/Localizable.strings b/PortfolioJournal/Resources/de.lproj/Localizable.strings index dda0244..11f0b0c 100644 --- a/PortfolioJournal/Resources/de.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/de.lproj/Localizable.strings @@ -311,6 +311,7 @@ "category_name_placeholder" = "z.B. Notfallfonds"; "chart_yoy_empty" = "Füge Snapshots über verschiedene Jahre hinzu, um den Vergleich zu sehen."; "chart_yoy_title" = "Jahr für Jahr"; +"chart_yoy_estimated_note" = "* geschätzt (Jahresend-Prognose)"; "checking_icloud" = "iCloud wird geprüft..."; "contributions_vs_returns_invested" = "Investiert"; "contributions_vs_returns_returns" = "Marktrendite"; @@ -354,6 +355,12 @@ "reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten."; "reengagement_title" = "Dein Portfolio wartet"; "snapshot_duplicate_add" = "Trotzdem hinzufügen"; +"snapshot_contribution_propagate_title" = "Beitrag anwenden"; +"snapshot_contribution_propagate_message" = "%@ auch als Beitrag auf andere Einträge anwenden?"; +"snapshot_contribution_propagate_forward" = "Auf spätere anwenden"; +"snapshot_contribution_propagate_backward" = "Auf frühere anwenden"; +"snapshot_contribution_propagate_all" = "Auf alle anwenden"; +"snapshot_contribution_propagate_this" = "Nur dieser Eintrag"; "snapshot_duplicate_message" = "Du hast bereits einen Snapshot für diesen Monat. Möchtest du einen weiteren hinzufügen?"; "snapshot_duplicate_replace" = "Vorhandenen ersetzen"; "snapshot_duplicate_title" = "Snapshot bereits vorhanden"; @@ -384,3 +391,26 @@ "whats_new_goals_title" = "Intelligentere Ziele & Journal"; "whats_new_goals_body" = "Verbesserte Leerzustände helfen dir, schneller zu starten."; "whats_new_continue" = "Los geht's"; +"source_monthly_contribution_title" = "Monatlicher Beitrag"; +"source_monthly_contribution_placeholder" = "z.B. 500"; +"source_monthly_contribution_not_set" = "Nicht konfiguriert"; +"source_monthly_contribution_hint" = "Wird in der Schnellaktualisierung vorausgefüllt"; +"quick_update_contribution_label" = "Beitrag"; +"quick_update_contribution_placeholder" = "Betrag"; +"source_monthly_contribution_apply_title" = "Beitrag anwenden"; +"source_monthly_contribution_apply_message" = "%@ auf alle früheren Snapshots ohne Beitrag anwenden?"; +"source_monthly_contribution_apply_retroactive" = "Auf alle früheren Snapshots anwenden"; +"source_monthly_contribution_apply_forward" = "Nur ab jetzt"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Einblicke"; +"insight_milestone_title" = "Meilenstein in Sicht"; +"insight_milestone_value" = "%1$@ bis %2$@"; +"insight_ytd_title" = "Jahresbeginn"; +"insight_market_gains_title" = "Marktgewinne"; +"insight_market_gains_value" = "+%@ aus Märkten"; +"insight_streak_title" = "Tracking-Serie"; +"insight_streak_value" = "%d Monate in Folge"; +"insight_forecast_title" = "Auf dem Weg zu"; +"notification_milestone_title" = "Portfolio-Meilenstein! 🎉"; +"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!"; diff --git a/PortfolioJournal/Resources/en.lproj/Localizable.strings b/PortfolioJournal/Resources/en.lproj/Localizable.strings index 6bed3f5..6c4782f 100644 --- a/PortfolioJournal/Resources/en.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/en.lproj/Localizable.strings @@ -354,12 +354,19 @@ "snapshot_duplicate_message" = "You already have a snapshot for this month. Do you want to add another one?"; "snapshot_duplicate_replace" = "Replace Existing"; "snapshot_duplicate_add" = "Add Anyway"; +"snapshot_contribution_propagate_title" = "Apply Contribution"; +"snapshot_contribution_propagate_message" = "Apply %@ as the contribution to other snapshots too?"; +"snapshot_contribution_propagate_forward" = "Apply Going Forward"; +"snapshot_contribution_propagate_backward" = "Apply to Earlier"; +"snapshot_contribution_propagate_all" = "Apply to All"; +"snapshot_contribution_propagate_this" = "Only This Snapshot"; "contributions_vs_returns_title" = "Invested vs. Returns"; "contributions_vs_returns_invested" = "Invested"; "contributions_vs_returns_returns" = "Market Returns"; "chart_yoy_title" = "Year over Year"; +"chart_yoy_estimated_note" = "* estimated (year-end forecast)"; "chart_yoy_empty" = "Add more snapshots across different years to see the comparison."; // MARK: - Goal Delete Confirmation (1.3.2) @@ -392,3 +399,26 @@ "whats_new_goals_title" = "Smarter Goals & Journal"; "whats_new_goals_body" = "Improved empty states help you get started faster."; "whats_new_continue" = "Let's Go"; +"source_monthly_contribution_title" = "Monthly Contribution"; +"source_monthly_contribution_placeholder" = "e.g. 500"; +"source_monthly_contribution_not_set" = "Not configured"; +"source_monthly_contribution_hint" = "Pre-filled as contribution in Quick Update"; +"quick_update_contribution_label" = "Contribution"; +"quick_update_contribution_placeholder" = "Amount"; +"source_monthly_contribution_apply_title" = "Apply Contribution"; +"source_monthly_contribution_apply_message" = "Apply %@ to all past snapshots that don't have a contribution?"; +"source_monthly_contribution_apply_retroactive" = "Apply to All Past Snapshots"; +"source_monthly_contribution_apply_forward" = "Only Going Forward"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Insights"; +"insight_milestone_title" = "Milestone ahead"; +"insight_milestone_value" = "%1$@ from %2$@"; +"insight_ytd_title" = "Year to date"; +"insight_market_gains_title" = "Market gains"; +"insight_market_gains_value" = "+%@ from markets"; +"insight_streak_title" = "Tracking streak"; +"insight_streak_value" = "%d months in a row"; +"insight_forecast_title" = "On track for"; +"notification_milestone_title" = "Portfolio Milestone! 🎉"; +"notification_milestone_body" = "Your portfolio just passed %@. Congrats!"; diff --git a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings index 4a8c265..6832929 100644 --- a/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/es-ES.lproj/Localizable.strings @@ -295,12 +295,19 @@ "snapshot_duplicate_message" = "Ya tienes un snapshot para este mes. ¿Quieres añadir otro?"; "snapshot_duplicate_replace" = "Reemplazar existente"; "snapshot_duplicate_add" = "Añadir de todos modos"; +"snapshot_contribution_propagate_title" = "Aplicar aportación"; +"snapshot_contribution_propagate_message" = "¿Aplicar %@ como aportación también a otros registros?"; +"snapshot_contribution_propagate_forward" = "Aplicar hacia adelante"; +"snapshot_contribution_propagate_backward" = "Aplicar hacia atrás"; +"snapshot_contribution_propagate_all" = "Aplicar a todos"; +"snapshot_contribution_propagate_this" = "Solo este registro"; "contributions_vs_returns_title" = "Invertido vs. Rentabilidad"; "contributions_vs_returns_invested" = "Invertido"; "contributions_vs_returns_returns" = "Rentabilidad de mercado"; "chart_yoy_title" = "Año a año"; +"chart_yoy_estimated_note" = "* estimado (previsión de cierre de año)"; "chart_yoy_empty" = "Añade más snapshots en diferentes años para ver la comparación."; "Year vs Year" = "Año a año"; @@ -336,3 +343,26 @@ "whats_new_goals_body" = "Los estados vacíos mejorados te ayudan a empezar más rápido."; "whats_new_continue" = "¡Vamos!"; "goal_delete_confirm" = "Eliminar"; +"source_monthly_contribution_title" = "Aportación Mensual"; +"source_monthly_contribution_placeholder" = "ej. 500"; +"source_monthly_contribution_not_set" = "No configurada"; +"source_monthly_contribution_hint" = "Se rellena automáticamente en Actualización Rápida"; +"quick_update_contribution_label" = "Aportación"; +"quick_update_contribution_placeholder" = "Importe"; +"source_monthly_contribution_apply_title" = "Aplicar Aportación"; +"source_monthly_contribution_apply_message" = "¿Aplicar %@ a todos los snapshots anteriores sin aportación?"; +"source_monthly_contribution_apply_retroactive" = "Aplicar a Todos los Históricos"; +"source_monthly_contribution_apply_forward" = "Solo a Partir de Ahora"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Perspectivas"; +"insight_milestone_title" = "Hito cerca"; +"insight_milestone_value" = "%1$@ para %2$@"; +"insight_ytd_title" = "Año en curso"; +"insight_market_gains_title" = "Ganancias de mercado"; +"insight_market_gains_value" = "+%@ en mercados"; +"insight_streak_title" = "Racha de seguimiento"; +"insight_streak_value" = "%d meses consecutivos"; +"insight_forecast_title" = "En camino hacia"; +"notification_milestone_title" = "¡Hito de cartera! 🎉"; +"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!"; diff --git a/PortfolioJournal/Resources/fr.lproj/Localizable.strings b/PortfolioJournal/Resources/fr.lproj/Localizable.strings index 8db691d..54f319c 100644 --- a/PortfolioJournal/Resources/fr.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/fr.lproj/Localizable.strings @@ -311,6 +311,7 @@ "category_name_placeholder" = "ex. Fonds d'urgence"; "chart_yoy_empty" = "Ajoutez plus de snapshots sur différentes années pour voir la comparaison."; "chart_yoy_title" = "Année par année"; +"chart_yoy_estimated_note" = "* estimé (prévision de fin d'année)"; "checking_icloud" = "Vérification d'iCloud..."; "contributions_vs_returns_invested" = "Investi"; "contributions_vs_returns_returns" = "Rendement du marché"; @@ -354,6 +355,12 @@ "reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements."; "reengagement_title" = "Votre portefeuille vous attend"; "snapshot_duplicate_add" = "Ajouter quand même"; +"snapshot_contribution_propagate_title" = "Appliquer la contribution"; +"snapshot_contribution_propagate_message" = "Appliquer %@ comme contribution aux autres relevés aussi ?"; +"snapshot_contribution_propagate_forward" = "Appliquer aux suivants"; +"snapshot_contribution_propagate_backward" = "Appliquer aux précédents"; +"snapshot_contribution_propagate_all" = "Appliquer à tous"; +"snapshot_contribution_propagate_this" = "Ce relevé uniquement"; "snapshot_duplicate_message" = "Vous avez déjà un snapshot pour ce mois. Voulez-vous en ajouter un autre ?"; "snapshot_duplicate_replace" = "Remplacer l'existant"; "snapshot_duplicate_title" = "Snapshot déjà existant"; @@ -384,3 +391,26 @@ "whats_new_goals_title" = "Objectifs et journal plus intelligents"; "whats_new_goals_body" = "Les états vides améliorés vous aident à démarrer plus rapidement."; "whats_new_continue" = "C'est parti !"; +"source_monthly_contribution_title" = "Apport Mensuel"; +"source_monthly_contribution_placeholder" = "ex. 500"; +"source_monthly_contribution_not_set" = "Non configuré"; +"source_monthly_contribution_hint" = "Pré-rempli dans Mise à jour rapide"; +"quick_update_contribution_label" = "Apport"; +"quick_update_contribution_placeholder" = "Montant"; +"source_monthly_contribution_apply_title" = "Appliquer l'apport"; +"source_monthly_contribution_apply_message" = "Appliquer %@ à tous les snapshots passés sans apport ?"; +"source_monthly_contribution_apply_retroactive" = "Appliquer à tous les anciens snapshots"; +"source_monthly_contribution_apply_forward" = "Seulement à partir de maintenant"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Aperçus"; +"insight_milestone_title" = "Jalon en vue"; +"insight_milestone_value" = "%1$@ de %2$@"; +"insight_ytd_title" = "Depuis le début de l'année"; +"insight_market_gains_title" = "Gains de marché"; +"insight_market_gains_value" = "+%@ des marchés"; +"insight_streak_title" = "Série de suivi"; +"insight_streak_value" = "%d mois consécutifs"; +"insight_forecast_title" = "En bonne voie pour"; +"notification_milestone_title" = "Jalon de portefeuille ! 🎉"; +"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !"; diff --git a/PortfolioJournal/Resources/it.lproj/Localizable.strings b/PortfolioJournal/Resources/it.lproj/Localizable.strings index 38ffc1d..a35dd0d 100644 --- a/PortfolioJournal/Resources/it.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/it.lproj/Localizable.strings @@ -311,6 +311,7 @@ "category_name_placeholder" = "es. Fondo di emergenza"; "chart_yoy_empty" = "Aggiungi più snapshot in anni diversi per vedere il confronto."; "chart_yoy_title" = "Anno su anno"; +"chart_yoy_estimated_note" = "* stimato (previsione di fine anno)"; "checking_icloud" = "Verifica iCloud..."; "contributions_vs_returns_invested" = "Investito"; "contributions_vs_returns_returns" = "Rendimento di mercato"; @@ -354,6 +355,12 @@ "reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti."; "reengagement_title" = "Il tuo portafoglio ti aspetta"; "snapshot_duplicate_add" = "Aggiungi comunque"; +"snapshot_contribution_propagate_title" = "Applica contributo"; +"snapshot_contribution_propagate_message" = "Applicare %@ come contributo anche agli altri snapshot?"; +"snapshot_contribution_propagate_forward" = "Applica ai successivi"; +"snapshot_contribution_propagate_backward" = "Applica ai precedenti"; +"snapshot_contribution_propagate_all" = "Applica a tutti"; +"snapshot_contribution_propagate_this" = "Solo questo snapshot"; "snapshot_duplicate_message" = "Hai già uno snapshot per questo mese. Vuoi aggiungerne un altro?"; "snapshot_duplicate_replace" = "Sostituisci esistente"; "snapshot_duplicate_title" = "Snapshot già esistente"; @@ -384,3 +391,26 @@ "whats_new_goals_title" = "Obiettivi e diario più intelligenti"; "whats_new_goals_body" = "Gli stati vuoti migliorati ti aiutano a iniziare più velocemente."; "whats_new_continue" = "Iniziamo!"; +"source_monthly_contribution_title" = "Contributo Mensile"; +"source_monthly_contribution_placeholder" = "es. 500"; +"source_monthly_contribution_not_set" = "Non configurato"; +"source_monthly_contribution_hint" = "Pre-compilato in Aggiornamento Rapido"; +"quick_update_contribution_label" = "Contributo"; +"quick_update_contribution_placeholder" = "Importo"; +"source_monthly_contribution_apply_title" = "Applica Contributo"; +"source_monthly_contribution_apply_message" = "Applicare %@ a tutti gli snapshot passati senza contributo?"; +"source_monthly_contribution_apply_retroactive" = "Applica a tutti i snapshot passati"; +"source_monthly_contribution_apply_forward" = "Solo da adesso in poi"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Approfondimenti"; +"insight_milestone_title" = "Traguardo vicino"; +"insight_milestone_value" = "%1$@ da %2$@"; +"insight_ytd_title" = "Da inizio anno"; +"insight_market_gains_title" = "Guadagni di mercato"; +"insight_market_gains_value" = "+%@ dai mercati"; +"insight_streak_title" = "Serie di aggiornamenti"; +"insight_streak_value" = "%d mesi consecutivi"; +"insight_forecast_title" = "In direzione di"; +"notification_milestone_title" = "Traguardo raggiunto! 🎉"; +"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!"; diff --git a/PortfolioJournal/Resources/ja.lproj/Localizable.strings b/PortfolioJournal/Resources/ja.lproj/Localizable.strings index 105fa20..e754441 100644 --- a/PortfolioJournal/Resources/ja.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/ja.lproj/Localizable.strings @@ -311,6 +311,7 @@ "category_name_placeholder" = "例:緊急資金"; "chart_yoy_empty" = "比較を表示するには、異なる年のスナップショットを追加してください。"; "chart_yoy_title" = "年次比較"; +"chart_yoy_estimated_note" = "* 推定(年末予測)"; "checking_icloud" = "iCloudを確認中..."; "contributions_vs_returns_invested" = "投資額"; "contributions_vs_returns_returns" = "市場リターン"; @@ -354,6 +355,12 @@ "reengagement_body" = "数分で投資の状況を把握できます。"; "reengagement_title" = "ポートフォリオが待っています"; "snapshot_duplicate_add" = "とにかく追加"; +"snapshot_contribution_propagate_title" = "拠出を適用"; +"snapshot_contribution_propagate_message" = "%@ を他のスナップショットにも拠出として適用しますか?"; +"snapshot_contribution_propagate_forward" = "以降に適用"; +"snapshot_contribution_propagate_backward" = "以前に適用"; +"snapshot_contribution_propagate_all" = "すべてに適用"; +"snapshot_contribution_propagate_this" = "このスナップショットのみ"; "snapshot_duplicate_message" = "今月のスナップショットはすでにあります。別のスナップショットを追加しますか?"; "snapshot_duplicate_replace" = "既存を置き換え"; "snapshot_duplicate_title" = "スナップショットがすでに存在します"; @@ -384,3 +391,26 @@ "whats_new_goals_title" = "スマートな目標とジャーナル"; "whats_new_goals_body" = "改善された空の状態でより素早く始められます。"; "whats_new_continue" = "始めよう"; +"source_monthly_contribution_title" = "月次積立額"; +"source_monthly_contribution_placeholder" = "例: 500"; +"source_monthly_contribution_not_set" = "未設定"; +"source_monthly_contribution_hint" = "クイック更新で自動入力されます"; +"quick_update_contribution_label" = "積立"; +"quick_update_contribution_placeholder" = "金額"; +"source_monthly_contribution_apply_title" = "積立の適用"; +"source_monthly_contribution_apply_message" = "積立なしの過去のスナップショットすべてに%@を適用しますか?"; +"source_monthly_contribution_apply_retroactive" = "過去のスナップショットにも適用"; +"source_monthly_contribution_apply_forward" = "今後のみ適用"; + +// MARK: - Portfolio Insights +"insights_section_title" = "インサイト"; +"insight_milestone_title" = "目標まであと少し"; +"insight_milestone_value" = "%2$@まであと%1$@"; +"insight_ytd_title" = "年初来"; +"insight_market_gains_title" = "市場利益"; +"insight_market_gains_value" = "市場から+%@"; +"insight_streak_title" = "追跡連続記録"; +"insight_streak_value" = "%dヶ月連続"; +"insight_forecast_title" = "目標に向けて順調"; +"notification_milestone_title" = "ポートフォリオ達成!🎉"; +"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!"; diff --git a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings index 0da29e5..180ece2 100644 --- a/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings +++ b/PortfolioJournal/Resources/pt-BR.lproj/Localizable.strings @@ -311,6 +311,7 @@ "category_name_placeholder" = "ex.: Reserva de emergência"; "chart_yoy_empty" = "Adicione mais snapshots em anos diferentes para ver a comparação."; "chart_yoy_title" = "Ano a ano"; +"chart_yoy_estimated_note" = "* estimado (previsão de fim de ano)"; "checking_icloud" = "Verificando iCloud..."; "contributions_vs_returns_invested" = "Investido"; "contributions_vs_returns_returns" = "Retorno do mercado"; @@ -354,6 +355,12 @@ "reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos."; "reengagement_title" = "Seu portfólio está esperando"; "snapshot_duplicate_add" = "Adicionar mesmo assim"; +"snapshot_contribution_propagate_title" = "Aplicar contribuição"; +"snapshot_contribution_propagate_message" = "Aplicar %@ como contribuição também a outros registros?"; +"snapshot_contribution_propagate_forward" = "Aplicar adiante"; +"snapshot_contribution_propagate_backward" = "Aplicar para trás"; +"snapshot_contribution_propagate_all" = "Aplicar a todos"; +"snapshot_contribution_propagate_this" = "Apenas este registro"; "snapshot_duplicate_message" = "Você já tem um snapshot para este mês. Deseja adicionar outro?"; "snapshot_duplicate_replace" = "Substituir existente"; "snapshot_duplicate_title" = "Snapshot já existe"; @@ -384,3 +391,26 @@ "whats_new_goals_title" = "Metas e diário mais inteligentes"; "whats_new_goals_body" = "Estados vazios melhorados ajudam você a começar mais rápido."; "whats_new_continue" = "Vamos lá!"; +"source_monthly_contribution_title" = "Aporte Mensal"; +"source_monthly_contribution_placeholder" = "ex. 500"; +"source_monthly_contribution_not_set" = "Não configurado"; +"source_monthly_contribution_hint" = "Preenchido automaticamente na Atualização Rápida"; +"quick_update_contribution_label" = "Aporte"; +"quick_update_contribution_placeholder" = "Valor"; +"source_monthly_contribution_apply_title" = "Aplicar Aporte"; +"source_monthly_contribution_apply_message" = "Aplicar %@ a todos os snapshots anteriores sem aporte?"; +"source_monthly_contribution_apply_retroactive" = "Aplicar a Todos os Snapshots Anteriores"; +"source_monthly_contribution_apply_forward" = "Apenas daqui para frente"; + +// MARK: - Portfolio Insights +"insights_section_title" = "Insights"; +"insight_milestone_title" = "Meta à vista"; +"insight_milestone_value" = "%1$@ para %2$@"; +"insight_ytd_title" = "No ano"; +"insight_market_gains_title" = "Ganhos de mercado"; +"insight_market_gains_value" = "+%@ dos mercados"; +"insight_streak_title" = "Sequência de atualizações"; +"insight_streak_value" = "%d meses consecutivos"; +"insight_forecast_title" = "Caminhando para"; +"notification_milestone_title" = "Meta de portfólio! 🎉"; +"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!"; diff --git a/PortfolioJournal/Services/NotificationService.swift b/PortfolioJournal/Services/NotificationService.swift index 000c06e..98e3efb 100644 --- a/PortfolioJournal/Services/NotificationService.swift +++ b/PortfolioJournal/Services/NotificationService.swift @@ -219,14 +219,28 @@ extension NotificationService { } } - /// Schedules a monthly check-in notification on the 1st of each month at 9am. - /// Only schedules if not already pending. + /// Schedules a non-repeating monthly check-in notification for the 1st of the next month + /// that doesn't have a completed check-in. Safe to call on every app activation. func scheduleMonthlyCheckIn() { guard isAuthorized else { return } let identifier = "monthly_checkin" - center.getPendingNotificationRequests { [weak self] requests in - guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return } + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + + let calendar = Calendar.current + let now = Date() + guard let thisMonthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) else { return } + + // Walk forward from next month to find the first month whose check-in is not done + for offset in 1...13 { + guard let targetStart = calendar.date(byAdding: .month, value: offset, to: thisMonthStart) else { break } + let isDone = MonthlyCheckInStore.completionDate(for: targetStart.adding(days: 1)) != nil + if isDone { continue } + + var components = calendar.dateComponents([.year, .month], from: targetStart) + components.day = 1 + components.hour = 9 + components.minute = 0 let content = UNMutableNotificationContent() content.title = String(localized: "monthly_checkin_notification_title") @@ -234,19 +248,15 @@ extension NotificationService { content.sound = .default content.userInfo = ["action": "batchUpdate"] - var components = DateComponents() - components.day = 1 - components.hour = 9 - components.minute = 0 - - let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - self.center.add(request) { error in + center.add(request) { error in if let error = error { print("Monthly check-in notification error: \(error)") } } + return } } @@ -299,6 +309,42 @@ extension NotificationService { } } } + + /// Fires a notification when the portfolio crosses a round milestone for the first time. + func checkAndScheduleMilestoneNotification(portfolioValue: Decimal) { + guard isAuthorized else { return } + + let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000, + 100000, 250000, 500000, 1_000_000] + let notifiedKey = "lastNotifiedMilestone" + let lastNotified = UserDefaults.standard.double(forKey: notifiedKey) + let current = NSDecimalNumber(decimal: portfolioValue).doubleValue + + for milestone in milestones.reversed() { + let ms = NSDecimalNumber(decimal: milestone).doubleValue + if current >= ms { + if ms > lastNotified { + UserDefaults.standard.set(ms, forKey: notifiedKey) + let milestoneStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0) + + let content = UNMutableNotificationContent() + content.title = String(localized: "notification_milestone_title") + content.body = String(format: String(localized: "notification_milestone_body"), milestoneStr) + content.sound = .default + content.userInfo = ["action": "openDashboard"] + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "portfolio_milestone_\(Int(ms))", + content: content, + trigger: trigger + ) + center.add(request) { _ in } + } + break + } + } + } } // MARK: - Background Refresh diff --git a/PortfolioJournal/Services/PredictionEngine.swift b/PortfolioJournal/Services/PredictionEngine.swift index a64740d..c66efb7 100644 --- a/PortfolioJournal/Services/PredictionEngine.swift +++ b/PortfolioJournal/Services/PredictionEngine.swift @@ -3,370 +3,192 @@ import Foundation class PredictionEngine { static let shared = PredictionEngine() - private let context = CoreDataStack.shared.viewContext - - // MARK: - Performance: Cached Calendar reference private static let calendar = Calendar.current private init() {} - // MARK: - Main Prediction Interface + // MARK: - Public Interface - func predict( - snapshots: [Snapshot], - monthsAhead: Int = 12, - algorithm: PredictionAlgorithm? = nil - ) -> PredictionResult { - guard snapshots.count >= 3 else { - return PredictionResult( - predictions: [], - algorithm: .linear, - accuracy: 0, - volatility: 0 - ) - } + func predict(snapshots: [Snapshot], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult { + guard snapshots.count >= 3 else { return emptyResult() } + let sorted = snapshots.sorted { $0.date < $1.date } + return predictFromValues( + sorted.map { $0.decimalValue.doubleValue }, + dates: sorted.map { $0.date }, + monthsAhead: monthsAhead, + algorithm: algorithm + ) + } - // Sort snapshots by date - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } + func predict(series: [(date: Date, value: Decimal)], monthsAhead: Int = 12, algorithm: PredictionAlgorithm? = nil) -> PredictionResult { + guard series.count >= 3 else { return emptyResult() } + let sorted = series.sorted { $0.date < $1.date } + return predictFromValues( + sorted.map { NSDecimalNumber(decimal: $0.value).doubleValue }, + dates: sorted.map { $0.date }, + monthsAhead: monthsAhead, + algorithm: algorithm + ) + } - // Calculate volatility for algorithm selection - let volatility = calculateVolatility(snapshots: sortedSnapshots) + // MARK: - Private Core - // Select algorithm if not specified + private func emptyResult() -> PredictionResult { + PredictionResult(predictions: [], algorithm: .linear, accuracy: 0, volatility: 0) + } + + private func predictFromValues(_ values: [Double], dates: [Date], monthsAhead: Int, algorithm: PredictionAlgorithm?) -> PredictionResult { + let volatility = calculateVolatility(values: values) let selectedAlgorithm = algorithm ?? selectBestAlgorithm(volatility: volatility) + let lastDate = dates.last! + let firstDate = dates.first! - // Generate predictions let predictions: [Prediction] let accuracy: Double switch selectedAlgorithm { case .linear: - predictions = predictLinear(snapshots: sortedSnapshots, monthsAhead: monthsAhead) - accuracy = calculateLinearAccuracy(snapshots: sortedSnapshots) + predictions = linearPredictions(values: values, firstDate: firstDate, lastDate: lastDate, monthsAhead: monthsAhead) + accuracy = linearAccuracy(values: values, firstDate: firstDate) case .exponentialSmoothing: - predictions = predictExponentialSmoothing(snapshots: sortedSnapshots, monthsAhead: monthsAhead) - accuracy = calculateESAccuracy(snapshots: sortedSnapshots) + predictions = esPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead) + accuracy = esAccuracy(values: values) case .movingAverage: - predictions = predictMovingAverage(snapshots: sortedSnapshots, monthsAhead: monthsAhead) - accuracy = calculateMAAccuracy(snapshots: sortedSnapshots) + predictions = maPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead) + accuracy = maAccuracy(values: values) case .holtTrend: - predictions = predictHoltTrend(snapshots: sortedSnapshots, monthsAhead: monthsAhead) - accuracy = calculateHoltAccuracy(snapshots: sortedSnapshots) + predictions = holtPredictions(values: values, lastDate: lastDate, monthsAhead: monthsAhead) + accuracy = holtAccuracy(values: values) } - return PredictionResult( - predictions: predictions, - algorithm: selectedAlgorithm, - accuracy: accuracy, - volatility: volatility - ) + return PredictionResult(predictions: predictions, algorithm: selectedAlgorithm, accuracy: accuracy, volatility: volatility) } // MARK: - Algorithm Selection private func selectBestAlgorithm(volatility: Double) -> PredictionAlgorithm { switch volatility { - case 0..<8: - return .holtTrend - case 8..<20: - return .exponentialSmoothing - default: - return .movingAverage + case 0..<8: return .holtTrend + case 8..<20: return .exponentialSmoothing + default: return .movingAverage } } // MARK: - Linear Regression - func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] { - guard snapshots.count >= 3 else { return [] } - - guard let firstDate = snapshots.first?.date else { return [] } - - let dataPoints: [(x: Double, y: Double)] = snapshots.map { snapshot in - let daysSinceStart = snapshot.date.timeIntervalSince(firstDate) / 86400 - return (x: daysSinceStart, y: snapshot.decimalValue.doubleValue) - } - + private func linearPredictions(values: [Double], firstDate: Date, lastDate: Date, monthsAhead: Int) -> [Prediction] { + let dataPoints = values.enumerated().map { (x: Double($0.offset), y: $0.element) } let (slope, intercept) = calculateLinearRegression(dataPoints: dataPoints) let residualStdDev = calculateResidualStdDev(dataPoints: dataPoints, slope: slope, intercept: intercept) + let n = Double(values.count) - var predictions: [Prediction] = [] - let lastDate = snapshots.last!.date - - for month in 1...monthsAhead { - guard let futureDate = Self.calendar.date( - byAdding: .month, - value: month, - to: lastDate - ) else { continue } - - let daysFromStart = futureDate.timeIntervalSince(firstDate) / 86400 - let predictedValue = max(0, slope * daysFromStart + intercept) - - // Widen confidence interval for further predictions - let confidenceMultiplier = 1.0 + (Double(month) * 0.02) - let intervalWidth = residualStdDev * 1.96 * confidenceMultiplier - - predictions.append(Prediction( - date: futureDate, - predictedValue: Decimal(predictedValue), - algorithm: .linear, - confidenceInterval: Prediction.ConfidenceInterval( - lower: Decimal(max(0, predictedValue - intervalWidth)), - upper: Decimal(predictedValue + intervalWidth) - ) - )) + return (1...monthsAhead).compactMap { month in + guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil } + let x = n - 1 + Double(month) + let predicted = max(0, slope * x + intercept) + let width = residualStdDev * 1.96 * (1.0 + Double(month) * 0.02) + return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .linear, + confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width))) } - - return predictions } - private func calculateLinearRegression( - dataPoints: [(x: Double, y: Double)] - ) -> (slope: Double, intercept: Double) { - let n = Double(dataPoints.count) - let sumX = dataPoints.reduce(0) { $0 + $1.x } - let sumY = dataPoints.reduce(0) { $0 + $1.y } - let sumXY = dataPoints.reduce(0) { $0 + ($1.x * $1.y) } - let sumX2 = dataPoints.reduce(0) { $0 + ($1.x * $1.x) } - - let denominator = n * sumX2 - sumX * sumX - guard denominator != 0 else { return (0, sumY / n) } - - let slope = (n * sumXY - sumX * sumY) / denominator - let intercept = (sumY - slope * sumX) / n - - return (slope, intercept) - } - - private func calculateResidualStdDev( - dataPoints: [(x: Double, y: Double)], - slope: Double, - intercept: Double - ) -> Double { - guard dataPoints.count > 2 else { return 0 } - - let residuals = dataPoints.map { point in - let predicted = slope * point.x + intercept - return pow(point.y - predicted, 2) - } - - let meanSquaredError = residuals.reduce(0, +) / Double(dataPoints.count - 2) - return sqrt(meanSquaredError) - } - - private func calculateLinearAccuracy(snapshots: [Snapshot]) -> Double { - guard snapshots.count >= 5 else { return 0.5 } - - // Use last 20% of data for validation - let splitIndex = Int(Double(snapshots.count) * 0.8) - let trainingData = Array(snapshots.prefix(splitIndex)) - let validationData = Array(snapshots.suffix(from: splitIndex)) - - guard let firstDate = trainingData.first?.date else { return 0.5 } - - let trainPoints = trainingData.map { snapshot in - (x: snapshot.date.timeIntervalSince(firstDate) / 86400, y: snapshot.decimalValue.doubleValue) - } - + private func linearAccuracy(values: [Double], firstDate: Date) -> Double { + guard values.count >= 5 else { return 0.5 } + let splitIndex = Int(Double(values.count) * 0.8) + let trainPoints = Array(values.prefix(splitIndex)).enumerated().map { (x: Double($0.offset), y: $0.element) } let (slope, intercept) = calculateLinearRegression(dataPoints: trainPoints) - - // Calculate R-squared on validation data - let validationValues = validationData.map { $0.decimalValue.doubleValue } - let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count) - - var ssRes: Double = 0 - var ssTot: Double = 0 - - for snapshot in validationData { - let x = snapshot.date.timeIntervalSince(firstDate) / 86400 - let actual = snapshot.decimalValue.doubleValue - let predicted = slope * x + intercept - - ssRes += pow(actual - predicted, 2) - ssTot += pow(actual - meanValidation, 2) + let validation = Array(values.suffix(from: splitIndex)) + let mean = validation.reduce(0, +) / Double(validation.count) + var ssRes = 0.0, ssTot = 0.0 + for (i, actual) in validation.enumerated() { + let x = Double(splitIndex + i) + ssRes += pow(actual - (slope * x + intercept), 2) + ssTot += pow(actual - mean, 2) } - guard ssTot != 0 else { return 0.5 } - let rSquared = max(0, 1 - (ssRes / ssTot)) - - return min(1.0, rSquared) + return min(1.0, max(0, 1 - ssRes / ssTot)) } // MARK: - Exponential Smoothing - func predictExponentialSmoothing( - snapshots: [Snapshot], - monthsAhead: Int = 12, - alpha: Double = 0.3 - ) -> [Prediction] { - guard snapshots.count >= 3 else { return [] } - - let values = snapshots.map { $0.decimalValue.doubleValue } - - // Calculate smoothed values + private func esPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.3) -> [Prediction] { var smoothed = values[0] - for i in 1..= 2 { - let recentChange = values.suffix(3).reduce(0) { $0 + $1 } / 3.0 - - values.prefix(3).reduce(0) { $0 + $1 } / 3.0 - trend = recentChange / Double(values.count) - } - - // Calculate standard deviation for confidence interval + let trend: Double = values.count >= 2 + ? (values.suffix(3).reduce(0, +) / Double(min(3, values.count)) - + values.prefix(3).reduce(0, +) / Double(min(3, values.count))) / Double(values.count) + : 0 let stdDev = calculateStdDev(values: values) - var predictions: [Prediction] = [] - let lastDate = snapshots.last!.date - - for month in 1...monthsAhead { - guard let futureDate = Self.calendar.date( - byAdding: .month, - value: month, - to: lastDate - ) else { continue } - - let predictedValue = max(0, smoothed + trend * Double(month)) - let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.05) - - predictions.append(Prediction( - date: futureDate, - predictedValue: Decimal(predictedValue), - algorithm: .exponentialSmoothing, - confidenceInterval: Prediction.ConfidenceInterval( - lower: Decimal(max(0, predictedValue - intervalWidth)), - upper: Decimal(predictedValue + intervalWidth) - ) - )) + return (1...monthsAhead).compactMap { month in + guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil } + let predicted = max(0, smoothed + trend * Double(month)) + let width = stdDev * 1.96 * (1.0 + Double(month) * 0.05) + return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .exponentialSmoothing, + confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width))) } - - return predictions } - private func calculateESAccuracy(snapshots: [Snapshot]) -> Double { - guard snapshots.count >= 5 else { return 0.5 } - - let values = snapshots.map { $0.decimalValue.doubleValue } + private func esAccuracy(values: [Double]) -> Double { + guard values.count >= 5 else { return 0.5 } let splitIndex = Int(Double(values.count) * 0.8) - var smoothed = values[0] - for i in 1.. [Prediction] { - guard snapshots.count >= windowSize else { return [] } - - let values = snapshots.map { $0.decimalValue.doubleValue } - - // Calculate moving average of last window - let recentValues = Array(values.suffix(windowSize)) - let movingAverage = recentValues.reduce(0, +) / Double(windowSize) - - // Calculate average monthly change + private func maPredictions(values: [Double], lastDate: Date, monthsAhead: Int, windowSize: Int = 3) -> [Prediction] { + guard values.count >= windowSize else { return [] } + let recent = Array(values.suffix(windowSize)) + let movingAvg = recent.reduce(0, +) / Double(windowSize) var changes: [Double] = [] - for i in 1.. Double { - guard snapshots.count >= 5 else { return 0.5 } - - let values = snapshots.map { $0.decimalValue.doubleValue } - let windowSize = 3 + private func maAccuracy(values: [Double], windowSize: Int = 3) -> Double { + guard values.count >= 5 else { return 0.5 } let splitIndex = Int(Double(values.count) * 0.8) - guard splitIndex > windowSize else { return 0.5 } - let recentWindow = Array(values[(splitIndex - windowSize).. [Prediction] { - guard snapshots.count >= 3 else { return [] } - - let values = snapshots.map { $0.decimalValue.doubleValue } + private func holtPredictions(values: [Double], lastDate: Date, monthsAhead: Int, alpha: Double = 0.4, beta: Double = 0.3) -> [Prediction] { var level = values[0] var trend = values[1] - values[0] - var fitted: [Double] = [] for value in values { let lastLevel = level @@ -374,92 +196,86 @@ class PredictionEngine { trend = beta * (level - lastLevel) + (1 - beta) * trend fitted.append(level + trend) } + let stdDev = calculateStdDev(values: zip(values, fitted).map { $0 - $1 }) - let residuals = zip(values, fitted).map { $0 - $1 } - let stdDev = calculateStdDev(values: residuals) - - var predictions: [Prediction] = [] - let lastDate = snapshots.last!.date - - for month in 1...monthsAhead { - guard let futureDate = Self.calendar.date( - byAdding: .month, - value: month, - to: lastDate - ) else { continue } - - let predictedValue = max(0, level + Double(month) * trend) - let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.04) - - predictions.append(Prediction( - date: futureDate, - predictedValue: Decimal(predictedValue), - algorithm: .holtTrend, - confidenceInterval: Prediction.ConfidenceInterval( - lower: Decimal(max(0, predictedValue - intervalWidth)), - upper: Decimal(predictedValue + intervalWidth) - ) - )) + return (1...monthsAhead).compactMap { month in + guard let futureDate = Self.calendar.date(byAdding: .month, value: month, to: lastDate) else { return nil } + let predicted = max(0, level + Double(month) * trend) + let width = stdDev * 1.96 * (1.0 + Double(month) * 0.04) + return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .holtTrend, + confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width))) } - - return predictions } - private func calculateHoltAccuracy(snapshots: [Snapshot]) -> Double { - guard snapshots.count >= 5 else { return 0.5 } - - let values = snapshots.map { $0.decimalValue.doubleValue } + private func holtAccuracy(values: [Double]) -> Double { + guard values.count >= 5 else { return 0.5 } let splitIndex = Int(Double(values.count) * 0.8) guard splitIndex >= 2 else { return 0.5 } - var level = values[0] var trend = values[1] - values[0] - for value in values.prefix(splitIndex) { let lastLevel = level level = 0.4 * value + 0.6 * (level + trend) trend = 0.3 * (level - lastLevel) + 0.7 * trend } - - let validationValues = Array(values.suffix(from: splitIndex)) - let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count) - - var ssRes: Double = 0 - var ssTot: Double = 0 - - for (i, actual) in validationValues.enumerated() { - let predicted = level + Double(i + 1) * trend - ssRes += pow(actual - predicted, 2) - ssTot += pow(actual - meanValidation, 2) + let validation = Array(values.suffix(from: splitIndex)) + let mean = validation.reduce(0, +) / Double(validation.count) + var ssRes = 0.0, ssTot = 0.0 + for (i, actual) in validation.enumerated() { + ssRes += pow(actual - (level + Double(i + 1) * trend), 2) + ssTot += pow(actual - mean, 2) } - guard ssTot != 0 else { return 0.5 } - return max(0, min(1.0, 1 - (ssRes / ssTot))) + return max(0, min(1.0, 1 - ssRes / ssTot)) } // MARK: - Helpers - private func calculateVolatility(snapshots: [Snapshot]) -> Double { - let values = snapshots.map { $0.decimalValue.doubleValue } + private func calculateVolatility(values: [Double]) -> Double { guard values.count >= 2 else { return 0 } - var returns: [Double] = [] for i in 1.. (slope: Double, intercept: Double) { + let n = Double(dataPoints.count) + let sumX = dataPoints.reduce(0) { $0 + $1.x } + let sumY = dataPoints.reduce(0) { $0 + $1.y } + let sumXY = dataPoints.reduce(0) { $0 + $1.x * $1.y } + let sumX2 = dataPoints.reduce(0) { $0 + $1.x * $1.x } + let denom = n * sumX2 - sumX * sumX + guard denom != 0 else { return (0, sumY / n) } + let slope = (n * sumXY - sumX * sumY) / denom + return (slope, (sumY - slope * sumX) / n) + } + + private func calculateResidualStdDev(dataPoints: [(x: Double, y: Double)], slope: Double, intercept: Double) -> Double { + guard dataPoints.count > 2 else { return 0 } + let mse = dataPoints.map { pow($0.y - (slope * $0.x + intercept), 2) }.reduce(0, +) / Double(dataPoints.count - 2) + return sqrt(mse) + } + private func calculateStdDev(values: [Double]) -> Double { guard values.count >= 2 else { return 0 } - let mean = values.reduce(0, +) / Double(values.count) - let squaredDifferences = values.map { pow($0 - mean, 2) } - let variance = squaredDifferences.reduce(0, +) / Double(values.count - 1) - + let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count - 1) return sqrt(variance) } + + // MARK: - Public compatibility (kept for external callers) + + func predictLinear(snapshots: [Snapshot], monthsAhead: Int = 12) -> [Prediction] { + guard snapshots.count >= 3 else { return [] } + let sorted = snapshots.sorted { $0.date < $1.date } + return linearPredictions( + values: sorted.map { $0.decimalValue.doubleValue }, + firstDate: sorted.first!.date, + lastDate: sorted.last!.date, + monthsAhead: monthsAhead + ) + } } diff --git a/PortfolioJournal/Utilities/MonthlyContributionStore.swift b/PortfolioJournal/Utilities/MonthlyContributionStore.swift new file mode 100644 index 0000000..38d24c1 --- /dev/null +++ b/PortfolioJournal/Utilities/MonthlyContributionStore.swift @@ -0,0 +1,33 @@ +import Foundation + +enum MonthlyContributionStore { + private static let key = "monthlyContributions" + + static func contribution(for sourceId: UUID) -> Decimal? { + let dict = load() + guard let raw = dict[sourceId.uuidString] else { return nil } + return Decimal(raw) + } + + static func setContribution(_ amount: Decimal?, for sourceId: UUID) { + var dict = load() + if let amount, amount > 0 { + dict[sourceId.uuidString] = NSDecimalNumber(decimal: amount).doubleValue + } else { + dict.removeValue(forKey: sourceId.uuidString) + } + save(dict) + } + + private static func load() -> [String: Double] { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] } + return decoded + } + + private static func save(_ dict: [String: Double]) { + if let data = try? JSONEncoder().encode(dict) { + UserDefaults.standard.set(data, forKey: key) + } + } +} diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index a603e29..96db5fa 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -121,11 +121,13 @@ class ChartsViewModel: ObservableObject { @Published var predictionData: [Prediction] = [] @Published var allocationEvolutionData: [(date: Date, category: String, percentage: Double, color: String)] = [] @Published var yearOverYearData: [YearSeries] = [] + @Published var yoySelectedYears: Set = [] struct YearSeries: Identifiable { let id: Int // year let year: Int - let values: [Decimal] // one value per month (Jan=0, Dec=11), 0 if no data + let values: [Double] // cumulative % return within year (first available month = 0%), NaN = no data + var forecastEndValue: Double? = nil // estimated cumulative % at year-end (only for the current, still-incomplete year) } // MARK: - Comparison chart data models @@ -134,6 +136,7 @@ class ChartsViewModel: ObservableObject { case indexed = "Base 100" case returnPct = "Return %" case absolute = "Value" + case monthlyReturn = "Month %" var id: String { rawValue } } @@ -146,7 +149,7 @@ class ChartsViewModel: ObservableObject { @Published var comparisonData: [ComparisonSeries] = [] @Published var comparisonSelectedSourceIds: Set = [] - @Published var comparisonDisplayMode: ComparisonDisplayMode = .indexed + @Published var comparisonDisplayMode: ComparisonDisplayMode = .returnPct @Published var comparisonAvailableSources: [InvestmentSource] = [] // MARK: - Simulator chart data models @@ -181,6 +184,7 @@ class ChartsViewModel: ObservableObject { @Published var isLoading = false @Published var showingPaywall = false @Published private var predictionMonthsAhead = 12 + @Published var performancePeriodMonths: Int = 12 // MARK: - Time Range @@ -351,6 +355,15 @@ class ChartsViewModel: ObservableObject { } .store(in: &cancellables) + // Performance: react to custom period slider + $performancePeriodMonths + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.selectedChartType == .performance else { return } + self.updateChartData(chartType: .performance, category: self.selectedCategory, timeRange: self.selectedTimeRange) + } + .store(in: &cancellables) + // Period comparison: react to date picker changes Publishers.CombineLatest4($periodAStart, $periodAEnd, $periodBStart, $periodBEnd) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) @@ -396,10 +409,10 @@ class ChartsViewModel: ObservableObject { func availableTimeRanges(for chartType: ChartType) -> [TimeRange] { switch chartType { - case .evolution, .performance: + case .evolution: return [.all, .yearToDate, .year, .quarter] - case .yearOverYear, .simulator, .periodComparison: - return [] // No time range filter for these charts + case .performance, .yearOverYear, .simulator, .periodComparison: + return [] // Performance uses custom slider; others have no time range case .comparison: return [.month, .quarter, .halfYear, .year, .yearToDate, .all] default: @@ -469,7 +482,17 @@ class ChartsViewModel: ObservableObject { calculateAllocationData(for: sources, breakdown: selectedBreakdown) calculateAllocationEvolutionData(from: completedSnapshots) case .performance: - let completedSnapshotsBySource = groupSnapshotsBySource(completedSnapshots) + // Fetch with performancePeriodMonths (independent of selectedTimeRange) + let perfSourceIds = sources.compactMap { $0.id } + let perfAllSnapshots = freemiumValidator.filterSnapshots( + snapshotRepository.fetchSnapshots(for: perfSourceIds, months: performancePeriodMonths + 1) + ) + let perfCutoff = Calendar.current.date(byAdding: .month, value: -performancePeriodMonths, to: Date()) ?? Date() + let perfFiltered = filterSnapshotsForCharts( + sources: sources, + snapshots: perfAllSnapshots.filter { $0.date >= perfCutoff } + ) + let completedSnapshotsBySource = groupSnapshotsBySource(perfFiltered) calculatePerformanceData( for: sources, snapshotsBySource: completedSnapshotsBySource, @@ -584,7 +607,7 @@ class ChartsViewModel: ObservableObject { } /// Source-specific color palette (distinct from category colors, same order as Color.sourceColors) - private static let sourceColorHexes: [String] = [ + static let sourceColorHexesPublic: [String] = [ "#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899", "#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981" ] @@ -594,7 +617,7 @@ class ChartsViewModel: ObservableObject { let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } var map: [UUID: String] = [:] for (index, source) in sorted.enumerated() { - map[source.id] = Self.sourceColorHexes[index % Self.sourceColorHexes.count] + map[source.id] = Self.sourceColorHexesPublic[index % Self.sourceColorHexesPublic.count] } return map } @@ -961,17 +984,22 @@ class ChartsViewModel: ObservableObject { .sorted { $0.date < $1.date } } - private func calculateRollingReturnData(from snapshots: [Snapshot]) { - let monthlyTotals = monthlyTotals(from: snapshots) - guard monthlyTotals.count >= 13 else { + private func calculateRollingReturnData(from _: [Snapshot]) { + // Needs full history (not time-filtered) to have 13+ months + let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id } + let allSnapshots = freemiumValidator.filterSnapshots( + snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths) + ) + let totals = monthlyTotals(from: allSnapshots) + guard totals.count >= 13 else { rollingReturnData = [] return } var returns: [(date: Date, value: Double)] = [] - for index in 12.. 0 else { continue } let change = current.totalValue - base.totalValue let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100 @@ -1034,51 +1062,60 @@ class ChartsViewModel: ObservableObject { } private func calculateDrawdownData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - guard !sortedSnapshots.isEmpty else { + let totals = monthlyTotals(from: snapshots) + guard !totals.isEmpty else { drawdownData = [] return } - var peak = sortedSnapshots.first!.decimalValue + var peak = totals.first!.totalValue var data: [(date: Date, drawdown: Double)] = [] - for snapshot in sortedSnapshots { - let value = snapshot.decimalValue - if value > peak { - peak = value - } - + for point in totals { + let value = point.totalValue + if value > peak { peak = value } let drawdown = peak > 0 ? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100 : 0 - - data.append((date: snapshot.date, drawdown: -drawdown)) + data.append((date: point.date, drawdown: -drawdown)) } drawdownData = data } private func calculateVolatilityData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - guard sortedSnapshots.count >= 3 else { + let totals = monthlyTotals(from: snapshots) + guard totals.count >= 4 else { volatilityData = [] return } - var data: [(date: Date, volatility: Double)] = [] + // Compute monthly return series from portfolio totals + var monthlyReturns: [Double] = [] + var returnDates: [Date] = [] + for i in 1.. 0 else { continue } + let ret = NSDecimalNumber(decimal: (curr - prev) / prev).doubleValue * 100 + monthlyReturns.append(ret) + returnDates.append(totals[i].date) + } + let windowSize = 3 + guard monthlyReturns.count >= windowSize else { + volatilityData = [] + return + } - for i in windowSize.. 0 ? (stdDev / mean) * 100 : 0 - - data.append((date: sortedSnapshots[i].date, volatility: volatility)) + // Rolling std dev of monthly returns (annualised) + var data: [(date: Date, volatility: Double)] = [] + for i in (windowSize - 1).. YearSeries in + var values = [Double](repeating: Double.nan, count: 12) + let yearData = byYearMonth[year] ?? [:] + let sortedMonths = yearData.keys.sorted() + guard let baselineMonth = sortedMonths.first, + let baselineDecimal = yearData[baselineMonth] else { + return YearSeries(id: year, year: year, values: values) + } + let baseline = NSDecimalNumber(decimal: baselineDecimal).doubleValue + guard baseline > 0 else { return YearSeries(id: year, year: year, values: values) } + for monthIdx in 0..<12 { let month = monthIdx + 1 - if let v = byYearMonth[year]?[month] { - last = v + if let v = yearData[month] { + let vDouble = NSDecimalNumber(decimal: v).doubleValue + values[monthIdx] = ((vDouble - baseline) / baseline) * 100.0 } - values[monthIdx] = last } - return YearSeries(id: year, year: year, values: values) + + // #146: forecast the year-end value for the current, still-incomplete year so the + // "end diff" compares full-year vs full-year (estimated) instead of ~N months vs 12. + var forecastEnd: Double? = nil + if year == nowYear, nowMonth < 12, let lastDataMonth = sortedMonths.last, lastDataMonth < 12 { + let monthsAhead = 12 - lastDataMonth + let absSeries: [(date: Date, value: Decimal)] = sortedMonths.compactMap { m in + guard let v = yearData[m], + let d = calendar.date(from: DateComponents(year: year, month: m, day: 1)) else { return nil } + return (date: d, value: v) + } + if absSeries.count >= 3 { + let result = predictionEngine.predict(series: absSeries, monthsAhead: monthsAhead) + if let predDec = result.predictions.last?.predictedValue { + let predDouble = NSDecimalNumber(decimal: predDec).doubleValue + forecastEnd = ((predDouble - baseline) / baseline) * 100.0 + } + } + // Fallback (fewer than 3 points): linear extrapolation of the cumulative % to December. + if forecastEnd == nil, + let firstIdx = (0..<12).first(where: { !values[$0].isNaN }), + let lastIdx = (0..<12).last(where: { !values[$0].isNaN }), + lastIdx > firstIdx { + let monthlyRate = values[lastIdx] / Double(lastIdx - firstIdx) + forecastEnd = monthlyRate * Double(11 - firstIdx) + } + } + + return YearSeries(id: year, year: year, values: values, forecastEndValue: forecastEnd) + } + + // Default-select the last 2 available years + let availableYears = Set(years) + if yoySelectedYears.isEmpty || !yoySelectedYears.isSubset(of: availableYears) { + yoySelectedYears = Set(years.suffix(2)) } } // MARK: - Comparison Chart Calculation func calculateComparisonData(sources: [InvestmentSource], allSnapshots: [Snapshot]) { - let colorHexes = Self.sourceColorHexes + let colorHexes = Self.sourceColorHexesPublic let sortedSources = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -1191,6 +1267,13 @@ class ChartsViewModel: ObservableObject { } case .absolute: points = monthlyValues + case .monthlyReturn: + points = monthlyValues.enumerated().map { idx, item in + guard idx > 0 else { return (date: item.date, value: 0.0) } + let prev = monthlyValues[idx - 1].value + let pct = prev > 0 ? ((item.value - prev) / prev) * 100.0 : 0.0 + return (date: item.date, value: pct) + } } let colorHex = colorHexes[index % colorHexes.count] @@ -1215,7 +1298,7 @@ class ChartsViewModel: ObservableObject { return } - let colorHexes = Self.sourceColorHexes + let colorHexes = Self.sourceColorHexesPublic let sortedSources = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } @@ -1395,12 +1478,21 @@ class ChartsViewModel: ObservableObject { func calculatePeriodComparisonData(allSnapshots: [Snapshot]) { func seriesForPeriod(start: Date, end: Date, label: String, colorHex: String, id: String) -> PeriodSeries? { - let periodSnapshots = allSnapshots.filter { $0.date >= start && $0.date <= end } + // Incluir el mes de `end` completo: el date picker normaliza `end` al día 1 del mes, + // por lo que usar `<= end` excluía cualquier snapshot tomado dentro de ese mes (off-by-one). + // Cota superior exclusiva al inicio del mes siguiente → incluye todo el último mes (simétrico A/B). + let rangeStart = start.startOfMonth + let rangeEnd = end.startOfMonth.adding(months: 1) + let periodSnapshots = allSnapshots.filter { $0.date >= rangeStart && $0.date < rangeEnd } guard !periodSnapshots.isEmpty else { return nil } - // Group by month, compute portfolio total + // Group by month, compute portfolio total. + // Usar el mes calendario crudo del snapshot (mismo criterio que el filtro de rango). + // chartMonth(for:) aplica la lógica de "grace period" del check-in mensual, que para + // snapshots históricos con día <= 20 los reasigna al mes anterior y hace desaparecer + // el bucket del último mes del periodo (off-by-one que ocultaba el último mes de B). let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in - chartMonth(for: snap.date) + Calendar.current.dateComponents([.year, .month], from: snap.date) } var monthlyTotalsArr: [(date: Date, value: Double)] = [] for (key, snaps) in grouped { diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift index e3fc29a..64d4193 100644 --- a/PortfolioJournal/ViewModels/DashboardViewModel.swift +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -1,6 +1,7 @@ import Foundation import Combine import CoreData +import SwiftUI @MainActor class DashboardViewModel: ObservableObject { @@ -646,6 +647,82 @@ class DashboardViewModel: ObservableObject { sourcesNeedingUpdate.count } + var insights: [PortfolioInsight] { + var result: [PortfolioInsight] = [] + guard portfolioSummary.totalValue > 0 else { return result } + let total = portfolioSummary.totalValue + + // 1. Milestone approaching (within 10% of next round milestone) + let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000, + 100000, 250000, 500000, 1_000_000, + 2_500_000, 5_000_000, 10_000_000] + if let next = milestones.first(where: { $0 > total }) { + let pct = NSDecimalNumber(decimal: total / next).doubleValue + if pct >= 0.90 { + let gap = next - total + let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0) + let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0) + result.append(PortfolioInsight( + id: "milestone", + systemImage: "flag.checkered", + title: String(localized: "insight_milestone_title"), + value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr), + accentColor: .orange + )) + } + } + + // 2. Year-to-date performance + let ytdPct = portfolioSummary.yearChangePercentage + if abs(ytdPct) >= 0.1 { + let icon = ytdPct >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill" + result.append(PortfolioInsight( + id: "ytd", + systemImage: icon, + title: String(localized: "insight_ytd_title"), + value: String(format: "%+.1f%%", ytdPct), + accentColor: ytdPct >= 0 ? .positiveGreen : .negativeRed + )) + } + + // 3. All-time market gains + let gains = portfolioSummary.allTimeReturn + if gains > 0 { + let gainStr = CurrencyFormatter.format(gains, style: .currency, maximumFractionDigits: 0) + result.append(PortfolioInsight( + id: "market_gains", + systemImage: "chart.line.uptrend.xyaxis", + title: String(localized: "insight_market_gains_title"), + value: String(format: String(localized: "insight_market_gains_value"), gainStr), + accentColor: .appPrimary + )) + } + + // 4. Tracking streak (>= 3 months) + if updateStreak >= 3 { + result.append(PortfolioInsight( + id: "streak", + systemImage: "flame.fill", + title: String(localized: "insight_streak_title"), + value: String(format: String(localized: "insight_streak_value"), updateStreak), + accentColor: .orange + )) + } + + // 5. Forecast + if let forecast = portfolioForecast, forecast.forecastValue > total { + result.append(PortfolioInsight( + id: "forecast", + systemImage: "wand.and.stars", + title: String(localized: "insight_forecast_title"), + value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)", + accentColor: .purple + )) + } + + return result + } + var topCategories: [CategoryMetrics] { Array(categoryMetrics.prefix(5)) } diff --git a/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift index c9eeddc..86ade2c 100644 --- a/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift +++ b/PortfolioJournal/ViewModels/SnapshotFormViewModel.swift @@ -30,6 +30,11 @@ class SnapshotFormViewModel: ObservableObject { let mode: Mode let source: InvestmentSource + /// Contribution stored on the snapshot when editing started (nil in add mode or + /// when the snapshot had no contribution). Used to detect whether the user changed + /// the contribution and should be offered to propagate it to other snapshots. + private(set) var originalContribution: Decimal? + // MARK: - Dependencies private var cancellables = Set() @@ -63,6 +68,7 @@ class SnapshotFormViewModel: ObservableObject { if let contribution = snapshot.contribution { includeContribution = true contributionString = formatDecimalForInput(contribution.decimalValue) + originalContribution = contribution.decimalValue } notes = snapshot.notes ?? "" } @@ -128,6 +134,12 @@ class SnapshotFormViewModel: ObservableObject { return parseDecimal(trimmed) } + /// True when the current contribution differs from the value the snapshot had when + /// editing started. Drives the "propagate to other snapshots" prompt. + var contributionChanged: Bool { + contribution != originalContribution + } + var formattedValue: String { guard let value = value else { return CurrencyFormatter.format(Decimal.zero) } return value.currencyString diff --git a/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift b/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift new file mode 100644 index 0000000..f3d3b97 --- /dev/null +++ b/PortfolioJournal/Views/Charts/ChartSummaryComponents.swift @@ -0,0 +1,139 @@ +import SwiftUI + +// MARK: - Stat definition +struct ChartStat { + let label: String + let value: String + let color: Color +} + +// MARK: - Stats summary row (horizontal scroll of chips) +struct ChartStatsRow: View { + let stats: [ChartStat] + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(stats.indices, id: \.self) { i in + VStack(alignment: .center, spacing: 3) { + Text(stats[i].label) + .font(.caption2) + .foregroundColor(.secondary) + Text(stats[i].value) + .font(.subheadline.weight(.semibold)) + .foregroundColor(stats[i].color) + } + .frame(minWidth: 60) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + } +} + +// MARK: - Data table row model +struct ChartDataTableRow: Identifiable { + let id = UUID() + let label: String + let value: String + let deltaPrev: String? + let deltaFirst: String? + let isPrevPositive: Bool + let isFirstPositive: Bool +} + +// MARK: - Expandable data table +struct ChartDataTable: View { + let rows: [ChartDataTableRow] + let valueHeader: String + let deltaPrevHeader: String? + let deltaFirstHeader: String? + + @State private var isExpanded = false + private let previewCount = 5 + + var body: some View { + VStack(spacing: 0) { + tableHeader + Divider() + let displayed = isExpanded ? rows : Array(rows.suffix(previewCount)) + ForEach(displayed) { row in + tableDataRow(row) + if row.id != displayed.last?.id { + Divider().opacity(0.35) + } + } + if rows.count > previewCount { + Divider().opacity(0.35) + Button { + withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Text(isExpanded ? "Show less" : "Show all \(rows.count)") + .font(.caption) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + } + .foregroundColor(.appPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } + } + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + private var tableHeader: some View { + HStack(spacing: 0) { + Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + .frame(width: 72, alignment: .trailing) + if let h = deltaPrevHeader { + Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + .frame(width: 62, alignment: .trailing) + } + if let h = deltaFirstHeader { + Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary) + .frame(width: 62, alignment: .trailing) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func tableDataRow(_ row: ChartDataTableRow) -> some View { + HStack(spacing: 0) { + Text(row.label).font(.caption2).foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Text(row.value).font(.caption2.weight(.medium)) + .frame(width: 72, alignment: .trailing) + if let dp = row.deltaPrev, deltaPrevHeader != nil { + Text(dp).font(.caption2.weight(.medium)) + .foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed) + .frame(width: 62, alignment: .trailing) + } + if let df = row.deltaFirst, deltaFirstHeader != nil { + Text(df).font(.caption2.weight(.medium)) + .foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed) + .frame(width: 62, alignment: .trailing) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + } +} + +// MARK: - Compact date formatter +private let tableMonthFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMMy") + return f +}() + +func chartTableDateLabel(_ date: Date) -> String { + tableMonthFormatter.string(from: date) +} diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 8874f85..236b398 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -26,6 +26,7 @@ struct ChartsContainerView: View { .sheet(isPresented: $viewModel.showingPaywall) { PaywallView() } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu } + ToolbarItem(placement: .navigationBarLeading) { focusModeButton } } .onAppear { syncState() } .onChange(of: accountStore.selectedAccount) { _, newAccount in @@ -46,6 +47,26 @@ struct ChartsContainerView: View { } } + // MARK: - Focus Mode Button + + private var focusModeButton: some View { + Button { + calmModeEnabled.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars") + Text(calmModeEnabled ? "Focus" : "Full") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1)) + .foregroundColor(calmModeEnabled ? .appPrimary : .secondary) + .cornerRadius(14) + } + .buttonStyle(.plain) + } + // MARK: - iPad Layout (sidebar + full-width chart) private var iPadChartsLayout: some View { @@ -66,6 +87,35 @@ struct ChartsContainerView: View { iPadChartTypeRow(chartType) } + Divider().padding(.vertical, 8) + + // Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView) + Button { + calmModeEnabled.toggle() + } label: { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5)) + .frame(width: 32, height: 32) + Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars") + .font(.system(size: 14)) + .foregroundColor(calmModeEnabled ? .white : .primary) + } + Text("Focus Mode") + .font(.subheadline) + .foregroundColor(.primary) + Spacer() + Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle") + .foregroundColor(calmModeEnabled ? .appPrimary : .secondary) + .font(.system(size: 16)) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear) + } + .buttonStyle(.plain) + if hasAnyFilter { Divider().padding(.vertical, 12) filtersSection @@ -209,13 +259,28 @@ struct ChartsContainerView: View { VStack(alignment: .leading, spacing: 10) { let chartType = viewModel.selectedChartType - // Time Range - if chartType != .allocation && chartType != .riskReturn { + // Time Range (not for performance — uses slider) + if chartType != .allocation && chartType != .riskReturn && chartType != .performance { filterRow(icon: "calendar", label: "Period") { timeRangeSelector } } + // Performance: custom period slider + if chartType == .performance { + filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") { + Slider( + value: Binding( + get: { Double(viewModel.performancePeriodMonths) }, + set: { viewModel.performancePeriodMonths = Int($0) } + ), + in: 1...60, + step: 1 + ) + .tint(.appPrimary) + } + } + // Breakdown (category vs source) if chartType == .allocation || chartType == .performance { filterRow(icon: "square.grid.2x2", label: "Group") { @@ -449,6 +514,16 @@ struct ChartsContainerView: View { } } + private func periodLabel(_ months: Int) -> String { + if months < 12 { + return "\(months)M" + } else if months % 12 == 0 { + return "\(months / 12)Y" + } else { + return "\(months / 12)Y \(months % 12)M" + } + } + // MARK: - Chart Content @ViewBuilder @@ -501,7 +576,7 @@ struct ChartsContainerView: View { historicalData: viewModel.evolutionData ) case .yearOverYear: - YearOverYearChartView(series: viewModel.yearOverYearData) + YearOverYearChartView(viewModel: viewModel) case .comparison: ComparisonChartView(viewModel: viewModel) case .simulator: @@ -696,6 +771,9 @@ struct EvolutionChartView: View { headerView modePicker chartSection + if !data.isEmpty && chartMode == .total { + ChartStatsRow(stats: evolutionStats) + } } .padding() .background(Color(.systemBackground)) @@ -703,6 +781,23 @@ struct EvolutionChartView: View { .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + private var evolutionStats: [ChartStat] { + guard !data.isEmpty else { return [] } + let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue } + let latest = values.last ?? 0 + let first = values.first ?? 0 + let change = latest - first + let changePct = first > 0 ? (change / first) * 100 : 0 + let maxVal = values.max() ?? 0 + let minVal = values.min() ?? 0 + return [ + ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary), + ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed), + ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen), + ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed), + ] + } + private var headerView: some View { HStack { Text("Portfolio Evolution") @@ -937,6 +1032,7 @@ struct ContributionsChartView: View { } } .frame(height: 260) + ChartStatsRow(stats: contributionStats).padding(.top, 4) } } .padding() @@ -944,6 +1040,21 @@ struct ContributionsChartView: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + + private var contributionStats: [ChartStat] { + guard !data.isEmpty else { return [] } + let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue } + let total = amounts.reduce(0, +) + let avg = total / Double(amounts.count) + let highest = amounts.max() ?? 0 + let last = amounts.last ?? 0 + return [ + ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary), + ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary), + ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen), + ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary), + ] + } } // MARK: - Rolling 12-Month Return @@ -994,6 +1105,13 @@ struct RollingReturnChartView: View { } } .frame(height: 260) + ChartStatsRow(stats: rollingStats).padding(.top, 4) + ChartDataTable( + rows: rollingTableRows, + valueHeader: "Return", + deltaPrevHeader: "Δ prev", + deltaFirstHeader: "Δ first" + ) } } .padding() @@ -1001,6 +1119,41 @@ struct RollingReturnChartView: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + + private var rollingStats: [ChartStat] { + guard !data.isEmpty else { return [] } + let values = data.map { $0.value } + let latest = values.last ?? 0 + let best = values.max() ?? 0 + let worst = values.min() ?? 0 + let avg = values.reduce(0, +) / Double(values.count) + let change = (values.last ?? 0) - (values.first ?? 0) + return [ + ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed), + ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen), + ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed), + ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), + ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed), + ] + } + + private var rollingTableRows: [ChartDataTableRow] { + guard !data.isEmpty else { return [] } + let firstVal = data.first?.value ?? 0 + return data.enumerated().map { idx, point in + let prevVal = idx > 0 ? data[idx - 1].value : point.value + let deltaPrev = point.value - prevVal + let deltaFirst = point.value - firstVal + return ChartDataTableRow( + label: chartTableDateLabel(point.date), + value: String(format: "%.1f%%", point.value), + deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil, + deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil, + isPrevPositive: deltaPrev >= 0, + isFirstPositive: deltaFirst >= 0 + ) + } + } } // MARK: - Risk vs Return @@ -1115,6 +1268,7 @@ struct CashflowStackedChartView: View { } } .frame(height: 260) + ChartStatsRow(stats: cashflowStats).padding(.top, 4) } } .padding() @@ -1122,6 +1276,18 @@ struct CashflowStackedChartView: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + + private var cashflowStats: [ChartStat] { + guard !data.isEmpty else { return [] } + let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions } + let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance } + let net = totalContrib + totalPerf + return [ + ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary), + ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed), + ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed), + ] + } } // MARK: - Allocation Evolution Chart diff --git a/PortfolioJournal/Views/Charts/ComparisonChartView.swift b/PortfolioJournal/Views/Charts/ComparisonChartView.swift index a510add..785404e 100644 --- a/PortfolioJournal/Views/Charts/ComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/ComparisonChartView.swift @@ -4,7 +4,6 @@ import Charts struct ComparisonChartView: View { @ObservedObject var viewModel: ChartsViewModel - private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal] var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -35,7 +34,7 @@ struct ComparisonChartView: View { HStack(spacing: 8) { ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id) - let chipColor = palette[index % palette.count] + let chipColor = Color(hex: ChartsViewModel.sourceColorHexesPublic[index % ChartsViewModel.sourceColorHexesPublic.count]) ?? .appPrimary Button { if isSelected { viewModel.comparisonSelectedSourceIds.remove(source.id) @@ -106,8 +105,8 @@ struct ComparisonChartView: View { private var lineChart: some View { VStack(spacing: 12) { Chart { - ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in - let seriesColor = palette[index % palette.count] + ForEach(viewModel.comparisonData, id: \.id) { series in + let seriesColor = Color(hex: series.colorHex) ?? .appPrimary ForEach(series.points, id: \.date) { point in LineMark( x: .value("Date", point.date), @@ -149,10 +148,10 @@ struct ComparisonChartView: View { private var legend: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { - ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in + ForEach(viewModel.comparisonData, id: \.id) { series in HStack(spacing: 6) { RoundedRectangle(cornerRadius: 2) - .fill(palette[index % palette.count]) + .fill(Color(hex: series.colorHex) ?? .appPrimary) .frame(width: 20, height: 3) Text(series.name) .font(.caption) @@ -183,6 +182,8 @@ struct ComparisonChartView: View { return String(format: "%.1f%%", value) case .absolute: return Decimal(value).shortCurrencyString + case .monthlyReturn: + return String(format: "%.1f%%", value) } } } diff --git a/PortfolioJournal/Views/Charts/DrawdownChart.swift b/PortfolioJournal/Views/Charts/DrawdownChart.swift index 9160d65..b8be431 100644 --- a/PortfolioJournal/Views/Charts/DrawdownChart.swift +++ b/PortfolioJournal/Views/Charts/DrawdownChart.swift @@ -96,6 +96,13 @@ struct DrawdownChart: View { ) } .padding(.top, 8) + ChartDataTable( + rows: drawdownTableRows, + valueHeader: "Drawdown", + deltaPrevHeader: "Δ prev", + deltaFirstHeader: "Δ first" + ) + .padding(.top, 4) } else { Text("Not enough data for drawdown analysis") .foregroundColor(.secondary) @@ -114,6 +121,24 @@ struct DrawdownChart: View { let sum = data.reduce(0.0) { $0 + abs($1.drawdown) } return sum / Double(data.count) } + + private var drawdownTableRows: [ChartDataTableRow] { + data.enumerated().map { idx, point in + let val = abs(point.drawdown) + let prevVal = idx > 0 ? abs(data[idx - 1].drawdown) : val + let firstVal = abs(data.first?.drawdown ?? val) + let deltaPrev = -(val - prevVal) // negative delta = improvement (less drawdown) + let deltaFirst = -(val - firstVal) + return ChartDataTableRow( + label: chartTableDateLabel(point.date), + value: String(format: "%.1f%%", val), + deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil, + deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil, + isPrevPositive: deltaPrev >= 0, + isFirstPositive: deltaFirst >= 0 + ) + } + } } struct DrawdownStatView: View { @@ -156,15 +181,6 @@ struct VolatilityChartView: View { .font(.headline) Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("Current") - .font(.caption) - .foregroundColor(.secondary) - Text(String(format: "%.1f%%", currentVolatility)) - .font(.subheadline.weight(.semibold)) - .foregroundColor(volatilityColor(currentVolatility)) - } } Text("Measures price variability over time") @@ -221,13 +237,19 @@ struct VolatilityChartView: View { } .frame(height: 250) - // Volatility interpretation - HStack(spacing: 16) { - VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen) - VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning) - VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed) - } + ChartStatsRow(stats: [ + ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)), + ChartStat(label: "Max", value: String(format: "%.1f%%", data.map { $0.volatility }.max() ?? 0), color: .negativeRed), + ChartStat(label: "Min", value: String(format: "%.1f%%", data.map { $0.volatility }.min() ?? 0), color: .positiveGreen), + ChartStat(label: "Average", value: String(format: "%.1f%%", averageVolatility), color: .secondary), + ]) .padding(.top, 8) + ChartDataTable( + rows: volatilityTableRows, + valueHeader: "Volatility", + deltaPrevHeader: "Δ prev", + deltaFirstHeader: nil + ) } else { Text("Not enough data for volatility analysis") .foregroundColor(.secondary) @@ -251,6 +273,21 @@ struct VolatilityChartView: View { return .negativeRed } } + + private var volatilityTableRows: [ChartDataTableRow] { + data.enumerated().map { idx, point in + let prevVal = idx > 0 ? data[idx - 1].volatility : point.volatility + let delta = point.volatility - prevVal + return ChartDataTableRow( + label: chartTableDateLabel(point.date), + value: String(format: "%.1f%%", point.volatility), + deltaPrev: idx > 0 ? String(format: "%+.1f%%", delta) : nil, + deltaFirst: nil, + isPrevPositive: delta <= 0, + isFirstPositive: false + ) + } + } } struct VolatilityLevelView: View { diff --git a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift index 44dfb69..484f000 100644 --- a/PortfolioJournal/Views/Charts/PerformanceBarChart.swift +++ b/PortfolioJournal/Views/Charts/PerformanceBarChart.swift @@ -14,6 +14,10 @@ struct PerformanceBarChart: View { .font(.caption) .foregroundColor(.secondary) + if !data.isEmpty { + ChartStatsRow(stats: perfStats) + } + if !data.isEmpty { Chart(data, id: \.category) { item in BarMark( @@ -84,6 +88,19 @@ struct PerformanceBarChart: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + + private var perfStats: [ChartStat] { + guard !data.isEmpty else { return [] } + let values = data.map { $0.cagr } + let best = values.max() ?? 0 + let worst = values.min() ?? 0 + let avg = values.reduce(0, +) / Double(values.count) + return [ + ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen), + ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed), + ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary), + ] + } } // MARK: - Horizontal Bar Version diff --git a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift index 559fd40..65f357d 100644 --- a/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift +++ b/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift @@ -4,6 +4,9 @@ import Charts struct PeriodComparisonChartView: View { @ObservedObject var viewModel: ChartsViewModel + private let colorA = Color(hex: "#3478F6") ?? .blue + private let colorB = Color(hex: "#FF9500") ?? .orange + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Period vs Period") @@ -21,39 +24,37 @@ struct PeriodComparisonChartView: View { // MARK: - Period Pickers private var periodPickersSection: some View { - VStack(spacing: 10) { - periodRow( - label: "Period A", - color: Color(hex: "#3478F6") ?? .blue, - start: $viewModel.periodAStart, - end: $viewModel.periodAEnd - ) - periodRow( - label: "Period B", - color: Color(hex: "#FF9500") ?? .orange, - start: $viewModel.periodBStart, - end: $viewModel.periodBEnd - ) + VStack(spacing: 8) { + periodRow(label: "Period A", color: colorA, start: $viewModel.periodAStart, end: $viewModel.periodAEnd) + Divider() + .background(Color.secondary.opacity(0.15)) + periodRow(label: "Period B", color: colorB, start: $viewModel.periodBStart, end: $viewModel.periodBEnd) } .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(10) + .background( + LinearGradient( + colors: [colorA.opacity(0.06), colorB.opacity(0.06)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.1), lineWidth: 1) + ) } - private func periodRow( - label: String, - color: Color, - start: Binding, - end: Binding - ) -> some View { - HStack(spacing: 8) { - Circle() + private func periodRow(label: String, color: Color, start: Binding, end: Binding) -> some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2) .fill(color) - .frame(width: 8, height: 8) + .frame(width: 3, height: 30) + Text(label) - .font(.caption.weight(.semibold)) + .font(.caption.weight(.bold)) .foregroundColor(color) - .frame(width: 58, alignment: .leading) + .frame(width: 60, alignment: .leading) DatePicker( "", @@ -67,7 +68,7 @@ struct PeriodComparisonChartView: View { .labelsHidden() .frame(maxWidth: .infinity) - Text("–") + Text("→") .font(.caption) .foregroundColor(.secondary) @@ -92,13 +93,73 @@ struct PeriodComparisonChartView: View { if viewModel.periodComparisonData.isEmpty { emptyView } else { - VStack(spacing: 12) { + VStack(spacing: 16) { + numericalSummary periodChart legend } } } + private var numericalSummary: some View { + HStack(spacing: 12) { + ForEach(viewModel.periodComparisonData) { series in + let color = Color(hex: series.colorHex) ?? .gray + let finalReturn = series.points.last?.returnPct ?? 0 + let maxReturn = series.points.map { $0.returnPct }.max() ?? 0 + let minReturn = series.points.map { $0.returnPct }.min() ?? 0 + + HStack(spacing: 0) { + Rectangle() + .fill(color) + .frame(width: 4) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(series.label) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Text(String(format: "%+.2f%%", finalReturn)) + .font(.title2.weight(.bold)) + .foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed) + HStack(spacing: 10) { + HStack(spacing: 3) { + Image(systemName: "arrow.up") + .font(.caption2.weight(.semibold)) + .foregroundColor(.positiveGreen) + Text(String(format: "%.1f%%", maxReturn)) + .font(.caption2) + .foregroundColor(.positiveGreen) + } + HStack(spacing: 3) { + Image(systemName: "arrow.down") + .font(.caption2.weight(.semibold)) + .foregroundColor(.negativeRed) + Text(String(format: "%.1f%%", minReturn)) + .font(.caption2) + .foregroundColor(.negativeRed) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(color.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(color.opacity(0.15), lineWidth: 1) + ) + } + } + } + private var emptyView: some View { VStack(spacing: 12) { Image(systemName: "calendar.badge.clock") @@ -148,21 +209,28 @@ struct PeriodComparisonChartView: View { Color(hex: $0.colorHex) ?? .gray } - return Chart(points) { point in - LineMark( - x: .value("Month", point.monthOffset), - y: .value("Return", point.returnPct), - series: .value("Period", point.seriesLabel) - ) - .interpolationMethod(.catmullRom) - .foregroundStyle(by: .value("Period", point.seriesLabel)) + return Chart { + ForEach(points) { point in + LineMark( + x: .value("Month", point.monthOffset), + y: .value("Return", point.returnPct), + series: .value("Period", point.seriesLabel) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Period", point.seriesLabel)) + .lineStyle(StrokeStyle(lineWidth: 2.5)) - PointMark( - x: .value("Month", point.monthOffset), - y: .value("Return", point.returnPct) - ) - .foregroundStyle(by: .value("Period", point.seriesLabel)) - .symbolSize(20) + PointMark( + x: .value("Month", point.monthOffset), + y: .value("Return", point.returnPct) + ) + .foregroundStyle(by: .value("Period", point.seriesLabel)) + .symbolSize(30) + } + + RuleMark(y: .value("Zero", 0)) + .foregroundStyle(Color.secondary.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) } .chartForegroundStyleScale( domain: viewModel.periodComparisonData.map { $0.label }, @@ -194,23 +262,22 @@ struct PeriodComparisonChartView: View { } .frame(height: 260) .drawingGroup() - .onChange(of: seriesIds) { _, _ in } // silence unused warning + .onChange(of: seriesIds) { _, _ in } } private var legend: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 16) { - ForEach(viewModel.periodComparisonData) { series in - HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 2) - .fill(Color(hex: series.colorHex) ?? .gray) - .frame(width: 20, height: 3) - Text(series.label) - .font(.caption) - .foregroundColor(.secondary) - } + HStack(spacing: 16) { + ForEach(viewModel.periodComparisonData) { series in + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(hex: series.colorHex) ?? .gray) + .frame(width: 20, height: 3) + Text(series.label) + .font(.caption) + .foregroundColor(.secondary) } } + Spacer() } } diff --git a/PortfolioJournal/Views/Charts/PredictionChartView.swift b/PortfolioJournal/Views/Charts/PredictionChartView.swift index 85871b5..0dea4c2 100644 --- a/PortfolioJournal/Views/Charts/PredictionChartView.swift +++ b/PortfolioJournal/Views/Charts/PredictionChartView.swift @@ -77,6 +77,28 @@ struct PredictionChartView: View { .symbolSize(24) } + // Bull scenario line (upper confidence interval) + ForEach(predictions) { prediction in + LineMark( + x: .value("Date", prediction.date), + y: .value("Bull", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue), + series: .value("Scenario", "bull") + ) + .foregroundStyle(Color.positiveGreen.opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } + + // Bear scenario line (lower confidence interval) + ForEach(predictions) { prediction in + LineMark( + x: .value("Date", prediction.date), + y: .value("Bear", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue), + series: .value("Scenario", "bear") + ) + .foregroundStyle(Color.negativeRed.opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3])) + } + // Connect historical to prediction if let lastHistorical = historicalData.last, let firstPrediction = predictions.first { @@ -114,6 +136,17 @@ struct PredictionChartView: View { } .frame(height: 280) + // Stats row + if let currentValue = historicalData.last?.value, + let lastPred = predictions.last { + ChartStatsRow(stats: [ + ChartStat(label: "Current", value: currentValue.compactCurrencyString, color: .appPrimary), + ChartStat(label: "Base (\(predictions.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary), + ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen), + ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed), + ]) + } + // Legend HStack(spacing: 20) { HStack(spacing: 6) { @@ -150,6 +183,24 @@ struct PredictionChartView: View { .font(.caption) .foregroundColor(.secondary) } + + HStack(spacing: 6) { + Rectangle() + .fill(Color.positiveGreen.opacity(0.7)) + .frame(width: 20, height: 2) + Text("Bull") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 6) { + Rectangle() + .fill(Color.negativeRed.opacity(0.7)) + .frame(width: 20, height: 2) + Text("Bear") + .font(.caption) + .foregroundColor(.secondary) + } } // Prediction details @@ -195,6 +246,14 @@ struct PredictionChartView: View { } } } + + Divider().padding(.vertical, 4) + ChartDataTable( + rows: predictionTableRows, + valueHeader: "Base", + deltaPrevHeader: "Bull", + deltaFirstHeader: "Bear" + ) } } else { VStack(spacing: 12) { @@ -220,6 +279,19 @@ struct PredictionChartView: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) } + + private var predictionTableRows: [ChartDataTableRow] { + predictions.map { pred in + ChartDataTableRow( + label: chartTableDateLabel(pred.date), + value: pred.predictedValue.compactCurrencyString, + deltaPrev: pred.confidenceInterval.upper.compactCurrencyString, + deltaFirst: pred.confidenceInterval.lower.compactCurrencyString, + isPrevPositive: true, + isFirstPositive: false + ) + } + } } #Preview { diff --git a/PortfolioJournal/Views/Charts/YearOverYearChartView.swift b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift new file mode 100644 index 0000000..c4e00b2 --- /dev/null +++ b/PortfolioJournal/Views/Charts/YearOverYearChartView.swift @@ -0,0 +1,278 @@ +import SwiftUI +import Charts + +struct YearOverYearChartView: View { + @ObservedObject var viewModel: ChartsViewModel + + private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + private let lineColors: [Color] = [ + .appPrimary, .appSecondary, .orange, .purple, .pink, .teal + ] + + private var allYears: [ChartsViewModel.YearSeries] { viewModel.yearOverYearData } + + private var selectedSeries: [ChartsViewModel.YearSeries] { + allYears.filter { viewModel.yoySelectedYears.contains($0.year) } + .sorted { $0.year < $1.year } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "chart_yoy_title")) + .font(.headline) + + if allYears.isEmpty { + Text(String(localized: "chart_yoy_empty")) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 200) + } else { + yearSelector + if !selectedSeries.isEmpty { + // KPIs arriba del todo (#147) + yearEndStatsRow + if selectedSeries.count == 2 { + comparisonStatsHeader + } + if hasEstimate { + Text(String(localized: "chart_yoy_estimated_note")) + .font(.caption2) + .foregroundColor(.secondary) + } + // Detalle debajo (#147) + chartBody + legend + if selectedSeries.count == 2 { + comparisonTable + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + // MARK: - Year Selector + + private var yearSelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(allYears) { ys in + let idx = allYears.firstIndex(where: { $0.id == ys.id }) ?? 0 + let color = lineColors[idx % lineColors.count] + let isSelected = viewModel.yoySelectedYears.contains(ys.year) + Button { + if isSelected { + viewModel.yoySelectedYears.remove(ys.year) + } else { + viewModel.yoySelectedYears.insert(ys.year) + } + } label: { + Text(String(ys.year)) + .font(.caption.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1)) + .foregroundColor(isSelected ? color : .secondary) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? color : Color.clear, lineWidth: 1.5) + ) + .cornerRadius(16) + } + .buttonStyle(.plain) + } + } + } + } + + // MARK: - Chart + + @ViewBuilder + private var chartBody: some View { + if #available(iOS 16.0, *) { + Chart { + ForEach(selectedSeries) { yearSeries in + let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 + let color = lineColors[idx % lineColors.count] + ForEach(0..<12, id: \.self) { monthIdx in + let value = yearSeries.values[monthIdx] + if !value.isNaN { + LineMark( + x: .value("Month", monthIdx), + y: .value("Return", value), + series: .value("Year", String(yearSeries.year)) + ) + .foregroundStyle(color) + .interpolationMethod(.catmullRom) + } + } + } + } + .chartXAxis { + AxisMarks(values: Array(0..<12)) { value in + AxisValueLabel { + if let idx = value.as(Int.self) { + Text(monthAbbreviations[idx]) + .font(.caption2) + } + } + } + } + .chartYAxis { + AxisMarks { value in + AxisValueLabel { + if let d = value.as(Double.self) { + Text(String(format: "%+.1f%%", d)) + .font(.caption2) + } + } + AxisGridLine() + } + } + .frame(height: 220) + } else { + Text("iOS 16+ required for chart") + .foregroundColor(.secondary) + .frame(height: 220) + } + } + + // MARK: - Legend + + private var legend: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(selectedSeries) { yearSeries in + let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(lineColors[idx % lineColors.count]) + .frame(width: 20, height: 3) + Text(String(yearSeries.year)) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + + // MARK: - Year-end stats chips + + private var yearEndStatsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(selectedSeries) { yearSeries in + let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0 + let color = lineColors[idx % lineColors.count] + let end = endValue(yearSeries) + VStack(alignment: .center, spacing: 3) { + Text(String(yearSeries.year)) + .font(.caption2) + .foregroundColor(.secondary) + Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : "")) + .font(.subheadline.weight(.semibold)) + .foregroundColor(end.value >= 0 ? color : .negativeRed) + } + .frame(minWidth: 60) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + } + + // MARK: - 2-year comparison: KPIs (top) and table (bottom) + + @ViewBuilder + private var comparisonStatsHeader: some View { + let yearA = selectedSeries[0] + let yearB = selectedSeries[1] + let diffs = monthDiffs(yearA: yearA, yearB: yearB) + let validDiffs = diffs.compactMap { $0 } + let avgDiff = validDiffs.isEmpty ? 0 : validDiffs.reduce(0, +) / Double(validDiffs.count) + let endAInfo = endValue(yearA) + let endBInfo = endValue(yearB) + let endDiff = endBInfo.value - endAInfo.value + let endEstimated = endAInfo.estimated || endBInfo.estimated + let bestDiff = validDiffs.max() ?? 0 + let worstDiff = validDiffs.min() ?? 0 + + VStack(alignment: .leading, spacing: 8) { + Text("\(yearA.year) vs \(yearB.year)") + .font(.subheadline.weight(.semibold)) + + ChartStatsRow(stats: [ + ChartStat(label: "Avg diff", value: String(format: "%+.1f%%", avgDiff), + color: avgDiff >= 0 ? .positiveGreen : .negativeRed), + ChartStat(label: "End diff", value: String(format: "%+.1f%%", endDiff) + (endEstimated ? "*" : ""), + color: endDiff >= 0 ? .positiveGreen : .negativeRed), + ChartStat(label: "Best month", value: String(format: "%+.1f%%", bestDiff), + color: .positiveGreen), + ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff), + color: .negativeRed), + ]) + } + } + + @ViewBuilder + private var comparisonTable: some View { + let yearA = selectedSeries[0] + let yearB = selectedSeries[1] + ChartDataTable( + rows: comparisonTableRows(yearA: yearA, yearB: yearB), + valueHeader: String(yearA.year), + deltaPrevHeader: String(yearB.year), + deltaFirstHeader: "Diff" + ) + } + + // MARK: - Helpers + + /// Effective year-end value: the forecast (estimated) for the current incomplete year, + /// otherwise the last month with real data. + private func endValue(_ s: ChartsViewModel.YearSeries) -> (value: Double, estimated: Bool) { + if let f = s.forecastEndValue { return (f, true) } + return (s.values.last(where: { !$0.isNaN }) ?? 0, false) + } + + private var hasEstimate: Bool { + selectedSeries.contains { $0.forecastEndValue != nil } + } + + private func monthDiffs(yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries) -> [Double?] { + (0..<12).map { idx in + let a = yearA.values[idx] + let b = yearB.values[idx] + guard !a.isNaN, !b.isNaN else { return nil } + return b - a + } + } + + private func comparisonTableRows( + yearA: ChartsViewModel.YearSeries, + yearB: ChartsViewModel.YearSeries + ) -> [ChartDataTableRow] { + (0..<12).compactMap { idx in + let valA = yearA.values[idx] + let valB = yearB.values[idx] + guard !valA.isNaN || !valB.isNaN else { return nil } + let diff = (!valA.isNaN && !valB.isNaN) ? valB - valA : 0 + return ChartDataTableRow( + label: monthAbbreviations[idx], + value: valA.isNaN ? "—" : String(format: "%+.1f%%", valA), + deltaPrev: valB.isNaN ? "—" : String(format: "%+.1f%%", valB), + deltaFirst: (!valA.isNaN && !valB.isNaN) ? String(format: "%+.1f%%", diff) : "—", + isPrevPositive: valB >= 0, + isFirstPositive: diff >= 0 + ) + } + } +} diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index eb48a0a..e3628e9 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -47,6 +47,10 @@ struct DashboardView: View { .transition(.move(edge: .top).combined(with: .opacity)) } + if !viewModel.insights.isEmpty { + InsightsRow(insights: viewModel.insights) + } + if horizontalSizeClass == .regular { iPadDashboardLayout } else { @@ -99,6 +103,9 @@ struct DashboardView: View { Image(systemName: "slider.horizontal.3") } } + ToolbarItem(placement: .navigationBarLeading) { + dashboardFocusModeButton + } } .onAppear { viewModel.selectedAccount = accountStore.selectedAccount @@ -143,6 +150,24 @@ struct DashboardView: View { } } + private var dashboardFocusModeButton: some View { + Button { + calmModeEnabled.toggle() + } label: { + HStack(spacing: 4) { + Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars") + Text(calmModeEnabled ? "Focus" : "Full") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1)) + .foregroundColor(calmModeEnabled ? .appPrimary : .secondary) + .cornerRadius(14) + } + .buttonStyle(.plain) + } + private var streakBadge: some View { HStack(spacing: 6) { Image(systemName: "flame.fill") diff --git a/PortfolioJournal/Views/Dashboard/InsightsRow.swift b/PortfolioJournal/Views/Dashboard/InsightsRow.swift new file mode 100644 index 0000000..d9c40eb --- /dev/null +++ b/PortfolioJournal/Views/Dashboard/InsightsRow.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct InsightsRow: View { + let insights: [PortfolioInsight] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "insights_section_title")) + .font(.headline) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(insights) { insight in + insightChip(insight) + } + } + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + } + + private func insightChip(_ insight: PortfolioInsight) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + Image(systemName: insight.systemImage) + .font(.caption.weight(.semibold)) + .foregroundColor(insight.accentColor) + Text(insight.title) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(insight.value) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(insight.accentColor.opacity(0.08)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(insight.accentColor.opacity(0.2), lineWidth: 1) + ) + } +} diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index 906f1eb..dc8a290 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -257,6 +257,7 @@ struct MonthlyCheckInView: View { : min(referenceDate.endOfMonth, now) MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate) ReviewPromptService.shared.recordMonthlyCheckInCompleted() + NotificationService.shared.scheduleMonthlyCheckIn() viewModel.refresh() let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys) if ReviewPromptService.shared.shouldAskForAchievementSatisfaction( @@ -1079,6 +1080,7 @@ struct BatchUpdateView: View { value: parsed, contribution: parsedContribution ) + NotificationService.shared.scheduleReminder(for: source) count += 1 } diff --git a/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift index 505810b..e699363 100644 --- a/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift +++ b/PortfolioJournal/Views/Dashboard/QuickUpdateView.swift @@ -11,6 +11,7 @@ struct QuickUpdateView: View { ) private var sources: FetchedResults @State private var values: [NSManagedObjectID: String] = [:] + @State private var contributions: [NSManagedObjectID: String] = [:] @State private var isSaving = false @State private var saveError: String? @@ -62,30 +63,51 @@ struct QuickUpdateView: View { } message: { Text(saveError ?? "") } + .onAppear { prefillContributions() } } } @ViewBuilder private func sourceRow(_ source: InvestmentSource) -> some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(source.name) - .font(.subheadline.weight(.medium)) - if source.latestValue != .zero { - Text(source.latestValue.currencyString) + VStack(spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(source.name) + .font(.subheadline.weight(.medium)) + if source.latestValue != .zero { + Text(source.latestValue.currencyString) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + TextField( + String(localized: "quick_update_placeholder"), + text: valueBinding(for: source) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 120) + .font(.subheadline) + } + + if contributions[source.objectID] != nil { + HStack { + Text(String(localized: "quick_update_contribution_label")) .font(.caption) .foregroundColor(.secondary) + Spacer() + TextField( + String(localized: "quick_update_contribution_placeholder"), + text: contributionBinding(for: source) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 120) + .font(.caption) + .foregroundColor(.secondary) } } - Spacer() - TextField( - String(localized: "quick_update_placeholder"), - text: binding(for: source) - ) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 120) - .font(.subheadline) } } @@ -93,13 +115,28 @@ struct QuickUpdateView: View { values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty } } - private func binding(for source: InvestmentSource) -> Binding { + private func valueBinding(for source: InvestmentSource) -> Binding { Binding( get: { values[source.objectID] ?? "" }, set: { values[source.objectID] = $0 } ) } + private func contributionBinding(for source: InvestmentSource) -> Binding { + Binding( + get: { contributions[source.objectID] ?? "" }, + set: { contributions[source.objectID] = $0 } + ) + } + + private func prefillContributions() { + for source in sources { + if let amount = MonthlyContributionStore.contribution(for: source.id) { + contributions[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue) + } + } + } + private func saveAll() { isSaving = true let now = Date() @@ -114,10 +151,20 @@ struct QuickUpdateView: View { snapshot.value = NSDecimalNumber(decimal: value) snapshot.date = now snapshot.source = source + + if let contribRaw = contributions[source.objectID], + !contribRaw.trimmingCharacters(in: .whitespaces).isEmpty, + let contrib = CurrencyFormatter.parseUserInput(contribRaw) { + snapshot.contribution = NSDecimalNumber(decimal: contrib) + } } do { try context.save() + // Reschedule source reminders so they reflect the new snapshots + for source in sources where values[source.objectID].map({ !$0.isEmpty }) == true { + NotificationService.shared.scheduleReminder(for: source) + } dismiss() } catch { saveError = error.localizedDescription diff --git a/PortfolioJournal/Views/Onboarding/OnboardingView.swift b/PortfolioJournal/Views/Onboarding/OnboardingView.swift index 8e5259c..b30bcdd 100644 --- a/PortfolioJournal/Views/Onboarding/OnboardingView.swift +++ b/PortfolioJournal/Views/Onboarding/OnboardingView.swift @@ -281,10 +281,15 @@ struct OnboardingQuickStartView: View { .background(Color.gray.opacity(0.1)) .cornerRadius(12) - Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled) - .padding() - .background(Color.gray.opacity(0.1)) - .cornerRadius(12) + VStack(alignment: .leading, spacing: 6) { + Toggle("Focus Mode (recommended)", isOn: $calmModeEnabled) + Text("Shows returns since your last check-in instead of daily noise. You can change this anytime in Settings.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(12) Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled) .padding() diff --git a/PortfolioJournal/Views/Settings/CSVMappingView.swift b/PortfolioJournal/Views/Settings/CSVMappingView.swift new file mode 100644 index 0000000..c801569 --- /dev/null +++ b/PortfolioJournal/Views/Settings/CSVMappingView.swift @@ -0,0 +1,360 @@ +import SwiftUI + +struct CSVMappingView: View { + let csvContent: String + let onComplete: (ImportService.CSVMappingConfig) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var config = ImportService.CSVMappingConfig() + @State private var preview: ImportService.CSVPreview? + + var body: some View { + NavigationStack { + Form { + // CSV Preview section + if let preview, let sample = preview.sampleRow(hasHeaderRow: config.hasHeaderRow) { + Section(String(localized: "csv_preview_section")) { + Toggle("Has Header Row", isOn: $config.hasHeaderRow) + .onChange(of: config.hasHeaderRow) { _, _ in resetMappings() } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach( + Array(zip( + preview.headers(hasHeaderRow: config.hasHeaderRow), + sample + ).enumerated()), + id: \.offset + ) { _, pair in + VStack(alignment: .leading, spacing: 2) { + Text(pair.0) + .font(.caption2.weight(.semibold)) + .foregroundColor(.secondary) + Text(pair.1.isEmpty ? "—" : pair.1) + .font(.caption) + .lineLimit(2) + } + .frame(minWidth: 60, alignment: .leading) + } + } + .padding(.vertical, 4) + } + } + } + + // Required fields + Section(String(localized: "csv_required_section")) { + MappingRow( + label: String(localized: "csv_field_source"), + index: $config.sourceIndex, + constant: $config.sourceConstant, + headers: currentHeaders, + supportsConstant: true, + supportsUseToday: false, + canSkip: false + ) + + MappingRow( + label: String(localized: "csv_field_value"), + index: $config.valueIndex, + constant: .constant(""), + headers: currentHeaders, + supportsConstant: false, + supportsUseToday: false, + canSkip: false + ) + } + + // Date section + Section { + MappingRow( + label: String(localized: "csv_field_date"), + index: $config.dateIndex, + constant: .constant(""), + headers: currentHeaders, + supportsConstant: false, + supportsUseToday: true, + canSkip: false + ) + } header: { + Text("Date") + } footer: { + if config.dateIndex == ImportService.CSVMappingConfig.useToday { + Text(String(localized: "csv_no_date_hint")) + } + } + + // Optional fields + Section(String(localized: "csv_optional_section")) { + MappingRowWithConstant( + label: String(localized: "csv_field_category"), + index: $config.categoryIndex, + constant: $config.categoryConstant, + headers: currentHeaders + ) + + MappingRow( + label: String(localized: "csv_field_contribution"), + index: $config.contributionIndex, + constant: .constant(""), + headers: currentHeaders, + supportsConstant: false, + supportsUseToday: false, + canSkip: true + ) + + MappingRow( + label: String(localized: "csv_field_notes"), + index: $config.notesIndex, + constant: .constant(""), + headers: currentHeaders, + supportsConstant: false, + supportsUseToday: false, + canSkip: true + ) + } + } + .navigationTitle("Map Columns") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Import") { + onComplete(config) + dismiss() + } + .fontWeight(.semibold) + .disabled(!config.isValid) + } + } + .onAppear { + let p = ImportService.shared.previewCSV(csvContent) + self.preview = p + autoDetectColumns(from: p) + } + } + .presentationDetents([.large]) + } + + private var currentHeaders: [String] { + preview?.headers(hasHeaderRow: config.hasHeaderRow) ?? [] + } + + private func resetMappings() { + guard let preview else { return } + autoDetectColumns(from: preview) + } + + /// Try to auto-detect columns by matching common header names + private func autoDetectColumns(from preview: ImportService.CSVPreview) { + guard config.hasHeaderRow else { return } + let headers = preview.headers(hasHeaderRow: true) + let lower = headers.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } + + for (i, h) in lower.enumerated() { + if config.sourceIndex == ImportService.CSVMappingConfig.notMapped && + (h == "source" || h == "name" || h == "fuente" || h == "nombre") { + config.sourceIndex = i + } + if config.valueIndex == ImportService.CSVMappingConfig.notMapped && + (h.hasPrefix("value") || h == "amount" || h == "valor" || h == "importe") { + config.valueIndex = i + } + if config.dateIndex == ImportService.CSVMappingConfig.useToday && + (h == "date" || h == "fecha" || h.hasPrefix("date")) { + config.dateIndex = i + } + if config.categoryIndex == ImportService.CSVMappingConfig.constant && + (h == "category" || h == "categoría" || h == "categoria" || h == "type") { + config.categoryIndex = i + } + if config.contributionIndex == ImportService.CSVMappingConfig.notMapped && + (h.hasPrefix("contribution") || h == "aportación" || h == "aportacion") { + config.contributionIndex = i + } + if config.notesIndex == ImportService.CSVMappingConfig.notMapped && + (h == "notes" || h == "notas" || h == "note" || h == "comments") { + config.notesIndex = i + } + } + } +} + +// MARK: - Mapping Row (column or constant/useToday/skip) + +private struct MappingRow: View { + let label: String + @Binding var index: Int + @Binding var constant: String + let headers: [String] + let supportsConstant: Bool + let supportsUseToday: Bool + let canSkip: Bool + + private var selectionLabel: String { + if index == ImportService.CSVMappingConfig.useToday { + return String(localized: "csv_use_today") + } + if index == ImportService.CSVMappingConfig.constant { + return constant.isEmpty ? String(localized: "csv_enter_value") : constant + } + if index == ImportService.CSVMappingConfig.notMapped { + return String(localized: "csv_no_column") + } + return headers[safe: index] ?? "Column \(index + 1)" + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + .font(.subheadline) + + Spacer() + + Menu { + // Column options + if !headers.isEmpty { + ForEach(Array(headers.enumerated()), id: \.offset) { i, h in + Button { index = i } label: { + Label(h, systemImage: index == i ? "checkmark" : "") + } + } + Divider() + } + + if supportsUseToday { + Button { + index = ImportService.CSVMappingConfig.useToday + } label: { + Label( + String(localized: "csv_use_today"), + systemImage: index == ImportService.CSVMappingConfig.useToday ? "checkmark" : "" + ) + } + } + + if supportsConstant { + Button { + index = ImportService.CSVMappingConfig.constant + } label: { + Label( + String(localized: "csv_enter_value"), + systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : "" + ) + } + } + + if canSkip { + Button { + index = ImportService.CSVMappingConfig.notMapped + } label: { + Label( + String(localized: "csv_no_column"), + systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : "" + ) + } + } + } label: { + HStack(spacing: 4) { + Text(selectionLabel) + .font(.subheadline) + .foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + if index == ImportService.CSVMappingConfig.constant && supportsConstant { + TextField(String(localized: "csv_enter_value"), text: $constant) + .textFieldStyle(.roundedBorder) + .font(.subheadline) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Mapping Row With Constant (column, constant value, or skip) + +private struct MappingRowWithConstant: View { + let label: String + @Binding var index: Int + @Binding var constant: String + let headers: [String] + + private var selectionLabel: String { + if index == ImportService.CSVMappingConfig.constant { + return constant.isEmpty ? String(localized: "csv_enter_value") : constant + } + if index == ImportService.CSVMappingConfig.notMapped { + return String(localized: "csv_no_column") + } + return headers[safe: index] ?? "Column \(index + 1)" + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + .font(.subheadline) + + Spacer() + + Menu { + ForEach(Array(headers.enumerated()), id: \.offset) { i, h in + Button { index = i } label: { + Label(h, systemImage: index == i ? "checkmark" : "") + } + } + Divider() + Button { + index = ImportService.CSVMappingConfig.constant + } label: { + Label( + String(localized: "csv_enter_value"), + systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : "" + ) + } + Button { + index = ImportService.CSVMappingConfig.notMapped + } label: { + Label( + String(localized: "csv_no_column"), + systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : "" + ) + } + } label: { + HStack(spacing: 4) { + Text(selectionLabel) + .font(.subheadline) + .foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + + if index == ImportService.CSVMappingConfig.constant { + TextField(String(localized: "csv_enter_value"), text: $constant) + .textFieldStyle(.roundedBorder) + .font(.subheadline) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Array safe subscript + +private extension Array { + subscript(safe index: Int) -> Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } +} diff --git a/PortfolioJournal/Views/Settings/CategoriesView.swift b/PortfolioJournal/Views/Settings/CategoriesView.swift new file mode 100644 index 0000000..7078665 --- /dev/null +++ b/PortfolioJournal/Views/Settings/CategoriesView.swift @@ -0,0 +1,271 @@ +import SwiftUI +import CoreData + +// MARK: - Categories List View + +struct CategoriesView: View { + @StateObject private var categoryRepository = CategoryRepository() + @State private var showingAddCategory = false + @State private var editingCategory: Category? + @State private var showingDeleteError = false + + var body: some View { + List { + if categoryRepository.categories.isEmpty { + Text(String(localized: "categories_empty")) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 32) + } else { + ForEach(categoryRepository.categories) { category in + CategoryRow(category: category) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + editingCategory = category + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.appSecondary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if category.sourceCount == 0 { + Button(role: .destructive) { + categoryRepository.deleteCategory(category) + } label: { + Label("Delete", systemImage: "trash") + } + } else { + Button { + showingDeleteError = true + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.gray) + } + } + } + .onMove { from, to in + var reordered = categoryRepository.categories + reordered.move(fromOffsets: from, toOffset: to) + categoryRepository.updateSortOrder(reordered) + } + } + } + .navigationTitle("Categories") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingAddCategory = true + } label: { + Image(systemName: "plus") + } + } + ToolbarItem(placement: .navigationBarLeading) { + EditButton() + } + } + .sheet(isPresented: $showingAddCategory) { + CategoryEditorView() + } + .sheet(item: $editingCategory) { category in + CategoryEditorView(category: category) + } + .alert( + "Cannot Delete", + isPresented: $showingDeleteError + ) { + Button("OK", role: .cancel) { showingDeleteError = false } + } message: { + Text(String(localized: "category_has_sources_warning")) + } + } +} + +// MARK: - Category Row + +private struct CategoryRow: View { + let category: Category + + var body: some View { + Group { + if category.isDeleted || category.isFault { + EmptyView() + } else { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(category.color.opacity(0.2)) + .frame(width: 40, height: 40) + Image(systemName: category.icon) + .font(.system(size: 16)) + .foregroundColor(category.color) + } + + VStack(alignment: .leading, spacing: 2) { + Text(category.name) + .font(.body) + Text(category.sourceCount == 1 ? "1 source" : "\(category.sourceCount) sources") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 2) + } + } + } +} + +// MARK: - Category Editor View + +struct CategoryEditorView: View { + let category: Category? + + @Environment(\.dismiss) private var dismiss + @StateObject private var categoryRepository = CategoryRepository() + + @State private var name = "" + @State private var selectedColor = "#10B981" + @State private var selectedIcon = "chart.pie.fill" + @State private var didLoad = false + + init(category: Category? = nil) { + self.category = category + } + + private let colors: [String] = [ + "#10B981", "#3B82F6", "#8B5CF6", "#F59E0B", "#EF4444", "#EC4899", + "#14B8A6", "#6B7280", "#F97316", "#06B6D4", "#84CC16", "#64748B" + ] + + private let icons: [String] = [ + "chart.line.uptrend.xyaxis", "chart.bar.fill", "chart.pie.fill", + "banknote.fill", "building.columns.fill", "house.fill", + "bitcoinsign.circle.fill", "person.fill", "briefcase.fill", + "dollarsign.circle.fill", "creditcard.fill", "globe", + "cart.fill", "star.fill", "heart.fill", "ellipsis.circle.fill", + "folder.fill", "lock.fill", "leaf.fill", "flame.fill" + ] + + var body: some View { + NavigationStack { + Form { + Section("Category Name") { + TextField(String(localized: "category_name_placeholder"), text: $name) + .autocorrectionDisabled() + } + + Section("Color") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) { + ForEach(colors, id: \.self) { hex in + ColorSwatch(hex: hex, isSelected: selectedColor == hex) + .onTapGesture { selectedColor = hex } + } + } + .padding(.vertical, 4) + } + + Section("Icon") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 5), spacing: 12) { + ForEach(icons, id: \.self) { icon in + IconSwatch(icon: icon, colorHex: selectedColor, isSelected: selectedIcon == icon) + .onTapGesture { selectedIcon = icon } + } + } + .padding(.vertical, 4) + } + + // Preview + Section("Preview") { + HStack(spacing: 12) { + ZStack { + Circle() + .fill((Color(hex: selectedColor) ?? .blue).opacity(0.2)) + .frame(width: 44, height: 44) + Image(systemName: selectedIcon) + .font(.system(size: 18)) + .foregroundColor(Color(hex: selectedColor) ?? .blue) + } + Text(name.isEmpty ? String(localized: "category_name_placeholder") : name) + .foregroundColor(name.isEmpty ? .secondary : .primary) + } + } + } + .navigationTitle(category == nil ? "Add Category" : "Edit Category") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { save() } + .fontWeight(.semibold) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + guard let category, !didLoad else { return } + name = category.name + selectedColor = category.colorHex + selectedIcon = category.icon + didLoad = true + } + } + .presentationDetents([.large]) + } + + private func save() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + if let category { + categoryRepository.updateCategory(category, name: trimmed, colorHex: selectedColor, icon: selectedIcon) + } else { + categoryRepository.createCategory(name: trimmed, colorHex: selectedColor, icon: selectedIcon) + } + dismiss() + } +} + +// MARK: - Color Swatch + +private struct ColorSwatch: View { + let hex: String + let isSelected: Bool + + var body: some View { + let color = Color(hex: hex) ?? .blue + Circle() + .fill(color) + .frame(width: 36, height: 36) + .overlay(Circle().strokeBorder(isSelected ? Color.primary : Color.clear, lineWidth: 2)) + } +} + +// MARK: - Icon Swatch + +private struct IconSwatch: View { + let icon: String + let colorHex: String + let isSelected: Bool + + var body: some View { + let color = Color(hex: colorHex) ?? .blue + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? color.opacity(0.2) : Color(.systemGray6)) + .frame(width: 44, height: 44) + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(isSelected ? color : .secondary) + } + } +} + +#Preview { + NavigationStack { + CategoriesView() + } +} diff --git a/PortfolioJournal/Views/Settings/SettingsView.swift b/PortfolioJournal/Views/Settings/SettingsView.swift index de6f9ab..0d72650 100644 --- a/PortfolioJournal/Views/Settings/SettingsView.swift +++ b/PortfolioJournal/Views/Settings/SettingsView.swift @@ -706,7 +706,7 @@ struct SettingsView: View { private var longTermSection: some View { Section { - Toggle("Calm Mode", isOn: $calmModeEnabled) + Toggle("Focus Mode", isOn: $calmModeEnabled) Toggle("Show Forecast", isOn: $showForecast) @@ -723,7 +723,7 @@ struct SettingsView: View { } header: { Text("Long-Term Focus") } footer: { - Text("Calm Mode hides short-term noise and advanced charts. Show Forecast controls whether prediction data appears in portfolio and charts.") + Text("Focus Mode shows returns since your last check-in instead of daily fluctuations, and hides advanced technical charts. Turn it off for full access to all metrics and chart types.") } } diff --git a/PortfolioJournal/Views/Sources/AddSourceView.swift b/PortfolioJournal/Views/Sources/AddSourceView.swift index c3f62c1..a486f24 100644 --- a/PortfolioJournal/Views/Sources/AddSourceView.swift +++ b/PortfolioJournal/Views/Sources/AddSourceView.swift @@ -178,6 +178,12 @@ struct AddSourceView: View { // Schedule notification NotificationService.shared.scheduleReminder(for: source) + // Check milestone after snapshot is saved + let sourceRepo = InvestmentSourceRepository() + let allSources = sourceRepo.fetchActiveSources() + let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue } + NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total) + // Log analytics FirebaseService.shared.logSourceAdded( categoryName: category.name, @@ -391,6 +397,8 @@ struct AddSnapshotView: View { @StateObject private var viewModel: SnapshotFormViewModel @State private var showingDuplicateAlert = false + @State private var showingPropagationDialog = false + @State private var pendingContribution: Decimal? init(source: InvestmentSource, snapshot: Snapshot? = nil) { self.source = source @@ -464,24 +472,22 @@ struct AddSnapshotView: View { } } - if viewModel.inputMode == .detailed { - Section { - Toggle("Include Contribution", isOn: $viewModel.includeContribution) + Section { + Toggle("Include Contribution", isOn: $viewModel.includeContribution) - if viewModel.includeContribution { - HStack { - Text(viewModel.currencySymbol) - .foregroundColor(.secondary) + if viewModel.includeContribution { + HStack { + Text(viewModel.currencySymbol) + .foregroundColor(.secondary) - TextField("New capital added", text: $viewModel.contributionString) - .keyboardType(.decimalPad) - } + TextField("New capital added", text: $viewModel.contributionString) + .keyboardType(.decimalPad) } - } header: { - Text("Contribution (Optional)") - } footer: { - Text("Track new capital added to separate it from investment growth.") } + } header: { + Text("Contribution (Optional)") + } footer: { + Text("Track new capital added to separate it from investment growth.") } Section { @@ -530,6 +536,20 @@ struct AddSnapshotView: View { } message: { Text(String(localized: "snapshot_duplicate_message")) } + .confirmationDialog( + String(localized: "snapshot_contribution_propagate_title"), + isPresented: $showingPropagationDialog, + titleVisibility: .visible + ) { + Button(String(localized: "snapshot_contribution_propagate_forward")) { propagate(.forward) } + Button(String(localized: "snapshot_contribution_propagate_backward")) { propagate(.backward) } + Button(String(localized: "snapshot_contribution_propagate_all")) { propagate(.all) } + Button(String(localized: "snapshot_contribution_propagate_this"), role: .cancel) { dismiss() } + } message: { + if let amount = pendingContribution { + Text(String(format: String(localized: "snapshot_contribution_propagate_message"), amount.currencyString)) + } + } } } @@ -560,6 +580,7 @@ struct AddSnapshotView: View { guard let value = viewModel.value else { return } let repository = SnapshotRepository() + let isEdit = snapshot != nil if let snapshot = snapshot { let contributionTextIsEmpty = viewModel.contributionString .trimmingCharacters(in: .whitespacesAndNewlines) @@ -597,6 +618,20 @@ struct AddSnapshotView: View { // Log analytics FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) + // When editing, offer to propagate a changed contribution to other snapshots. + if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged { + pendingContribution = contribution + showingPropagationDialog = true + return + } + + dismiss() + } + + private func propagate(_ direction: SnapshotRepository.ContributionPropagation) { + if let snapshot = snapshot, let amount = pendingContribution { + SnapshotRepository().propagateContribution(amount, from: snapshot, direction: direction) + } dismiss() } diff --git a/PortfolioJournal/Views/Sources/SourceDetailView.swift b/PortfolioJournal/Views/Sources/SourceDetailView.swift index 9009901..342f9c4 100644 --- a/PortfolioJournal/Views/Sources/SourceDetailView.swift +++ b/PortfolioJournal/Views/Sources/SourceDetailView.swift @@ -10,6 +10,10 @@ struct SourceDetailView: View { @State private var showingDeleteConfirmation = false @Environment(\.managedObjectContext) private var viewContext @State private var editingSnapshotSelection: SnapshotSelection? + @State private var editingContribution = false + @State private var contributionInput = "" + @State private var showingContributionApplyDialog = false + @State private var pendingContributionAmount: Decimal? = nil init(source: InvestmentSource, iapService: IAPService) { _viewModel = StateObject(wrappedValue: SourceDetailViewModel( @@ -50,6 +54,9 @@ struct SourceDetailView: View { // Quick Actions quickActions + // Monthly Contribution + monthlyContributionCard + // Chart if !viewModel.chartData.isEmpty { chartSection @@ -185,6 +192,85 @@ struct SourceDetailView: View { } } + // MARK: - Monthly Contribution Card + + private var monthlyContributionCard: some View { + let sourceId = viewModel.source.id + let current = MonthlyContributionStore.contribution(for: sourceId) + return VStack(alignment: .leading, spacing: 10) { + HStack { + Label(String(localized: "source_monthly_contribution_title"), systemImage: "arrow.down.circle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + Spacer() + Button { + if editingContribution { + if let val = CurrencyFormatter.parseUserInput(contributionInput), val > 0 { + MonthlyContributionStore.setContribution(val, for: sourceId) + pendingContributionAmount = val + showingContributionApplyDialog = true + } else if contributionInput.trimmingCharacters(in: .whitespaces).isEmpty { + MonthlyContributionStore.setContribution(nil, for: sourceId) + } + } else { + contributionInput = current.map { String(format: "%.2f", NSDecimalNumber(decimal: $0).doubleValue) } ?? "" + } + editingContribution.toggle() + } label: { + Text(editingContribution ? String(localized: "done") : String(localized: "edit")) + .font(.caption.weight(.semibold)) + .foregroundColor(.appPrimary) + } + } + + if editingContribution { + TextField(String(localized: "source_monthly_contribution_placeholder"), text: $contributionInput) + .keyboardType(.decimalPad) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } else { + Text(current.map { $0.currencyString } ?? String(localized: "source_monthly_contribution_not_set")) + .font(.subheadline) + .foregroundColor(current != nil ? .primary : .secondary) + } + + Text(String(localized: "source_monthly_contribution_hint")) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(AppConstants.UI.cornerRadius) + .shadow(color: .black.opacity(0.05), radius: 8, y: 2) + .confirmationDialog( + String(localized: "source_monthly_contribution_apply_title"), + isPresented: $showingContributionApplyDialog, + titleVisibility: .visible + ) { + Button(String(localized: "source_monthly_contribution_apply_retroactive")) { + if let amount = pendingContributionAmount { + applyContributionRetroactively(amount: amount) + } + } + Button(String(localized: "source_monthly_contribution_apply_forward"), role: .cancel) {} + } message: { + if let amount = pendingContributionAmount { + Text(String(format: String(localized: "source_monthly_contribution_apply_message"), amount.currencyString)) + } + } + } + + private func applyContributionRetroactively(amount: Decimal) { + let request = Snapshot.fetchRequest() + request.predicate = NSPredicate(format: "source == %@ AND (contribution == nil OR contribution == 0)", viewModel.source) + guard let snapshots = try? viewContext.fetch(request) else { return } + for snapshot in snapshots { + snapshot.contribution = NSDecimalNumber(decimal: amount) + } + try? viewContext.save() + } + // MARK: - Chart Section private var chartSection: some View { diff --git a/PortfolioJournal/Views/Sources/SourceListView.swift b/PortfolioJournal/Views/Sources/SourceListView.swift index 8ea9e4b..df411bf 100644 --- a/PortfolioJournal/Views/Sources/SourceListView.swift +++ b/PortfolioJournal/Views/Sources/SourceListView.swift @@ -59,6 +59,7 @@ struct SourceListView: View { let source = try? context.existingObject(with: id) as? InvestmentSource, !source.isDeleted { SourceDetailView(source: source, iapService: iapService) + .id(id) } else { noSourceSelectedView } diff --git a/PortfolioJournalWidget/Info.plist b/PortfolioJournalWidget/Info.plist index 32695ab..fe50bad 100644 --- a/PortfolioJournalWidget/Info.plist +++ b/PortfolioJournalWidget/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - $(MARKETING_VERSION) + 1.4.1 CFBundleVersion - $(CURRENT_PROJECT_VERSION) + 42 ITSAppUsesNonExemptEncryption NSExtension diff --git a/PortfolioJournalWidget/InvestmentWidget.swift b/PortfolioJournalWidget/InvestmentWidget.swift index 8e15558..af12073 100644 --- a/PortfolioJournalWidget/InvestmentWidget.swift +++ b/PortfolioJournalWidget/InvestmentWidget.swift @@ -74,6 +74,8 @@ struct InvestmentWidgetEntry: TimelineEntry { let categoryTotals: [(name: String, value: Decimal, color: String)] let goals: [GoalSummary] let currencyCode: String + let insightTitle: String + let insightValue: String } struct CategorySeries: Identifiable { @@ -139,7 +141,9 @@ struct InvestmentWidgetProvider: TimelineProvider { goals: [ GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil) ], - currencyCode: "EUR" + currencyCode: "EUR", + insightTitle: "Milestone ahead", + insightValue: "€5K from €50K" ) } @@ -176,7 +180,9 @@ struct InvestmentWidgetProvider: TimelineProvider { categoryEvolution: [], categoryTotals: [], goals: [], - currencyCode: currencyCode + currencyCode: currencyCode, + insightTitle: "", + insightValue: "" ) } @@ -362,6 +368,41 @@ struct InvestmentWidgetProvider: TimelineProvider { } } + // Compute top insight + var insightTitle = "" + var insightValue = "" + + // Milestone approaching + let milestones: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000] + let totalDouble = NSDecimalNumber(decimal: totalValue).doubleValue + if let nextMs = milestones.first(where: { $0 > totalDouble }) { + let pct = totalDouble / nextMs + if pct >= 0.90 { + let gap = nextMs - totalDouble + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode + formatter.maximumFractionDigits = 0 + let msStr = formatter.string(from: NSNumber(value: nextMs)) ?? "" + let gapStr = formatter.string(from: NSNumber(value: gap)) ?? "" + insightTitle = "Milestone ahead" + insightValue = "\(gapStr) from \(msStr)" + } + } + + // Year performance (only if no milestone insight) + if insightTitle.isEmpty && !trendPoints.isEmpty && trendPoints.count >= 2 { + let first = NSDecimalNumber(decimal: trendPoints.first!).doubleValue + let last = NSDecimalNumber(decimal: trendPoints.last!).doubleValue + if first > 0 { + let pct = (last - first) / first * 100 + if abs(pct) >= 1 { + insightTitle = "Year to date" + insightValue = String(format: "%+.1f%%", pct) + } + } + } + return InvestmentWidgetEntry( date: Date(), isPremium: isPremium, @@ -374,7 +415,9 @@ struct InvestmentWidgetProvider: TimelineProvider { categoryEvolution: categoryEvolution, categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) }, goals: goals, - currencyCode: currencyCode + currencyCode: currencyCode, + insightTitle: insightTitle, + insightValue: insightValue ) } } @@ -498,6 +541,23 @@ struct MediumWidgetView: View { } } } + + if !entry.insightTitle.isEmpty { + HStack(spacing: 4) { + Image(systemName: "lightbulb.fill") + .font(.caption2) + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 0) { + Text(entry.insightTitle) + .font(.caption2) + .foregroundColor(.secondary) + Text(entry.insightValue) + .font(.caption.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + } + } + } } } .padding() @@ -930,7 +990,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle { categoryEvolution: [], categoryTotals: [], goals: [], - currencyCode: "EUR" + currencyCode: "EUR", + insightTitle: "", + insightValue: "" ) } @@ -953,7 +1015,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle { categoryEvolution: [], categoryTotals: [], goals: [], - currencyCode: "EUR" + currencyCode: "EUR", + insightTitle: "Year to date", + insightValue: "+12.4%" ) } @@ -1004,7 +1068,9 @@ struct PortfolioJournalWidgetBundle: WidgetBundle { goals: [ GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil) ], - currencyCode: "EUR" + currencyCode: "EUR", + insightTitle: "Milestone ahead", + insightValue: "€5K from €100K" ) } extension Decimal { diff --git a/Scripts/analyze_ga4.py b/Scripts/analyze_ga4.py new file mode 100644 index 0000000..9ce9d24 --- /dev/null +++ b/Scripts/analyze_ga4.py @@ -0,0 +1,185 @@ +""" +GA4 Analytics report for Portfolio Journal +Property: 300195945 +""" + +import os +from datetime import date, timedelta +from google.analytics.data_v1beta import BetaAnalyticsDataClient +from google.analytics.data_v1beta.types import ( + RunReportRequest, Dimension, Metric, DateRange, OrderBy, FilterExpression, Filter +) + +PROPERTY_ID = "519342587" + +import subprocess, json, tempfile + +def _fix_multiline_json(raw): + result, in_string, escape_next = [], False, False + for c in raw: + if escape_next: + result.append(c); escape_next = False + elif c == '\\': + result.append(c); escape_next = True + elif c == '"': + in_string = not in_string; result.append(c) + elif in_string and c == '\n': + result.append('\\n') + elif in_string and c == '\r': + pass + else: + result.append(c) + return ''.join(result) + +key_raw = subprocess.check_output(["pass", "show", "google/sirius-service-account"]).decode() +key_json = json.dumps(json.loads(_fix_multiline_json(key_raw))) +_tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) +_tmp.write(key_json); _tmp.flush() +os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = _tmp.name +client = BetaAnalyticsDataClient() + +TODAY = date.today().isoformat() +D7 = (date.today() - timedelta(days=7)).isoformat() +D28 = (date.today() - timedelta(days=28)).isoformat() +D90 = (date.today() - timedelta(days=90)).isoformat() + +def run(dimensions, metrics, date_from, date_to=TODAY, order_by=None, limit=20): + req = RunReportRequest( + property=f"properties/{PROPERTY_ID}", + dimensions=[Dimension(name=d) for d in dimensions], + metrics=[Metric(name=m) for m in metrics], + date_ranges=[DateRange(start_date=date_from, end_date=date_to)], + limit=limit, + ) + if order_by: + req.order_bys = order_by + return client.run_report(req) + +def run_no_dim(metrics, date_from, date_to=TODAY): + req = RunReportRequest( + property=f"properties/{PROPERTY_ID}", + metrics=[Metric(name=m) for m in metrics], + date_ranges=[DateRange(start_date=date_from, end_date=date_to)], + ) + return client.run_report(req) + +def header(title): + print(f"\n{'='*60}") + print(f" {title}") + print('='*60) + +def fmt_row(row, widths): + parts = [str(v.value).ljust(w) for v, w in zip(list(row.dimension_values) + list(row.metric_values), widths)] + print(" " + " ".join(parts)) + +# ── 1. Overview ──────────────────────────────────────────────── +header("OVERVIEW — últimos 28 días vs 7 días") + +r28 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D28) +r7 = run_no_dim(["activeUsers", "sessions", "screenPageViews", "averageSessionDuration"], D7) + +def ovw(r): + row = r.rows[0] + vals = [v.value for v in row.metric_values] + return vals + +v28 = ovw(r28) +v7 = ovw(r7) +labels = ["Usuarios activos", "Sesiones", "Pantallas vistas", "Duración media (s)"] +print(f"\n {'Métrica':<30} {'28d':>10} {'7d':>10}") +print(" " + "-"*52) +for l, a, b in zip(labels, v28, v7): + print(f" {l:<30} {a:>10} {b:>10}") + +# ── 2. Pantallas más visitadas ────────────────────────────────── +header("PANTALLAS MÁS VISITADAS — últimos 28d") +r = run(["unifiedScreenName"], ["screenPageViews", "activeUsers"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"), desc=True)]) +print(f"\n {'Pantalla':<40} {'Vistas':>8} {'Usuarios':>10}") +print(" " + "-"*60) +for row in r.rows: + fmt_row(row, [40, 8, 10]) + +# ── 3. Eventos clave ──────────────────────────────────────────── +header("EVENTOS CLAVE — últimos 28d") +r = run(["eventName"], ["eventCount", "totalUsers"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], + limit=30) +print(f"\n {'Evento':<45} {'Count':>8} {'Usuarios':>10}") +print(" " + "-"*65) +for row in r.rows: + fmt_row(row, [45, 8, 10]) + +# ── 4. Funnel paywall ─────────────────────────────────────────── +header("FUNNEL PAYWALL — últimos 90d") +paywall_events = [ + "paywall_shown", "purchase_attempt", "purchase_success", "purchase_failure", + "restore_purchases" +] +r = run(["eventName"], ["eventCount", "totalUsers"], D90, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], + limit=50) +print(f"\n {'Evento paywall':<40} {'Count':>8} {'Usuarios':>10}") +print(" " + "-"*60) +for row in r.rows: + name = row.dimension_values[0].value + if any(p in name for p in ["paywall", "purchase", "restore", "premium"]): + fmt_row(row, [40, 8, 10]) + +# ── 5. Triggers del paywall ───────────────────────────────────── +header("TRIGGERS DEL PAYWALL — últimos 90d (qué feature lo dispara)") +try: + r = run(["eventName", "customEvent:paywall_trigger"], ["eventCount"], D90, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="eventCount"), desc=True)], + limit=30) + rows = [row for row in r.rows if "paywall" in row.dimension_values[0].value] + if rows: + print(f"\n {'Evento':<30} {'Trigger':<25} {'Count':>8}") + print(" " + "-"*65) + for row in rows: + fmt_row(row, [30, 25, 8]) + else: + print("\n (sin eventos paywall con trigger en este periodo — los datos aparecerán desde hoy)") +except Exception as e: + print(f"\n Error: {e}") + +# ── 6. Retención — nuevos vs recurrentes ─────────────────────── +header("NUEVOS vs RECURRENTES — últimos 28d") +r = run(["newVsReturning"], ["activeUsers", "sessions", "screenPageViews"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)]) +print(f"\n {'Tipo':<20} {'Usuarios':>10} {'Sesiones':>10} {'Pantallas':>10}") +print(" " + "-"*54) +for row in r.rows: + fmt_row(row, [20, 10, 10, 10]) + +# ── 7. Versiones de app ───────────────────────────────────────── +header("VERSIONES EN USO — últimos 28d") +r = run(["appVersion"], ["activeUsers", "sessions"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], + limit=10) +print(f"\n {'Versión':<20} {'Usuarios':>10} {'Sesiones':>10}") +print(" " + "-"*44) +for row in r.rows: + fmt_row(row, [20, 10, 10]) + +# ── 8. Dispositivos ───────────────────────────────────────────── +header("DISPOSITIVOS — últimos 28d") +r = run(["deviceModel"], ["activeUsers"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], + limit=10) +print(f"\n {'Dispositivo':<30} {'Usuarios':>10}") +print(" " + "-"*44) +for row in r.rows: + fmt_row(row, [30, 10]) + +# ── 9. País / región ──────────────────────────────────────────── +header("PAÍSES — últimos 28d") +r = run(["country"], ["activeUsers"], D28, + order_by=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="activeUsers"), desc=True)], + limit=10) +print(f"\n {'País':<30} {'Usuarios':>10}") +print(" " + "-"*44) +for row in r.rows: + fmt_row(row, [30, 10]) + +print("\n") diff --git a/fastlane/Deliverfile b/fastlane/Deliverfile index dd16523..f777764 100644 --- a/fastlane/Deliverfile +++ b/fastlane/Deliverfile @@ -1 +1 @@ -app_version "1.3.2" +app_version "1.4.0" diff --git a/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/de-DE/release_notes.txt index e112315..00e8227 100644 --- a/fastlane/metadata/de-DE/release_notes.txt +++ b/fastlane/metadata/de-DE/release_notes.txt @@ -1,8 +1,9 @@ -Native iPad-Unterstützung und neue Portfolio-Tools: +Neue Charts, schnelle Updates und Portfolio-Einblicke: -• iPad — vollständig native Erfahrung mit Split-View-Navigation und optimierten Layouts für Dashboard, Quellen, Charts und Journal -• Ziele archivieren — erledigte Ziele archivieren, um die Liste übersichtlich zu halten -• Kategorieverwaltung — eigene Anlagekategorien in den Einstellungen erstellen und anpassen -• Jahresvergleichs-Chart — Portfolio-Wert über mehrere Jahre auf einen Blick vergleichen (Premium) -• Flexibler CSV-Import — beliebiges Spaltenformat für den Import von jedem Broker zuordnen +• Schnell-Update — Snapshots für alle Quellen auf einmal vom Dashboard hinzufügen +• Portfolio-Einblicke — clevere Beobachtungen zu deinem Portfolio (Streak, Gewinne, Meilensteine, Prognosen) +• 3 neue Premium-Charts: Quellen vergleichen, Was-wäre-wenn-Simulator, Zeitraum vs. Zeitraum +• Monatlicher Beitrag — Standard-Beitrag pro Quelle festlegen, auch rückwirkend anwenden +• Streak-Badge — aufeinanderfolgende Monate des Trackings im Dashboard sehen +• Intelligentere Benachrichtigungen — Erinnerungen überspringen bereits erledigte Monate; Meilenstein-Alerts • Fehlerbehebungen und Leistungsverbesserungen diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt index e87a5cd..cb38846 100644 --- a/fastlane/metadata/en-US/release_notes.txt +++ b/fastlane/metadata/en-US/release_notes.txt @@ -1,8 +1,9 @@ -Native iPad support and new portfolio tools: +New charts, quick updates and portfolio insights: -• iPad — full native experience with split-view navigation, optimised layouts for Dashboard, Sources, Charts and Journal -• Goal archiving — archive completed goals to keep your list focused -• Category management — create, edit and customise your investment categories from Settings -• Year-over-year chart — compare your portfolio value across multiple years at a glance (Premium) -• Flexible CSV import — map any column format to import data from any broker +• Quick Update — add snapshots to all your sources at once from the Dashboard +• Portfolio Insights — smart observations about your portfolio (streak, gains, milestones, forecasts) +• 3 new Premium charts: Compare Sources, What If Simulator, Period vs Period +• Monthly contribution — set a default contribution per source, apply retroactively +• Streak badge — see your consecutive months tracking streak on the Dashboard +• Smarter notifications — check-in reminders skip months you have already completed; milestone alerts when your portfolio crosses round numbers • Bug fixes and performance improvements diff --git a/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/es-ES/release_notes.txt index e186988..e3c24e4 100644 --- a/fastlane/metadata/es-ES/release_notes.txt +++ b/fastlane/metadata/es-ES/release_notes.txt @@ -1,8 +1,9 @@ -Soporte nativo para iPad y nuevas herramientas de cartera: +Nuevos gráficos, actualizaciones rápidas e insights de cartera: -• iPad — experiencia nativa completa con navegación en vista dividida y diseños optimizados para Dashboard, Fuentes, Gráficos y Diario -• Archivado de objetivos — archiva los objetivos completados para mantener tu lista organizada -• Gestión de categorías — crea, edita y personaliza tus categorías de inversión desde Ajustes -• Gráfico año a año — compara el valor de tu cartera en múltiples años de un vistazo (Premium) -• Importación CSV flexible — mapea cualquier formato de columnas para importar datos de cualquier bróker +• Actualización rápida — añade snapshots a todas tus fuentes de una vez desde el Dashboard +• Insights de cartera — observaciones inteligentes sobre tu cartera (racha, ganancias, hitos, previsiones) +• 3 nuevos gráficos Premium: Comparar Fuentes, Simulador ¿Y si...?, Período vs Período +• Contribución mensual — configura una contribución predeterminada por fuente, aplícala retroactivamente +• Racha — visualiza tu racha de meses consecutivos de seguimiento en el Dashboard +• Notificaciones más inteligentes — los recordatorios saltan meses ya completados; alertas al cruzar hitos redondos • Correcciones de errores y mejoras de rendimiento diff --git a/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/fr-FR/release_notes.txt index 24fed1f..189d793 100644 --- a/fastlane/metadata/fr-FR/release_notes.txt +++ b/fastlane/metadata/fr-FR/release_notes.txt @@ -1,8 +1,9 @@ -Support iPad natif et nouveaux outils de portefeuille : +Nouveaux graphiques, mises à jour rapides et insights de portefeuille : -• iPad — expérience native complète avec navigation en vue fractionnée et mises en page optimisées pour le Tableau de bord, les Sources, les Graphiques et le Journal -• Archivage des objectifs — archivez les objectifs atteints pour garder votre liste organisée -• Gestion des catégories — créez, modifiez et personnalisez vos catégories d'investissement depuis les Réglages -• Graphique année par année — comparez la valeur de votre portefeuille sur plusieurs années d'un coup d'œil (Premium) -• Import CSV flexible — mappez n'importe quel format de colonnes pour importer des données de n'importe quel courtier +• Mise à jour rapide — ajoutez des instantanés à toutes vos sources depuis le Tableau de bord +• Insights de portefeuille — observations intelligentes sur votre portefeuille (série, gains, jalons, prévisions) +• 3 nouveaux graphiques Premium : Comparer les Sources, Simulateur Et si…, Période vs Période +• Contribution mensuelle — définissez une contribution par défaut par source, applicable rétroactivement +• Badge de série — consultez votre série de mois consécutifs de suivi sur le Tableau de bord +• Notifications plus intelligentes — rappels de bilan sautant les mois déjà complétés ; alertes de jalons • Corrections de bugs et améliorations des performances diff --git a/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/it/release_notes.txt index aa08b90..74f796d 100644 --- a/fastlane/metadata/it/release_notes.txt +++ b/fastlane/metadata/it/release_notes.txt @@ -1,8 +1,9 @@ -Supporto nativo iPad e nuovi strumenti per il portafoglio: +Nuovi grafici, aggiornamenti rapidi e insight sul portafoglio: -• iPad — esperienza nativa completa con navigazione a vista divisa e layout ottimizzati per Dashboard, Fonti, Grafici e Diario -• Archiviazione obiettivi — archivia gli obiettivi raggiunti per mantenere la lista organizzata -• Gestione categorie — crea, modifica e personalizza le tue categorie di investimento dalle Impostazioni -• Grafico anno su anno — confronta il valore del portafoglio su più anni in un colpo d'occhio (Premium) -• Import CSV flessibile — mappa qualsiasi formato di colonne per importare dati da qualsiasi broker +• Aggiornamento rapido — aggiungi snapshot a tutte le fonti in una volta dalla Dashboard +• Insight portafoglio — osservazioni intelligenti sul tuo portafoglio (serie, guadagni, traguardi, previsioni) +• 3 nuovi grafici Premium: Confronta Fonti, Simulatore E se…, Periodo vs Periodo +• Contributo mensile — imposta un contributo predefinito per fonte, applicabile retroattivamente +• Badge serie — visualizza la tua serie di mesi consecutivi di monitoraggio nella Dashboard +• Notifiche più intelligenti — i promemoria saltano i mesi già completati; avvisi al raggiungimento di traguardi • Correzioni di bug e miglioramenti delle prestazioni diff --git a/fastlane/metadata/ja/release_notes.txt b/fastlane/metadata/ja/release_notes.txt index f87adc9..50db994 100644 --- a/fastlane/metadata/ja/release_notes.txt +++ b/fastlane/metadata/ja/release_notes.txt @@ -1,8 +1,9 @@ -ネイティブiPadサポートと新しいポートフォリオツール: +新しいチャート、クイック更新、ポートフォリオインサイト: -• iPad — スプリットビューナビゲーションとダッシュボード、ソース、チャート、ジャーナルに最適化されたレイアウトを備えた完全なネイティブ体験 -• 目標のアーカイブ — 達成した目標をアーカイブしてリストを整理 -• カテゴリ管理 — 設定から投資カテゴリを作成・編集・カスタマイズ -• 年別比較チャート — 複数年にわたるポートフォリオの価値を一目で比較(プレミアム) -• 柔軟なCSVインポート — 任意の証券会社のデータをインポートするために列形式をマッピング +• クイック更新 — ダッシュボードからすべてのソースのスナップショットを一括追加 +• ポートフォリオインサイト — 連続記録、利益、マイルストーン、予測など的確な観察 +• 3つの新しいプレミアムチャート:ソース比較、もしもシミュレーター、期間比較 +• 月次積立 — ソースごとにデフォルトの積立額を設定、過去に遡って適用可能 +• ストリークバッジ — ダッシュボードで連続記録月数を確認 +• スマート通知 — 完了済みの月はスキップ;節目の金額達成時にアラート • バグ修正とパフォーマンス改善 diff --git a/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/pt-BR/release_notes.txt index 9884a50..147fa3e 100644 --- a/fastlane/metadata/pt-BR/release_notes.txt +++ b/fastlane/metadata/pt-BR/release_notes.txt @@ -1,8 +1,9 @@ -Suporte nativo ao iPad e novas ferramentas de portfólio: +Novos gráficos, atualizações rápidas e insights de portfólio: -• iPad — experiência nativa completa com navegação em tela dividida e layouts otimizados para Dashboard, Fontes, Gráficos e Diário -• Arquivamento de objetivos — arquive objetivos concluídos para manter sua lista organizada -• Gerenciamento de categorias — crie, edite e personalize suas categorias de investimento nas Configurações -• Gráfico ano a ano — compare o valor do portfólio em vários anos de forma rápida (Premium) -• Importação CSV flexível — mapeie qualquer formato de colunas para importar dados de qualquer corretora +• Atualização rápida — adicione snapshots de todas as fontes de uma vez pelo Dashboard +• Insights de portfólio — observações inteligentes sobre seu portfólio (sequência, ganhos, marcos, previsões) +• 3 novos gráficos Premium: Comparar Fontes, Simulador E se…, Período vs Período +• Contribuição mensal — defina uma contribuição padrão por fonte, aplicável retroativamente +• Sequência — veja sua sequência de meses consecutivos de acompanhamento no Dashboard +• Notificações mais inteligentes — lembretes ignoram meses já concluídos; alertas ao atingir marcos • Correções de bugs e melhorias de desempenho