1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad

- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed)
  con diálogo de propagación al editar: solo este / adelante / atrás / todos
  (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged)
- 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba
  chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos;
  ahora agrupa por mes calendario crudo
- 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para
  recrear el StateObject al cambiar de selección
- 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs
  arriba / detalle debajo
- 145: card de contribución mensual en SourceDetailView
- Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -441,6 +441,7 @@ class CoreDataStack: ObservableObject {
viewContext.refreshAllObjects()
}
}
}
// MARK: - Shared Container for Widgets
@@ -0,0 +1,9 @@
import SwiftUI
struct PortfolioInsight: Identifiable {
let id: String
let systemImage: String
let title: String
let value: String
let accentColor: Color
}
@@ -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<Snapshot> 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) {
+2 -2
View File
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<string>1.4.1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<string>42</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
@@ -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!";
@@ -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!";
@@ -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!";
@@ -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 !";
@@ -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!";
@@ -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" = "ポートフォリオが%@を突破しました。おめでとうございます!";
@@ -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!";
@@ -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
+155 -339
View File
@@ -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..<values.count {
smoothed = alpha * values[i] + (1 - alpha) * smoothed
}
for i in 1..<values.count { smoothed = alpha * values[i] + (1 - alpha) * smoothed }
// Calculate trend
var trend: Double = 0
if values.count >= 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..<splitIndex {
smoothed = 0.3 * values[i] + 0.7 * smoothed
}
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() {
for i in 1..<splitIndex { smoothed = 0.3 * values[i] + 0.7 * smoothed }
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 predicted = smoothed + (smoothed - values[splitIndex - 1]) * Double(i + 1) / Double(splitIndex)
ssRes += pow(actual - predicted, 2)
ssTot += pow(actual - meanValidation, 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: - Moving Average
func predictMovingAverage(
snapshots: [Snapshot],
monthsAhead: Int = 12,
windowSize: Int = 3
) -> [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..<values.count {
changes.append(values[i] - values[i - 1])
}
for i in 1..<values.count { changes.append(values[i] - values[i - 1]) }
let avgChange = changes.isEmpty ? 0 : changes.reduce(0, +) / Double(changes.count)
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, movingAverage + avgChange * Double(month))
let intervalWidth = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
predictions.append(Prediction(
date: futureDate,
predictedValue: Decimal(predictedValue),
algorithm: .movingAverage,
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, movingAvg + avgChange * Double(month))
let width = stdDev * 1.96 * (1.0 + Double(month) * 0.03)
return Prediction(date: futureDate, predictedValue: Decimal(predicted), algorithm: .movingAverage,
confidenceInterval: .init(lower: Decimal(max(0, predicted - width)), upper: Decimal(predicted + width)))
}
return predictions
}
private func calculateMAAccuracy(snapshots: [Snapshot]) -> 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)..<splitIndex])
let movingAvg = recentWindow.reduce(0, +) / Double(windowSize)
let validationValues = Array(values.suffix(from: splitIndex))
let meanValidation = validationValues.reduce(0, +) / Double(validationValues.count)
var ssRes: Double = 0
var ssTot: Double = 0
for actual in validationValues {
let validation = Array(values.suffix(from: splitIndex))
let mean = validation.reduce(0, +) / Double(validation.count)
var ssRes = 0.0, ssTot = 0.0
for actual in validation {
ssRes += pow(actual - movingAvg, 2)
ssTot += pow(actual - meanValidation, 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: - Holt Trend (Double Exponential Smoothing)
func predictHoltTrend(
snapshots: [Snapshot],
monthsAhead: Int = 12,
alpha: Double = 0.4,
beta: Double = 0.3
) -> [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..<values.count {
guard values[i - 1] != 0 else { continue }
let periodReturn = (values[i] - values[i - 1]) / values[i - 1] * 100
returns.append(periodReturn)
returns.append((values[i] - values[i - 1]) / values[i - 1] * 100)
}
return calculateStdDev(values: returns)
}
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 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
)
}
}
@@ -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)
}
}
}
+167 -75
View File
@@ -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<Int> = []
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<UUID> = []
@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..<monthlyTotals.count {
let current = monthlyTotals[index]
let base = monthlyTotals[index - 12]
for index in 12..<totals.count {
let current = totals[index]
let base = totals[index - 12]
guard base.totalValue > 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..<totals.count {
let prev = totals[i - 1].totalValue
let curr = totals[i].totalValue
guard prev > 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..<sortedSnapshots.count {
let window = Array(sortedSnapshots[(i - windowSize)..<i])
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
let mean = values.reduce(0, +) / Double(values.count)
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
let stdDev = sqrt(variance)
let volatility = mean > 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)..<monthlyReturns.count {
let window = Array(monthlyReturns[(i - windowSize + 1)...i])
let mean = window.reduce(0, +) / Double(window.count)
let variance = window.map { pow($0 - mean, 2) }.reduce(0, +) / Double(max(1, window.count - 1))
let stdDev = sqrt(max(0, variance))
data.append((date: returnDates[i], volatility: stdDev))
}
volatilityData = data
@@ -1089,39 +1126,33 @@ class ChartsViewModel: ObservableObject {
predictionData = []
return
}
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
let totals = monthlyTotals(from: snapshots)
let series = totals.map { (date: $0.date, value: $0.totalValue) }
let result = predictionEngine.predict(series: series, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func calculateYearOverYearData(from snapshots: [Snapshot]) {
guard !snapshots.isEmpty else {
private func calculateYearOverYearData(from _: [Snapshot]) {
// Always use full history (not time-range filtered)
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.isEmpty else {
yearOverYearData = []
return
}
// Build all monthly totals (no time range filter for YoY)
let allSourceIds = sourceRepository.sources.filter { shouldIncludeSource($0) }.compactMap { $0.id }
let allSnapshots: [Snapshot]
if let cached = cachedSnapshots {
allSnapshots = cached
} else {
allSnapshots = snapshotRepository.fetchSnapshots(for: allSourceIds, months: maxHistoryMonths)
}
let calendar = Calendar.current
// group by (year, month), take latest per source
var byYearMonth: [Int: [Int: Decimal]] = [:]
var latestBySource: [UUID: Snapshot] = [:]
let sorted = allSnapshots.sorted { $0.date < $1.date }
for snap in sorted {
guard let sourceId = snap.source?.id else { continue }
latestBySource[sourceId] = snap
let year = calendar.component(.year, from: snap.date)
let month = calendar.component(.month, from: snap.date) // 1-based
let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue }
byYearMonth[year, default: [:]][month] = total
// Group monthly totals by (year, month)
var byYearMonth: [Int: [Int: Decimal]] = [:]
for point in totals {
let year = calendar.component(.year, from: point.date)
let month = calendar.component(.month, from: point.date)
byYearMonth[year, default: [:]][month] = point.totalValue
}
let years = byYearMonth.keys.sorted()
@@ -1130,25 +1161,70 @@ class ChartsViewModel: ObservableObject {
return
}
// Forward-fill within each year: carry previous month if no data
yearOverYearData = years.map { year in
var values = [Decimal](repeating: .zero, count: 12)
var last: Decimal = .zero
let nowYear = calendar.component(.year, from: Date())
let nowMonth = calendar.component(.month, from: Date())
// For each year, compute cumulative % return from the first available month (baseline = 0%)
yearOverYearData = years.map { year -> 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 {
@@ -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))
}
@@ -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<AnyCancellable>()
@@ -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
@@ -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)
}
@@ -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
@@ -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)
}
}
}
@@ -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 {
@@ -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
@@ -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<Date>,
end: Binding<Date>
) -> some View {
HStack(spacing: 8) {
Circle()
private func periodRow(label: String, color: Color, start: Binding<Date>, end: Binding<Date>) -> 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()
}
}
@@ -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 {
@@ -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
)
}
}
}
@@ -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")
@@ -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)
)
}
}
@@ -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
}
@@ -11,6 +11,7 @@ struct QuickUpdateView: View {
) private var sources: FetchedResults<InvestmentSource>
@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<String> {
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func contributionBinding(for source: InvestmentSource) -> Binding<String> {
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
@@ -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()
@@ -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]
}
}
@@ -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()
}
}
@@ -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.")
}
}
@@ -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()
}
@@ -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 {
@@ -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
}