126 Commits

Author SHA1 Message Date
alexandrev-tibco fa7df50400 Review prompt tras check-in: ≥2 meses de histórico, una vez por versión (build 85) #41
- ReviewRequestService: gate por meses distintos con snapshots + versión
- QuickUpdateView.saveAll y AddSnapshotView.performSave (solo alta, no edición) piden reseña via \.requestReview tras cerrar el sheet
- Motivo: 0 ratings visibles en US, 1 en ES — la ficha no convierte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-09-01 11:23:31 +02:00
alexandrev-tibco 66dc854461 Fix instrumentación: snapshot_added también con valor inicial en AddSourceView (build 85) #39
El snapshot creado desde el valor inicial no registraba snapshot_added en Firebase — el funnel de activación quedaba infracontado justo tras el cambio de la 1.6.1 que empuja a ese camino.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-08-26 14:29:04 +02:00
alexandrev-tibco f2f54a5ca3 Activación: valor inicial como primer check-in + CTA en Quick Update vacío (build 84) #37
- AddSourceView: sección de valor reencuadrada como 'Valor de hoy' (primer check-in); si se omite, abre AddSnapshotView automáticamente en vez de dejar al usuario en el dashboard vacío
- QuickUpdateView: estado sin fuentes ahora tiene CTA 'Añade tu primera fuente' → .openAddFirstSource
- 3 claves nuevas localizadas en los 7 idiomas
- Bump a 1.6.1 (84)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXtwQP6nYnbLFVqsHiTK5G
2026-08-23 12:37:58 +02:00
alexandrev-tibco 3b1f62cc26 Share cards compartibles: brand header grande, fecha, Dark/Light (build 83) #35
- Header de marca arriba, grande (logo BrandMark 48px + nombre title2 rounded),
  nunca se corta en Story (9:16 real 360x640, Post 4:5 360x450).
- Héroe = retorno % (privacy/branding-friendly), valor como apoyo.
- Fecha del último check-in bajo el nombre (share_as_of) para dar contexto a
  quien no conoce la app; DashboardViewModel.shareAsOfDate.
- Tema Dark + Light (ShareCardTheme) con selector en el sheet + preview vivo.
- Sparkline real (viewModel.evolutionData), ya se pasaba.
- CTA de descarga + QR con ct=snapshot_share (atribución App Analytics).
- L10n x7 (as_of, theme_dark/light, cta, hero, metrics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-07 09:45:10 +02:00
alexandrev-tibco 8084bc6a82 Share cards 2.0: dark panorama + privacidad + Story + atribución (build 82) #34
Rediseño de las cards de compartir con el sistema visual de las screenshots
ASO: fondo navy con glows y grid, sparkline de evolución con gradiente
azul→cian y punto final, chip de delta, footer con marca + QR. Aplica a
Portfolio Snapshot, Monthly Check-in y Monthly Summary.

Potencia referral: sheet de opciones previo con preview en vivo
(PortfolioShareOptionsView) — toggle «Solo porcentajes» (sin importes
absolutos, la fricción #1 para compartir) y formato Post/Story 9:16. QR y
link con ?ct=snapshot_share para atribución en App Analytics. Analytics:
share type portfolio_value_percent. L10n ×7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 23:09:59 +02:00
alexandrev-tibco 6e6f4c5489 Dashboard: quitar títulos duplicados en cards de gráficas (build 81) #33
showsTitle (default true) en HorizontalPerformanceChart, DrawdownChart,
VolatilityChartView, PredictionChartView y YearOverYearChartView; el
pageContent del Dashboard pasa false porque el header de la card ya nombra
la página. La sección Charts no cambia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 22:55:27 +02:00
alexandrev-tibco cf984dbf09 Quitar swipe entre charts + ventana del brush deslizable (build 80) #31 #32
Feedback TF build 79: fuera el swipe entre gráficas (navegación = chip bar;
el gesto chocaba con scrub/pan/brush). Brush con gesto unificado decidido en
touch-down: cerca de un asa (18pt) redimensiona; en cualquier otro punto
(dentro de la ventana o sobre el historial atenuado) desliza la ventana entera
manteniendo el ancho — seleccionar ene–mar y arrastrar hasta jun–ago. Con
ventanas estrechas las asas solo agarran desde fuera, el interior siempre mueve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 21:46:07 +02:00
alexandrev-tibco f1aeafccf6 Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el
swipe entre charts (el brush vivía dentro de chartContent, bajo el
simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de
periodo, fuera del área con gesto.

Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual
por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las
gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow,
comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries
(agregación mensual extraída de calculateEvolutionData como monthlySeries).
Presets limpian el rango custom; selección completa = sin filtro. El pinch
por-chart se mantiene como zoom visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 20:33:58 +02:00
alexandrev-tibco d941ecf27b Fix ITMS-90626: quitar 'Siri' de descripciones de App Intents + build 78
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 20:11:58 +02:00
alexandrev-tibco 9c2a67853d Bump build 77 (TestFlight: charts workable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 18:07:23 +02:00
alexandrev-tibco d38c6943e1 Charts iPhone workable: chip bar, ventana+brush, header vivo, drill-down, overlay #29 #30
Fix look&feel (#29): chip bar selector visible (sustituye page dots + title menu
como navegación primaria), sin bubbles permanentes en compact (solo endpoint),
Performance a barras horizontales con >6 categorías, línea con gradiente y
estética de widget, pinch anclado al centro + doble-tap reset + haptics.

Workable (#30): modelo de ventana (inicio,fin) único para pinch/pan/brush;
minimapa bajo demanda con asas snap-a-mes y ventana arrastrable; label de rango
con pickers mes/año precisos; header vivo (valor+delta del rango, scrub con
haptic); drill-down por tap en Allocation/Performance → Evolution filtrada
(premium, chip Filtrado ✕ para volver); overlay aportaciones acumuladas
(premium); insight contextual del rango visible (premium). L10n ×7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 18:03:50 +02:00
alexandrev-tibco 525911f67a Eliminar propiedad muerta sourcesByCategory (force-unwrap latente) #28
categories.first! petaría con una fuente sin categoría y categories vacío
(plausible en sync CloudKit). La propiedad no la consumía ninguna vista.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-02 15:21:36 +02:00
alexandrev-tibco 326be6db04 1.6.0 #10 ocultar saldos + Face ID · #11 widget interactivo + Siri
#10 Ocultar saldos: BalancePrivacyManager (balancesHidden + requireBiometricToReveal),
modifier .hiddenBalance() (blur + máscara de bullets, ancho estable), botón ojo en Dashboard,
revelar con Face ID vía AppLockService, sección Privacidad en Settings. Aplicado a total
portfolio, forecast, sources, goals, monthly summary.

#11 Siri/App Intents: NetWorthIntent ('¿cuál es mi patrimonio?' → 'Tu patrimonio es X'),
StreakIntent, 5 AppShortcuts con frases. Widget interactivo iOS 17: botón Refresh
(Button(intent:) → reloadAllTimelines, in-process) en medium/large, gated #available(iOS 17).
Reader read-only del AppGroup. Dialogs Siri refactorizados a String(localized:).

Localización: strings de #10 y de Siri en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:52:09 +02:00
alexandrev-tibco 648c2bd805 1.6.0 #9: auto-backup programado a iCloud Drive/Archivos
El BackupService (export JSON v3 lossless a local + iCloud ubiquity, retención,
restore, premium-gated) ya existía de 1.1.0 pero solo era manual. Añadido lo automático:
- BackupService.runScheduledBackupIfNeeded() (config vía UserDefaults, gate por
  backupsEnabled+premium, respeta frecuencia).
- Disparo en foreground (scenePhase .active) en background queue.
- Frecuencia Off/Diario/Semanal/Mensual + fecha del último backup en Settings
  (SettingsViewModel.autoBackupFrequencyDays + updateAutoBackupFrequency).
- Strings en 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:31:53 +02:00
alexandrev-tibco 6887d05b01 1.6.0 #6+#7: proyección/ETA de objetivos + insights inteligentes
Goal ETA (#6): GoalProjection engine (CAGR mensual robusto de la evolución +
aportaciones, proyecta mes a mes → fecha estimada, estado ahead/on-track/behind/
unreachable, sparkline). GoalsView muestra 'Projected: <mes>', chip de ritmo con color,
fallback 'Add contributions to reach this'. GoalsViewModel.projection(for:) cacheado.

Insights (#7): InsightsEngine rule-based on-device (sin red/AI). Reglas: allocation vs
target, riesgo de concentración, mejor/peor categoría, diversificación, racha, YTD,
ganancias, forecast, proximidad a milestone. Priorizados por tono, top 4. PortfolioInsight
gana detail + tone. Surface en el Dashboard.

Localización: 19 claves nuevas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:22:12 +02:00
alexandrev-tibco fd1094b4fc 1.6.0 #3: limpieza CloudKit-safe de duplicados de defaults (merge + Nullify)
Reglas de borrado Account.sources/goals y Category.sources: Cascade -> Nullify.
Así un borrado de padre (local o propagado por CloudKit al importar) NUNCA arrasa
hijos — elimina de raíz el mecanismo del desastre del build 68. Además es mejor UX
(borrar una categoría ya no borra todas sus sources+snapshots).

CoreDataStack.mergeDefaultDuplicates(): fusiona los Account 'Default' duplicados y
las categorías duplicadas por nombre en un ganador DETERMINISTA (el registro con el
UUID estable si existe, si no earliest createdAt / menor UUID → todos los dispositivos
convergen). Reasigna sources/goals al ganador y borra los perdedores (seguro por Nullify).
Llamado en la carga inicial y tras cada import remoto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:18:42 +02:00
alexandrev-tibco 922710c53f 1.6.0: auto-rellenar huecos (snapshots estimados) + widgets/lock screen enriquecidos
Auto-relleno (#5): SnapshotRepository.autoFillGap crea snapshots interpolados
linealmente entre los dos snapshots que bordean el hueco, marcados isEstimated.
deleteEstimatedSnapshots para borrado en bloque. Botón 'Autocompletar N meses'
(wand.and.stars) en DataGapsSheet + 'Añadir manualmente'. Idempotente. Strings 7 idiomas.

Widgets/Lock Screen (#8, vía agente): familias enriquecidas — systemSmall/Medium/Large
con net worth + cambio desde check-in + racha + próximo check-in + progreso de objetivo;
lock screen accessoryCircular (gauge objetivo/milestone), accessoryRectangular, accessoryInline.
Deep link quickupdate preservado. Solo InvestmentWidget.swift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:13:51 +02:00
alexandrev-tibco 1a3fce0df4 1.6.0 foundation: dedup prevención completa + atributo isEstimated
Dedup prevención: UUIDs estables (StableID v5) asignados al default Account y a las
categorías por defecto en createDefaultAccountIfNeeded, ensureDefaultCategoriesExist,
createDefaultCategories y el reset de SettingsViewModel → CloudKit fusiona nativamente,
no más acumulación de duplicados.

isEstimated: nuevo atributo Bool opcional (default NO, syncable) en Snapshot para marcar
snapshots interpolados del auto-relleno. Requiere deploy de schema CloudKit antes del
release. Compila verde (verificado antes de que el agente de widgets empezara a editar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:09:59 +02:00
alexandrev-tibco eee0910680 Rama 1.6.0: dedup CloudKit-safe (WIP: StableID + UUIDs estables en defaults)
Funde 1.5.1 en 1.6.0. Parte 1 dedup (prevención): StableID (UUIDv5 namespace+nombre),
Account.defaultAccountStableID, Category.stableID(for:) + asignación en
createDefaultCategories. Pendiente: asignar en repos + limpieza de duplicados existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 12:04:04 +02:00
alexandrev-tibco e4a93a5d4a Bump 1.5.1 (build 76)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 11:47:53 +02:00
alexandrev-tibco 20a73ea957 ASO: renderers dark-panorama (iPhone + iPad) para screenshots 1.5.0
frame_panorama_codex.py / _ipad.py: panorámica premium oscura (Impact, marco real,
+22.7% verde, línea continua) con copy localizado nativo en 7 idiomas. Usado para
las screenshots publicadas de la 1.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 11:47:52 +02:00
alexandrev-tibco 8d5f309f21 Bump build 75
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-24 09:36:51 +02:00
alexandrev-tibco 84d9240ee6 1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por
source (meses sin datos entre dos snapshots). Banner desechable en Dashboard +
DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa
la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan
datos reales.

Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca:
valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer
y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard.

Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run
READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks
de existencia que applyImport. ImportDataView muestra la confirmación antes de
importar. El path real de import (applyImport/importDataAsync) NO se toca.

Localización en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-24 09:36:50 +02:00
alexandrev-tibco 8d7cfc098b Bump build 74
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 20:13:22 +02:00
alexandrev-tibco df9309895a Export/import JSON lossless (v3): goals, monthly contributions, journal, ratings
El JSON v2 perdía Goals, source.monthlyContribution, category.allocationTarget y
todo el Journal (nota/mood/rating/completionTime). Nuevo formato v3:
- source: + monthlyContribution, customFrequencyMonths
- category: + allocationTarget
- top-level: + goals[] (name/targetAmount/targetDate/isActive/account)
- top-level: + journalEntries[] (monthKey/note/mood/rating/completionTime/createdAt)

Import parsea y aplica todo, creando Goals y JournalEntry DIRECTAMENTE en el
contexto de background (GoalRepository es @MainActor → no usable desde el import).
Dedup: goals por nombre+cuenta, journal por monthKey → reimport idempotente.
Retrocompatible con JSON v2. CSV sin cambios.

Guard: si el import trae journal, se salta el marcado de completions derivado de
snapshots (evita JournalEntry duplicados por el desfase de merge viewContext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 20:13:21 +02:00
alexandrev-tibco b60e3041cb Bump build 73
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 19:51:27 +02:00
alexandrev-tibco cbcb1da0ac Feature: rellenar huecos entre snapshots en las gráficas (interpolación lineal)
Nueva utilidad ChartGapFill: para cada source, rellena los meses sin snapshot
entre dos conocidos con interpolación lineal (Feb=100k, Jun=130k → Mar/Abr/May
~110/120/130k), arrastra el último valor tras el último snapshot y no aporta
antes del primero. Solo afecta al pintado de gráficas — NO crea ni modifica
snapshots (cero riesgo para la sync iCloud). Aplicado a las evolution del
Dashboard (total + por categoría) y de Charts. Toggle en Settings "Smooth Gaps
in Charts" (ON por defecto), localizado en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 19:51:26 +02:00
alexandrev-tibco fc05dfe2c6 Bump build 72
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 18:59:37 +02:00
alexandrev-tibco 047060f126 Fix check-ins no marcados al importar (setCompletionDate en hilo equivocado)
El import corre en un contexto de background, pero MonthlyCheckInStore muta
viewContext (main). Llamar setCompletionDate desde el background era una
violación de concurrencia de Core Data → los JournalEntry.completionTime de los
meses importados no persistían, dejando el "siguiente check-in" anclado a una
entrada vieja (p.ej. May 2025). Envuelto el marcado en viewContext.performAndWait
para ejecutarlo en la cola correcta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 18:59:36 +02:00
alexandrev-tibco 2ea32b5494 Bump build 71
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:38:33 +02:00
alexandrev-tibco d7149aad67 Fix ANR al terminar el import CSV (save + widget refresh por cada fila)
createSnapshot hacía save() por cada snapshot, y save() dispara refreshWidgetData
→ checkpointWAL (performAndWait en el viewContext main) + reloadAllTimelines. Con
213 snapshots eran 213 saves + 213 checkpoints en el main → Application Not
Responding. Añadido modo batch a createSnapshot (no guarda por fila; applyImport
ya guarda el contexto una vez al final) y flag suppressWidgetRefresh en
CoreDataStack para neutralizar el refresh por fila durante el import, con un único
refreshWidgetData al terminar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:38:32 +02:00
alexandrev-tibco 57280ca3ca Bump build 70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:02:57 +02:00
alexandrev-tibco 9539803d74 Fix crash al abrir Import Data en Mac Catalyst (@EnvironmentObject no heredado en sheets)
ImportDataView usa @EnvironmentObject (iapService/accountStore/tabSelection).
En Mac (Designed for iPad) las sheets no heredan el environment vía
PresentationHostingController → EnvironmentObject.error() / SIGTRAP al abrir
Import Data (ImportDataView.swift:247, shouldShowAccountSelection). Reinyectados
los 3 objects en las sheets de Settings, Dashboard y Onboarding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 11:02:56 +02:00
alexandrev-tibco d50926df76 Bump build 69 + cambios pendientes (scheme, AppShortcuts)
Build 69: primer build sin el auto-dedup destructivo. Base limpia para
recuperar los dispositivos vía TestFlight tras el incidente de build 68.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 19:58:24 +02:00
alexandrev-tibco 99dc855467 Desactiva auto-dedup (pérdida de datos cross-device) + forward-fill en gráficas de evolution
CRÍTICO: el auto-dedup de build 68 borraba entidades padre con regla Cascade
(Account/Category). Al propagarse el delete por CloudKit, otros dispositivos
aplicaban la cascada al importar ANTES de la reasignación de hijos → wipe de
sources/snapshots. Vació iPhone y iPad. Quitadas las llamadas automáticas a
cleanupLogicalDuplicates() en processRemoteChanges y en la carga; el UUID-dedup
seguro se mantiene. Métodos quedan definidos para un rediseño CloudKit-safe.

Gráficas: forward-fill por source en evolution/allocation/period para que el
total de cada mes refleje el último valor de CADA source (no solo las
actualizadas ese mes) y el último punto coincida con el total del portfolio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 19:58:13 +02:00
alexandrev-tibco 20b8a32ea3 Fix duplicados CloudKit cross-device: dedup por identidad lógica + bump build 68
cleanupLogicalDuplicates() colapsa registros con misma clave de negocio pero
distinto UUID (defaults sembrados por-dispositivo antes del primer import):
Account/Category por name, InvestmentSource por account+name, Snapshot por
source+día+valor, Goal por name+target, JournalEntry por monthKey. Ganador
determinista (createdAt asc, tiebreak UUID) → todos los dispositivos eligen el
mismo superviviente y los deletes convergen sin race. Reasigna hijos al ganador
antes de borrar. Snapshot dedup produce la UNIÓN → sin pérdida de historial.
Cableado en processRemoteChanges (tras import) + carga inicial (limpia lo ya
existente). El dedup previo por UUID no veía estos casos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-22 15:56:41 +02:00
alexandrev-tibco d87d9d3dd5 Debug helper: initializeCloudKitSchema (opt-in por launch arg -InitializeCloudKitSchema)
Materializa el modelo completo en el esquema de Development de CloudKit para poder desplegar
a Production los campos que nunca se exportaron (monthlyContribution, allocationTarget). Sin ellos,
Production rechaza los registros y el export falla en bloque (CKErrorPartialFailure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-22 09:56:33 +02:00
alexandrev-tibco f34460d97a Diagnóstico iCloud: dump completo del árbol CKError (códigos hoja + record types) + sonda de integridad local
deepErrorReport() sigue NSUnderlyingError y CKPartialErrorsByItemIDKey y extrae el recordType
del CKRecord embebido → dice qué entidad rechaza CloudKit. integrityReport() lista counts por
entidad + registros con id/createdAt nil u orphan snapshots. Ambos en el diagnóstico copiable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 18:00:22 +02:00
alexandrev-tibco e916218271 Bump build 66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 15:23:43 +02:00
alexandrev-tibco 7eb7cb81d4 Fix sync iCloud del Goal: accessor nil-tolerante en createdAt (Goal/Account/InvestmentSource)
El modelo declara createdAt optional=YES pero las clases lo tenían @NSManaged var createdAt: Date
(no-opcional). Al materializar un registro importado por CloudKit con createdAt transitoriamente
nil, el acceso revienta el merge → contribuye al CKErrorPartialFailure y a que el Goal no sincronice.
Mismo patrón safeValue/setManagedValue ya aplicado a Snapshot (hotfix b76b8f8) que no cubrió estas 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 15:22:42 +02:00
alexandrev-tibco 137931b37c Fix upload script: limpieza .p8 vía TRAPEXIT global (bug zsh: trap EXIT en función se dispara al retornar, borraba la key antes del archive)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 13:58:20 +02:00
alexandrev-tibco 32e1fb1c9c Bump build 65
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 13:56:39 +02:00
alexandrev-tibco 95c689d2b8 Home: carrusel de charts con gating premium (candado) + aviso de próximo check-in
- EvolutionChartCard: swipe entre 7 charts (evolution, allocation libres; performance,
  drawdown, volatility, prediction, year-over-year premium). No premium ve las premium
  con candado + CTA de unlock; premium las ve reales. Reutiliza ChartsViewModel (computa
  solo la página visible) y las vistas del feature Charts.
- MonthlyCheckInCard: aviso de próximo check-in siempre visible (fecha + badge de
  días restantes/atrasado), con fallback cuando no hay check-in previo.
- Localización de nuevas cadenas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-21 09:39:26 +02:00
alexandrev-tibco 2a5ffca48f Fix script upload App Store: .p8 en ruta fija (mktemp roto) + ExportOptions firma manual con 3 perfiles
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-20 21:20:43 +02:00
alexandrev-tibco 1e6e3ef361 Re-engagement con KPIs reales (goal ETA, YoY, mes, milestone, racha) + rotación (build 64)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZFmGhbWzibhApev3C4554
2026-07-20 19:29:27 +02:00
claude-code 8f14d4cd06 Add Crashlytics BigQuery report script
Consulta el export de Crashlytics en BigQuery (dataset firebase_crashlytics,
proyecto portfoliojournal-ef2d7) con el service account de pass. Top crashes por
versión/build. Setup documentado en el docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-13 00:54:07 +02:00
alexandrev-tibco 41e2a22a64 Swipe fiable en card del Home y Charts: simultaneousGesture sobre la gráfica (build 63)
El swipe entre páginas del Home (build 62) y entre charts (build 60) no
funcionaba encima de la gráfica: su DragGesture de scrub (minimumDistance 0)
consumía el gesto. Cambiado a .simultaneousGesture — ambos se reconocen; la
dominancia horizontal en onEnded evita romper el scroll vertical. Ahora el swipe
funciona en toda la card, también sobre el chart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-11 10:50:52 +02:00
alexandrev-tibco d8bd625327 Swipe entre mini-charts en la card del Home (build 62)
El feedback #4 original apuntaba a la card 'Portfolio Evolution' del HOME, no
solo a la pestaña Charts (donde ya se hizo en build 60). Ahora la card del Home
se desliza entre páginas:
- Evolution (con su toggle Total/By Category intacto)
- Allocation (semicírculo + leyenda con %), datos de viewModel.categoryMetrics
Puntos indicadores, transición direccional, título dinámico. 'By Category' no
es página aparte porque ya es un toggle dentro de Evolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 22:13:00 +02:00
alexandrev-tibco 7c6a29c821 OCR desde imagen del portapapeles + botón copiar diagnóstico iCloud (build 61)
- Quick Update: checkClipboard ahora detecta también una IMAGEN copiada
  (pb.hasImages) y hace OCR automático — te ofrece pegar el importe detectado
  sin pulsar 'Escanear'. Gated por changeCount. El 'Pegar €X' del teclado
  también usa el importe OCR'd de la imagen.
- Settings iCloud: botón 'Copiar diagnóstico de iCloud' SIEMPRE visible (el
  desplegable de detalle solo salía con un error activo, difícil de encontrar).
  Copia versión/build, contadores locales, últimas fechas import/export,
  criticidad, hint y el error completo con códigos internos. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 21:52:53 +02:00
alexandrev-tibco 6bdf2740c5 Feedback #3 y #4: pegar/OCR por campo en Quick Update + swipe entre charts (build 60)
#3 Quick Update paste/scan por campo:
- Barra de teclado con 'Pegar €X' (si hay importe en portapapeles) y 'Escanear'
  (PhotosPicker → OCR on-device). Actúan sobre el campo enfocado.
- ImageAmountScanner nuevo (app target, Vision) espejo del scanner del Share
  Extension: candidatos rankeados por prominencia, filtra % y años.
- 1 candidato → rellena directo; varios → confirmationDialog para elegir.
- Strings ×7.

#4 Swipe entre charts (iPhone):
- Gesto horizontal en el área del chart avanza al chart anterior/siguiente en
  el orden del menú de título (respeta showForecast). Indicador de página con
  puntos. Transición direccional. selectChart ya no bloquea premium (muestra
  teaser), así que deslizar a uno premium funciona.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 21:37:21 +02:00
alexandrev-tibco 86c95a9e73 Feedback TestFlight build 58 (build 59): labels, needs-update, period selector, Home masonry
De 7 comentarios de TestFlight:
- Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out
  (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie
  (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie.
  Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling,
  Drawdown, Volatility, Compare, Period, YoY, Prediction.
- 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth
  — el estado es por el MES QUE SE VE (effective month de referenceDate), no
  contra la última completion global. Diff usa el snapshot de ese mes.
- Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado
  consistente con el resto, opciones capadas a los meses de datos disponibles
  (availableHistoryMonths). Ya no deja pedir ventanas sin datos.
- Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho
  (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView.

Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK
→ es el subset del partial-failure, no filtro ni esquema; necesita códigos
internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 20:36:15 +02:00
alexandrev-tibco 1abffdb7bb Feedback iPad: allocation grande + %, Home 2 columnas, YoY KPIs, achievements fix, iCloud calmado (build 58)
Allocation:
- Semicírculo responsive que llena su mitad de la fila (grande en iPad, no
  minúsculo en una esquina) + porcentaje dentro de cada segmento >=7%.
  SemicircleArc acepta center explícito; labels en el ángulo medio del arco.

Home iPad:
- Ya no columna única estrecha a 720. Hero + check-in a ancho completo, resto
  de cards en DOS columnas masonry independientes (sin acoplar alturas → sin
  gaps del grid). Cap 1100pt.

Year vs Year KPIs:
- Retorno del año actual + diferencia (pp) vs año anterior + mejor/peor año
  histórico. Usa forecastEndValue para el año en curso.

Fixes:
- 'View all achievements' en Journal no hacía nada: NavigationLink inline
  (frágil según contexto de navegación) → sheet fiable en cualquier sitio.
- iCloud: CKErrorPartialFailure tras una sync que funciona ya NO se pinta en
  rojo alarmante — nota calmada 'Sincronizando con iCloud' + detalle técnico
  en disclosure. Rojo solo si nunca ha sincronizado y es fallo total.
  syncErrorIsCritical + strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:48:06 +02:00
alexandrev-tibco 18f2609d87 Fixes UI iPad + diagnóstico iCloud (build 57)
Feedback del usuario en iPad:
- Allocation: gráfica de tarta → semicírculo estilo hemiciclo (parlamento).
  SemicircleAllocation con arcos dibujados a mano (SemicircleArc Shape),
  segmentos por cuota en 180°, total/selección en el centro, tap por segmento.
- Charts de Analyze (Compare, Period vs Period, Year vs Year) ahora SÍ muestran
  KPIs en la cabecera de iPad como el resto: Compare (líder/rezagado por modo),
  Period (retorno final por periodo), YoY (retorno final por año seleccionado).
- Momentum & Streaks en el sidebar del Journal (iPad, 320pt): variante compact
  — tiles en fila sin subtítulos que rompían a 3 líneas, sin bloque de logros.

iCloud:
- CKErrorPartialFailure (CKErrorDomain code 2) mostraba un mensaje opaco.
  CoreDataStack.hint(for:) recorre CKPartialErrorsByItemIDKey y clasifica el
  código dominante → hint accionable (esquema/quota/red). Settings muestra el
  hint legible sobre el detalle técnico. lastSyncErrorHint publicado. Strings ×7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 14:16:24 +02:00
alexandrev-tibco b76b8f81f0 Hotfix: accessors nil-tolerantes en Core Data — crashes de bridging con iCloud sync (build 56)
Dos crashes de producción con la misma causa raíz: atributos declarados
no-opcionales en Swift (@NSManaged id: UUID, date: Date) que llegan nil en
runtime — NSPersistentCloudKitContainer materializa registros de otros
dispositivos por fases y los objetos borrados pueden seguir referenciados por
vistas vivas. El force-bridge de nil revienta:

1. UUID._unconditionallyBridgeFromObjectiveC en ForEach (keypath Identifiable.id)
2. Date._unconditionallyBridgeFromObjectiveC en ChartsViewModel.monthlyTotals

Fix sistémico (no por call-site): NSManagedObject.safeValue(forKey:fallback:healing:)
lee el primitive y cae a un valor seguro; id se auto-cura (UUID nuevo escrito al
primitive sin ensuciar el objeto). Aplicado a:
- Snapshot: id (heal), date, createdAt (fallback distantPast)
- InvestmentSource / Goal / Account: id (heal), name (fallback "")
- Category: id (heal), name, colorHex, icon (fallbacks neutros)

Los accessors son @objc → NSSortDescriptor(keyPath:) y KVC siguen funcionando.
awakeFromInsert/awakeFromFetch se mantienen. Smoke test UITest en verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 13:28:59 +02:00
alexandrev-tibco 4c0d2af564 Calm 2.0 Fase 4: TipKit para descubrimiento + aha post-onboarding (build 55)
TipKit (Tips.configure con frecuencia diaria):
- QuickUpdateTip: popover en el '+' del Home — descubre Quick Update
- ScreenshotOCRTip: inline en Quick Update — descubre el OCR por captura
- SiriTip: en el cierre del check-in guiado — descubre el intent de voz
- ChartShareTip: popover en el compartir de Charts
- Se invalidan al ejecutar la acción; máx 1-2 impresiones cada uno
- Strings ×7 idiomas

Onboarding:
- Al completar el onboarding sin datos, se abre Add Source directamente
  (notificación .openAddFirstSource) — el usuario nuevo no aterriza en un
  dashboard vacío

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 09:31:03 +02:00
alexandrev-tibco 08094483ed What's New versionado y acumulativo (build 54)
El modal estaba congelado en las features de 1.4.0 (hardcodeadas en la vista) y
el título decía 'What's New in 1.4' fijo. Rediseñado como catálogo por versión:

- WhatsNewCatalog: una entrada por release (1.4.0/1.4.1/1.4.2/1.5.0) con sus
  features; AÑADIR ENTRADA CON CADA RELEASE (comentario en el código).
- Diff ACUMULATIVO desde lastSeenWhatsNewVersion: saltar 1.3.x → 1.4.2 muestra
  1.4.0 + 1.4.1 + 1.4.2 agrupadas con badge de versión (headers solo si hay
  más de una release pendiente). Comparación numérica de versiones (1.10 > 1.9).
- lastSeen vacío + onboarding ya completado (usuarios pre-1.4.0) = mostrar todo
  el catálogo; instalación nueva = lastSeen sella a la versión actual al
  completar onboarding (un usuario recién onboardeado no ve 'novedades').
- Sin entrada en el catálogo para el salto → no se muestra sheet.
- Título dinámico (whats_new_title_format) y contenido scrollable.
- Strings de 4 releases + título ×7 idiomas.

Verificado en simulador con -lastSeenWhatsNewVersion 1.4.0: pending
[1.5.0, 1.4.2, 1.4.1] y sheet agrupada correcta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-10 08:06:49 +02:00
alexandrev-tibco ed737bc290 Calm 2.0 Fase 3: Home iPad columna única, Goals con anillos, Journal timeline, Sources polish (build 53)
Home iPad/Mac (feedback del usuario: 'me gusta más el iPhone'):
- Fuera el grid de 2 columnas (acoplaba alturas de fila y dejaba huecos entre
  cards de tamaños distintos) → columna única centrada a 720pt, patrón
  Things/Day One. Eliminado el drag-drop del grid, columnSpan y el overlay
  full-screen (~120 líneas); el orden/visibilidad sigue en Customize.

Goals:
- ProgressRing nuevo (anillo estilo Fitness con porcentaje centrado y color
  semántico: verde=logrado, ámbar=retrasado, acento=en camino) en GoalRowView
  y en la card de Goals del Home. GoalProgressBar queda solo en la share card.

Journal:
- Momentum & Streaks vive ahora en la cabecera del Journal (la historia del
  hábito es narrativa, no dashboard).
- Filas timeline: círculo de mood como ancla visual (tint semántico por mood),
  mes + estrellas + preview de nota.

Sources:
- Badge 'Needs update' como chip ámbar visible; valor en tipografía rounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-09 11:11:04 +02:00
alexandrev-tibco 7044cfd441 Calm 2.0 Fase 2: check-in guiado full-screen (build 52)
El ritual mensual como flujo paso a paso en vez de formulario:
- Una fuente por pantalla: valor anterior a la vista, entrada grande (40pt
  rounded), delta en vivo (chip verde/rojo con importe y %), sugerencia de
  portapapeles, 'Igual que el mes pasado' y 'Omitir'
- Barra de progreso del ritual; cancelable solo al inicio (interactiveDismiss
  bloqueado a mitad para no perder valores)
- Reflexión: mood (5 estados existentes) + nota de una línea
- Cierre: total nuevo, delta del mes, streak, compartir y haptic de éxito
- Persistencia por los MISMOS caminos que Quick Update (Snapshots) y el journal
  (MonthlyCheckInStore → JournalEntry): coherente con el resto de entradas
- Entrada: CTA del check-in card en Home (fullScreenCover); la fila de
  completado sigue navegando al journal
- Fix: referenceDate congelada en @State al presentar — el card la recalcula al
  guardar y SwiftUI re-evaluaba el contenido del cover cambiando el mes a mitad
  de flujo
- --pending-checkin en ScreenshotMode (revierte el seed a 'mes pendiente') +
  UITest testGuidedCheckInFlow end-to-end (valor → skips → mood → cierre) ✓
- Strings ×7 idiomas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-09 10:51:11 +02:00
alexandrev-tibco be7119f4b9 Calm 2.0 Fase 1: sistema de diseño limpio + Home en 3 zonas (1.5.0 build 51)
Sistema:
- AppBackground: fuera gradiente + formas decorativas → systemGroupedBackground
  plano en toda la app. La profundidad la da la jerarquía, no la decoración.

Home (3 zonas: estado / acción / contexto):
- TotalValueCard: hero tipográfico — número 44pt rounded sobre card limpia,
  chip de delta semántico (verde/rojo), sparkline de evolución 56pt estilo
  Stocks, fila secundaria YoY / inception / forecast. Fuera el gradiente azul.
- MonthlyCheckInCard state-driven: pendiente → protagonista con progreso real
  de fuentes (N de M) y un CTA; completado → fila discreta con checkmark,
  próximo check-in y streak integrado. En iPad va a ancho completo bajo el
  hero (zona de acción), fuera del grid.
- InsightsRow: de carrusel de chips a un insight único rotatorio (tap para
  ciclar, dots de posición).
- Eliminados del top: streak badge (vive en el check-in) y banner de pending
  updates (el progreso del check-in lo comunica).
- Defaults nuevos: momentumStreaks/pendingUpdates/periodReturns/monthlySummary
  ocultos por defecto (siguen disponibles en Customize; configs guardadas de
  usuarios existentes se respetan).
- Strings nuevas en 7 idiomas (checkin_done_*, checkin_sources_progress).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 23:58:06 +02:00
alexandrev-tibco ff67ea1e71 Fix causa raíz de taps muertos en Charts iPad: AppBackground interceptaba toques (build 50)
El círculo decorativo de AppBackground (70% del ancho del panel, offset -35%)
se desborda de su panel por diseño y en layouts side-by-side queda flotando
SOBRE el sidebar de Charts. Las shapes de SwiftUI participan en hit-testing por
defecto y el panel derecho va después en el HStack → cada tap en la zona del
sidebar cubierta por el círculo moría en silencio. El patrón lo delató: fallaban
Overview + Analyze (arriba, bajo el círculo) y funcionaban Risk + Forecast
(abajo). Dependía de la geometría (orientación/tamaño), por eso no reproducía
en el simulador en portrait.

- AppBackground: .allowsHitTesting(false) — un fondo decorativo jamás debe
  interceptar toques (fix global: aplica también a Sources/Journal en iPad)
- Panel de detalle de Charts: .clipped() para que la decoración tampoco PINTE
  sobre el sidebar
- Selección premium nunca se bloquea: los charts premium se seleccionan y
  muestran teaser de desbloqueo en el área del chart (chart_locked_* ×7 idiomas);
  el paywall se presenta desde el botón (contexto fiable)
- UITests: testChartSidebarSelection ahora corre en landscape (geometría que
  reproducía el bug) + testChartSelectionWithoutPremium nuevo; --no-premium
  en ScreenshotMode para testear la experiencia free

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 23:30:30 +02:00
alexandrev-tibco 7b8dec196d Charts 2.0 (build 49): sidebar nativa, filtros en toolbar, title menu en iPhone
El bug de Allocation/Contributions que no respondían en iPad persistía tras dos
fixes de lógica — la causa estaba en la capa de interacción (Buttons custom en
LazyVGrid dentro de ScrollView dentro de NavigationSplitView). Rediseño completo
alineado con el HIG:

- iPad: List(selection:) nativa con estilo .sidebar y secciones agrupadas
  (Overview/Analyze/Risk/Forecast) — hit-testing del sistema, navegación por
  teclado, pointer effects y VoiceOver gratis. El gating premium sigue en
  selectChart vía el binding de selección.
- Filtros (Group/Category/Sources) → Menu nativo en la toolbar con checkmarks
  e icono con badge cuando hay filtros activos. Desaparecen las pills apiladas.
- iPhone: toolbarTitleMenu — el título es el selector de chart (patrón
  Files/Freeform); fuera el carrusel de 14 chips. Periodo como segmented picker
  estilo Stocks encima de la gráfica.
- Slider de Performance integrado en el toolbar del chart (iPad) y como card
  compacta (iPhone).
- Eliminadas ~300 líneas de selectores custom (tiles, pills, chips).

Verificado con UITest nuevo (testChartSidebarSelection) en simulador iPad Pro:
tap en Allocation/Contributions/Rolling 12M carga cada chart, y el filtro de
periodo de Rolling reacciona. ScreenshotMode ahora activa debugPremiumOverride
para poder testear charts premium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 22:45:31 +02:00
alexandrev-tibco 0e0aec6bb9 Charts: fix selección iPad/charts bloqueadas, filtro Rolling 12M, KPIs específicos y botón compartir
Bugs:
- Charts bloqueadas (intermitente) y tiles que no responden: el observer Combine
  registraba el estado como actualizado ANTES de llamar a updateChartData, cuyo
  guard de reentrada descartaba la llamada en silencio — reintentarlo era no-op
  permanente. Ahora el bookkeeping vive dentro de updateChartData, las llamadas
  reentrantes se colean en vez de descartarse, y selectChart computa en síncrono
  (pulsar un tile es determinista y re-pulsar actúa de retry).
- Paywall sheet: movida del Group que cambia con el size class a cada layout —
  en NavigationSplitView no llegaba a presentarse (tiles premium 'no hacían nada').
- Rolling 12M: el filtro de periodo no filtraba — el cálculo necesita histórico
  completo para el lookback, pero ahora la salida respeta selectedTimeRange.

KPIs:
- El header de iPad muestra KPIs específicos del chart activo (los mismos que su
  stats row) en vez de las 5 métricas de cartera fijas; las stats rows dentro de
  las cards se ocultan en regular width para no duplicar (YoY opta por quedarse
  al no tener equivalente en el header).

Compartir:
- Botón de compartir por chart (toolbar iPad + navbar iPhone): renderiza la
  gráfica en una card con branding (BrandMark, KPIs, tagline, QR al App Store
  con ct=chart_share) vía ImageRenderer y abre el share sheet con imagen + link.
- drawingGroup() se desactiva durante el export (ImageRenderer no rasteriza
  capas Metal — salían en blanco). Strings nuevas en 7 idiomas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 16:32:42 +02:00
alexandrev-tibco a830a5f40c Intent paramétrico Log Value + frases de Siri localizadas en 7 idiomas
LogSnapshotIntent: 'Registra el saldo de Indexa' por voz o desde Atajos, sin
abrir la app (openAppWhenRun=false). SourceEntity expone las fuentes a
Siri/Atajos desde el mirror del App Group; el valor entra por la misma cola
que el Share Extension y se materializa como Snapshot al instante (el intent
corre en el proceso de la app). Diálogo de confirmación con formato de divisa
de la fuente.

AppShortcuts.xcstrings: las 7 frases de los 3 shortcuts localizadas a
es-ES/de/fr/it/ja/pt-BR — Siri en español ya funciona. Strings de UI de
Atajos (títulos, parámetros, diálogos) añadidas a los 7 Localizable.strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 16:11:18 +02:00
alexandrev-tibco 19b1408c55 1.4.2 (build 48): OCR de screenshots en Share Extension
El extension acepta ahora imágenes además de texto: al compartir un screenshot
del banco/broker, Vision reconoce el texto EN EL DISPOSITIVO (la imagen nunca
sale del teléfono), extrae los importes candidatos y rellena el más prominente
visualmente (en las UIs bancarias el balance es el número más grande). El resto
de candidatos se ofrecen como chips de un toque. Percentajes y años se filtran.

- ExtImageAmountScanner.swift: VNRecognizeTextRequest + ranking por altura de
  bounding box + dedup por valor
- ShareViewController: carga UIImage/URL/Data del provider, estado de escaneo,
  sección de candidatos
- Info.plist: NSExtensionActivationSupportsImageWithMaxCount=1
- Strings nuevas en los 7 idiomas (ext_scanning_image, ext_detected_amounts,
  ext_ocr_no_amounts)
- PredictionEngine: troceado ternario que agotaba el type-checker en Debug
- Bump a 1.4.2 build 48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-08 15:59:54 +02:00
alexandrev-tibco a953af2da0 Firma manual de distribución en Release para app, widget y extension QuickUpdate
El archive fallaba en ValidateEmbeddedBinary con firma mixta (extension Manual
+ app/widget Automatic). Los tres targets usan ahora Apple Distribution con sus
perfiles App Store en Release; Debug sigue en Automatic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
2026-07-07 14:39:17 +02:00
alexandrev-tibco cef5924f16 1.4.1 (build 47): labels en charts, Share Extension Quick Update y clipboard auto-advance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-04 07:40:30 +02:00
alexandrev-tibco 386f4ff265 Actualización sin cambiar de app: Share Extension "Quick Update" + clipboard auto-advance
El dolor: para actualizar cada source hay que saltar a la app del banco, copiar/
memorizar el valor y volver. iOS no permite ventanas flotantes editables (PiP es
solo vídeo), así que se resuelve con las dos vías sancionadas:

Share Extension (target nuevo PortfolioJournalQuickUpdate):
- Al compartir texto desde CUALQUIER app aparece "Portfolio Journal": mini-form
  sobre la app anfitriona con el importe pre-parseado del texto compartido y la
  siguiente source pendiente del mes preseleccionada. Guardar → sigues en el banco.
- Sin Core Data en la extensión: la app publica un espejo de sources activas en el
  App Group (SharedQuickUpdateSync.refreshMirror) y la extensión encola
  PendingQuickUpdate; la app los convierte en Snapshots reales al activarse
  (ingestPending). ExtSharedBridge duplica el contrato (keep-in-sync comentado).
- Parser de importes tolerante a locales (12.345,67 € / $1,234.56 / 1234,5).
- UI localizada en los 7 idiomas (lproj propios de la extensión).
- pbxproj: target app-extension completo (sync group, embed, dependency, configs)
  replicando el patrón del widget. Entitlements solo con App Group.

Clipboard auto-advance en Quick Update:
- Al volver a la app con un importe copiado: banner de un tap "Pegar X en <source>"
  que rellena la siguiente source vacía y avanza el foco a la siguiente.
- Badge NEXT en la fila activa; no repite sugerencias del mismo clipboard.

Script de upload: -allowProvisioningUpdates + API key también en el archive para
que el bundle id nuevo de la extensión se registre automáticamente.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 23:21:55 +02:00
alexandrev-tibco f7d4fdbe2d Charts: puntos + labels de valor en todas las gráficas de líneas
- Nuevo ChartValueLabels.swift: ChartValueBubble (cápsula) y ChartSelectionCard
  (tarjeta multi-serie), con umbral compartido (<=12 puntos → labels SIEMPRE
  visibles; las gráficas cortas aportan mucho más con los valores a la vista)
- PointMark añadido donde faltaba (Compare, Year vs Year, Drawdown, Volatility);
  agrandado donde ya existía (Evolution, Prediction, Period vs Period, Rolling)
- Selección por tap/arrastre (chartXSelection) en las 8 gráficas: RuleMark + burbuja
  prominente (una serie) o tarjeta con el valor de cada serie (multi-serie)
- Evolution reutiliza su scrubbing existente y ahora muestra burbuja en el punto
- Period vs Period: labels A arriba / B abajo para evitar solapes
- Drawdown/Volatility migrados de Chart(data:) a Chart{ForEach} para poder añadir
  marcas de selección

Verificado en iPad Pro 13" (labels visibles en Evolution y Period vs Period).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 23:09:35 +02:00
alexandrev-tibco 7c92ea36c0 1.4.1 (build 46): fix cambio de Chart Type roto por el cache de snapshots
El fetch del cache dependía del timeRange activo (monthsLimit = timeRange.months),
así que al reutilizarlo entre chart types/rangos (optimización del build 45) los
charts recibían historia truncada — Year vs Year o "All" con solo 12 meses, charts
vacíos o incompletos al cambiar de tipo.

Fix: el cache siempre guarda la historia completa (maxHistoryMonths) y el recorte
por rango se hace en memoria (el filtro por cutoffDate ya existía justo después).
Bonus: hiddenHistoryMonths ahora se calcula contra la ventana completa (teaser más
preciso).

Verificado en iPad Pro 13" simulador: Evolution 12M → All → Compare →
Period vs Period → Evolution, todos renderizan con datos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 16:35:42 +02:00
alexandrev-tibco a0710a6f76 1.4.1 (build 45): rediseño Charts iPad, zoom, sin Focus Mode, perf fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 10:28:56 +02:00
alexandrev-tibco db844d5e80 Rediseño Charts iPad, zoom en gráficas, eliminación de Focus Mode y fixes de rendimiento
Charts iPad (rediseño):
- Fila de KPIs sobre el chart: Total Value, Period Return, CAGR, Volatility y Max
  Drawdown del rango activo (nuevo ChartsViewModel.portfolioMetrics, calculado sobre
  los TOTALES MENSUALES AGREGADOS — pasar snapshots multi-source crudos a
  calculateMetrics producía KPIs absurdos)
- Sidebar (248pt) con tiles de chart type en grid 2 col agrupados por sección:
  Overview / Analyze / Risk / Forecast, con candado premium y accesibilidad
- Selector de periodo como Picker segmentado nativo en la cabecera del chart
  (fuera del sidebar); título + descripción del chart visibles
- Eliminado sheet de paywall duplicado del layout iPad

Zoom (todas las series temporales):
- Nuevo ChartZoom.swift: pinch + botones +/- y reset (HIG: el gesto nunca es el
  único camino), chartScrollableAxes + chartXVisibleDomain al hacer zoom
- Integrado en Evolution, Contributions, Rolling 12M, Cashflow, Drawdown y Volatility

Focus Mode eliminado (todo siempre disponible):
- Fuera el toggle de Dashboard/Charts/Settings/Onboarding y los 6 @AppStorage
- Todos los chart types siempre visibles; variantes completas en SourceDetail/SourceList
- Home: Total Portfolio Value muestra SIEMPRE el cambio desde el último check-in
- PeriodReturnsCard siempre visible

Rendimiento y bugs (de la auditoría):
- El cache de snapshots ya no se invalida al cambiar solo de chart type/rango/breakdown
- calculateAnnualizedGrowth y el forecast a 12 meses ahora componen (la aproximación
  lineal sobreestimaba con historiales cortos)
- Drawdown sin force unwrap; simulador: rama muerta reemplazada por preservación
  real de las asignaciones simuladas del usuario
- UITest de captura de Charts con manejo del alert de notificaciones

12 strings nuevas localizadas en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-03 10:17:10 +02:00
alexandrev-tibco d0a51d0162 1.4.1 (build 44): growth y monetización
Incluye respecto a build 43:
- Fix crítico: QR viral y enlace de Settings apuntaban a ids inexistentes (404)
- Paywall multi-plan (sub anual con trial + lifetime), 6 benefits, teaser de historia
- Rating prompt acelerado, sample data por defecto, streak protection, App Intents
- Instrumentación de onboarding/shares/backups

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:55:49 +02:00
alexandrev-tibco 197bddcd7e Growth y monetización: fixes críticos, paywall multi-plan, teasers y App Intents
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0):
- GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las
  imágenes compartidas llevaba a una página muerta. Corregido a id6757678318.
- SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante.

Monetización:
- IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con
  intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos.
  Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime
  mientras el producto no esté creado en App Store Connect).
- paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores).
- Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de
  truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner).
- Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups").

Retención / adquisición:
- Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app
  mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings).
- Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío).
- Notificación de protección de streak el día 25 si el check-in del mes está pendiente.
- App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio"
  vía Siri/Shortcuts/Spotlight.
- Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop).
- ScreenshotMode (--screenshots) para capturas de marketing automatizadas.

ASO:
- 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando
  traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma).
- Categoría secundaria: PRODUCTIVITY.
- 17 strings nuevas localizadas en los 7 idiomas.

Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción +
intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:12:32 +02:00
alexandrev-tibco 19e80c912b ASO tooling: compositores de marketing + captura UITest
- testCaptureScreenshots en PortfolioJournalUITests: captura tabs con datos demo,
  idioma vía SCREENSHOT_LANG (usa el modo --screenshots del commit siguiente)
- Scripts/aso: compositores de screenshots de App Store (frame_layout con
  perspectiva 3D + panorama multi-panel, variantes premium/tilt), layouts JSON
  iPhone/iPad, y asc.py (helper API App Store Connect para estado/screenshots)
- Fix test roto pre-existente: OnboardingQuickStartView.appSettingsURL restaurado

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:12:31 +02:00
alexandrev-tibco 9d56d420aa ASO: optimizar name/subtitle/keywords/promo ×7 y ampliar description EN
- Name: "…: Tracker" → keyword localizada de alto volumen (Net Worth/Patrimonio/
  Vermögen/Patrimoine/資産管理…) — el nombre es la señal de ranking #1
- Keywords ×7: fuera términos flojos/off-brand (log, monitor, watchlist, finance);
  dentro alto valor on-brand (retirement, assets, passive, compound, boglehead…),
  sin repetir palabras del name/subtitle (Apple ya las combina)
- Subtitle ×7: reordenado para cubrir asset classes + tracker sin duplicar el name
- Promotional text ×7: hook de privacidad + net worth + FIRE + charts (≤170)
- Description EN: añadidos los charts interactivos (Compare/What-If/Period vs Period)
  y Year vs Year con forecast

Todo validado a los límites de ASC (30/30/100/170). Pendiente subir con fastlane deliver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-01 14:46:18 +02:00
alexandrev-tibco 6651e59e22 1.4.1 (build 43): sync iCloud de monthly contributions y allocation targets
Incluye respecto a build 42:
- Migración de MonthlyContributionStore y AllocationTargetStore a CoreData (sync iCloud)
- Versionado por variables en Info.plist, fix bug .p8 del script de upload, .gitignore

NOTA: los campos CloudKit CD_monthlyContribution / CD_allocationTarget requieren deploy
del schema a producción para sincronizar en TestFlight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-01 09:53:33 +02:00
alexandrev-tibco 9927d21b86 Sync monthly contributions y allocation targets vía iCloud (CoreData)
Ambos vivían en UserDefaults (device-local, no sincroniza). Migrados a atributos
CoreData en entidades ya syncable=YES, así que ahora sincronizan por CloudKit:

- InvestmentSource.monthlyContribution (Decimal, opcional)
- Category.allocationTarget (Double, opcional)

Los stores mantienen su API por UUID (0 cambios en call sites) pero ahora leen/escriben
del atributo CoreData vía fetch por id. migrateIfNeeded(context:) mueve una sola vez los
valores del diccionario UserDefaults legacy al modelo y borra la clave; enganchado en
CoreDataStack.loadPersistentStores junto a MonthlyCheckInStore.

DashboardLayoutStore se mantiene en UserDefaults a propósito (preferencia de UI por
dispositivo: el columnSpan solo aplica en iPad).

NOTA: requiere desplegar el schema CloudKit a producción (CD_monthlyContribution,
CD_allocationTarget) antes de que sincronice en TestFlight/App Store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-01 09:28:37 +02:00
alexandrev-tibco 51223f7079 chore: versionado por variables, fix bug .p8 en script de upload y .gitignore
- Info.plist (app + widget): CFBundleShortVersionString -> $(MARKETING_VERSION),
  CFBundleVersion -> $(CURRENT_PROJECT_VERSION). Antes estaban hardcodeados, lo que
  obligaba a editar 3 sitios por release y causó el rebote de build (subía 41).
- archive_and_upload_appstore.sh: find_p8_file ahora asigna la variable global P8_PATH
  en vez de devolverla por $(...); el trap EXIT que limpia el .p8 temporal ya no corre
  en un subshell, así que el fichero sobrevive hasta el upload (antes xcodebuild fallaba
  en -exportArchive con "-authenticationKeyPath ... existing file").
- .gitignore nuevo: build artifacts, credenciales (.p8/.mobileprovision), basura macOS
  y documentos personales. Destrackeados .DS_Store y UserInterfaceState.xcuserstate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-06-30 17:10:59 +02:00
alexandrev-tibco 7a18dd8360 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
2026-06-30 17:01:50 +02:00
alexandrev-tibco 54dfd8a8e3 Show Compare and Period vs Period in calm mode (build 35)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:28:38 +02:00
alexandrev-tibco 12ab2d6009 Add 3 interactive chart types: Compare, What If, Period vs Period (build 34)
- Compare (free): multi-source overlay chart, Base 100 / Return % / Value modes,
  chip selector, reactive to time range filter
- What If (premium): allocation simulator with sliders per source, dual-line chart
  actual vs simulated indexed to 100, orange warning when weights ≠ 100%
- Period vs Period (free): date-picker-driven comparison of any two custom periods,
  return % normalized from month 1 = 0%, color-coded legend with period labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:32:57 +02:00
alexandrev-tibco 270edeb536 Migrate journal entries to CoreData for iCloud sync (build 33)
- Add JournalEntry CoreData entity (syncable=YES): id, monthKey, note, moodRaw, rating, completionTime, createdAt
- Rewrite MonthlyCheckInStore: CoreData as primary storage, same public API
- One-time migration from UserDefaults triggered after store loads
- MonthlyCheckInCard: @FetchRequest on JournalEntry — reactive to iCloud sync
- MonthlyCheckInView: onReceive NSManagedObjectContextObjectsDidChange to refresh @State vars on sync
- Stats cards: observe NSManagedObjectContextObjectsDidChange instead of UserDefaults
- ChartsViewModel.completedMonthKeys: remove MonthlyCheckInStore dependency (data-driven)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:05:53 +02:00
alexandrev-tibco 0b01d6f563 Fix iCloud sync: chart and next check-in date stale on secondary device (build 32)
- completedMonthKeys: remove UserDefaults-based MonthlyCheckInStore check (not synced via iCloud)
- MonthlyCheckInCard.effectiveLastCheckInDate: use max() instead of ?? to prefer newer synced snapshot date
- MonthlyCheckInView.lastCompletionDate: use max() of local + latest CoreData snapshot via @FetchRequest
- FirebaseService.logPaywallShown: fix parameter key to paywall_trigger (matches GA4 custom dimension)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:42:22 +02:00
alexandrev-tibco b48a47ce10 Release 1.4.0 (build 31): retención, quick update, streak, what's new
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes)
- 1B: Badge de racha mensual en Dashboard (streak counter)
- 1C: Empty states mejorados en Goals y Journal con CTAs claros
- 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla
- 2B: Notificación batch update redirige al Quick Update sheet
- 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update
- 3A: App Store version check banner en Settings
- 3C: What's New sheet en primer launch de versión nueva
- Localización completa en 7 idiomas para todas las nuevas strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:09:36 +02:00
alexandrev-tibco 773da6800b Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync:
- Fix: deploy CloudKit schema to production so exports work
- Fix: PredictionCache marked syncable=NO (binary attr caused partial failures)
- Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history
- Fix: auto-deduplication on CloudKit import (processRemoteChanges)
- Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state
- Add: cloudKitForceReload notification so repositories re-fetch on remote changes

Sources UI:
- Add: horizontal category filter chips in SourceListView
- Add: search toggle button with animated TextField

Other:
- Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance)
- Add: fastlane submit lane with run_precheck_before_submit=false
- Update: release notes all locales for 1.3.1
- Update: fastlane API key via pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:19:33 +02:00
alexandrev-tibco 8acac3d529 Release 1.3.0: fastlane manual signing + release notes all locales
- Fastfile: manual signing with PortfolioJournal AppStore profiles
- Fastfile: add metadata, beta and release lanes
- Deliverfile: set app_version 1.3.0
- Release notes updated in 7 locales (en-US, es-ES, de-DE, fr-FR, it, ja, pt-BR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:20:22 +02:00
alexandrev-tibco a9be27fa3a Disable script sandboxing for PortfolioJournal target (needed for Crashlytics dSYM upload) 2026-05-01 09:45:22 +02:00
alexandrev-tibco b67bcc71b8 Fix Crashlytics dSYM script: correct INFOPLIST_PATH input file 2026-05-01 09:36:52 +02:00
alexandrev-tibco 94ed4d17eb Release 1.3.0: onboarding UX, charts paywall banner, re-engagement notifications, Crashlytics
- Crashlytics: add FirebaseCrashlytics framework + dSYM upload build phase
- Onboarding: useSampleData off by default, Add First Investment as primary CTA
- AddSourceView: contextual placeholder and footer explaining what a source is
- Charts: CompactPaywallBanner visible to free users on every visit
- Notifications: re-engagement (7d) and monthly check-in local notifications
- Paywall: decorative chart preview replacing static crown icon
- Localization: all new strings in en + es-ES
2026-05-01 09:26:37 +02:00
alexandrev-tibco 10f6d0ca20 Release 1.2.1: iCloud sync improvements + ASO multilingual metadata
iCloud sync:
- Force viewContext.refreshAllObjects() on remote change notifications so
  data from other devices is picked up immediately without app restart
- Call refreshFromCloudKit() on foreground to merge any changes made while
  the device was inactive
- Wait up to 10s for initial CloudKit sync on launch before showing onboarding
  (shows "Checking iCloud..." during the wait)
- New OnboardingICloudCheckView: shown on fresh installs with iCloud available,
  lets user restore from iCloud before starting onboarding from scratch

Localization:
- Added de, fr, it, ja, pt-BR lproj folders
- New iCloud onboarding strings in en + es-ES (+ button literals)

ASO metadata (fastlane):
- Updated en-US: new subtitle, keywords, description (fixed "no cloud sync"
  claim), promotional text, release notes
- Added full metadata for es-ES, de-DE, fr-FR, it, ja, pt-BR (63 files total)
- All keyword fields validated ≤100 Unicode chars

Infrastructure:
- Gemfile + Gemfile.lock for fastlane
- Scripts/archive_and_upload_appstore.sh for CI/CD

Version: 1.2.1 (build 7)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:54:35 +01:00
alexandrev-tibco 7cb5f92cf4 Remove Add Transaction feature and clean all related code
Deleted files:
- AddTransactionView.swift
- TransactionRepository.swift
- Transaction+CoreDataClass.swift

Cleaned files:
- SourceDetailViewModel: removed transactions published property,
  showingAddTransaction flag, transactionRepository dependency,
  addTransaction() and deleteTransaction() methods
- SourceDetailView: removed transactionsSection, TransactionRow struct,
  Add Transaction button from quickActions, sheet presentation
- InvestmentSource+CoreDataClass: removed transactions NSManaged property,
  transactionsArray, totalInvested, totalDividends, totalFees computed
  properties, and all transaction Core Data accessors
- SettingsViewModel: removed "Transaction" from resetAllData entity list
- SampleDataService: removed transactionRepository and sample transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 23:04:03 +01:00
alexandrev-tibco b17d8866a4 Fix charts cutting off at Dec 2025: remove overly strict post-completion filter
The filter excluded snapshots with snapshot.date > completionDate for that month.
The bug: when a check-in for January is marked complete in February, the stored
completion date is backdated to Jan 31 (min(endOfMonth, now)), but batch update
snapshots were dated today (Feb 20). effectiveMonth maps Feb 20 → January, but
Feb 20 > Jan 31 triggered the exclusion, hiding all 2026 data from charts.

The check was redundant — monthDate <= lastCompleted already ensures only
completed months are included. Removing it also correctly handles the existing
snapshots already saved with a late date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:56:30 +01:00
alexandrev-tibco c94974546f Fix widget gap: forward-fill months and use reference month date for batch update
BatchUpdateView now accepts a saveDate parameter. When saving from a past-month
check-in, the call site passes referenceDate.endOfMonth so snapshots are dated
within the correct month instead of today. For the current month, Date() is used.

Widget forward-fills missing month buckets (trend and category series) using the
most recent earlier month's value, preventing gaps when a check-in is done in the
following month.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:51:40 +01:00
alexandrev-tibco e3ec0ddb25 Fix progress bar period to start at first day of interval month
Period now starts at startOfMonth of the first month in the interval,
not 30 days before the deadline. For monthly: Feb 1 → Feb 28.
For quarterly: Jan 1 → Mar 31. Formula: startOfMonth(nextDate - (interval-1) months).

Feb 20 with next check-in Feb 28: 19/27 days ≈ 70% instead of 0%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:45:59 +01:00
alexandrev-tibco b6314db314 Fix check-in progress bar always showing empty after completing check-in
Previous logic measured elapsed days from lastCompletionDate to nextCheckInDate,
so completing a check-in today reset the bar to 0/8 days instead of showing
how far through the full monthly period we are.

New logic: the bar represents the full interval (e.g. 1 month) ending at
nextCheckInDate, starting from nextCheckInDate minus that interval.
Today (Feb 20) with next check-in Feb 28 now shows ~74% progress
(23 of 31 days elapsed since Jan 28) instead of 0%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:44:22 +01:00
alexandrev-tibco 472342fa67 Ensure full Spanish localization for all 1.2.0 strings
- OnboardingView: wrap page titles/descriptions with String(localized:)
  using symbolic keys so SwiftUI can look them up correctly
- IAPService.paywallBenefits: change from static let to static var,
  wrap all strings with String(localized:) using symbolic keys
- en.lproj: add symbolic keys for new onboarding pages and paywall benefits
- es-ES.lproj: add Spanish translations for all new keys (onboarding pages,
  paywall benefits, batch update, contribution fields) plus pre-existing
  gaps (Monthly Highlights, Best/Worst Performer, Best Contributor)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:41:40 +01:00
alexandrev-tibco 015e718b39 Add Spanish translations for 1.2.0 new strings
Covers paywall redesign (new copy), batch update view,
and contribution fields added in this version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:35:00 +01:00
alexandrev-tibco 28b662e5a6 Restrict contribution fields to detailed mode only
AddSnapshotView: restore inputMode == .detailed guard for contribution section.
BatchUpdateView: show contribution field per row only when the source's
account is configured with inputMode == .detailed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:33:02 +01:00
alexandrev-tibco ce4bbd9676 Fix decimal parsing bug + contribution and pre-fill features
BUG FIX: parseDecimal ignored locale mismatch between currency and device.
CurrencyFormatter.locale(for: "EUR") could return any EUR locale (e.g.
en_IE with decimal='.'), causing "408857,62" to be parsed as 40,885,762.
New CurrencyFormatter.parseUserInput() uses digit-position detection:
a separator followed by ≤2 digits at the end is decimal, otherwise
it is a thousands separator. Fully locale-independent.

FEATURE: Contribution field now always visible in AddSnapshotView.
Previously gated behind inputMode == .detailed; now a toggle available
regardless of account mode.

FEATURE: BatchUpdateView pre-fills each text field with the source's
current value so the user only edits what changed. Adds an optional
contribution field per source row, persisted on save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:29:26 +01:00
alexandrev-tibco bc159507c8 Fix CFBundleShortVersionString to use MARKETING_VERSION variable
Replaces hardcoded '1.1.0' with $(MARKETING_VERSION) so the version
is always in sync with the project.pbxproj setting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 22:22:29 +01:00
alexandrev-tibco 02ddad9e26 Bump version to 1.2.0 (build 5)
Aligns branch version with its name. Build 5 follows 1.1.1 (build 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 12:52:46 +01:00
alexandrev-tibco edb04efc86 Redesign onboarding and paywall for 1.2.0
- Onboarding: replace feature-focused slides with outcome-focused copy
  ("Know exactly where you stand", "5 minutes a month is enough", etc.)
- Paywall: non-scrollable layout, reduce from 8 to 4 key benefits,
  remove transactional language (Unlock/Upgrade/Premium),
  new CTA "Get Full Access", floating close button
- Add IAPService.paywallBenefits for the condensed 4-item paywall list
- Update CompactPaywallBanner and PremiumLockOverlay copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 12:51:13 +01:00
alexandrev-tibco ace58e5b0f Fix compilation errors in ChartsViewModel and AllocationEvolutionChart
- Remove unnecessary optional binding for non-optional source.id
- Break up AllocationEvolutionChart body to help Swift type-checker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:04:52 +01:00
alexandrev-tibco 151eb0e662 Redesign achievements progress bar with milestone circles
Replaces the simple progress bar with a custom milestone bar showing
individual circles for each achievement, filled when unlocked with a
checkmark indicator.

Fixes #27

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:57:58 +01:00
alexandrev-tibco b4615ac558 Add setting to toggle forecast visibility in charts and dashboard
Adds a "Show Forecast" toggle in Settings > Long-Term Focus section.
When disabled, hides the forecast from the total portfolio card and
removes the prediction chart type from the charts tab.

Fixes #26

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:56:48 +01:00
alexandrev-tibco c99398c350 Add allocation evolution chart showing allocation changes over time
Adds a stacked area chart under the Allocation tab that displays how the
portfolio allocation percentages have changed across months.

Fixes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:54:43 +01:00
alexandrev-tibco 0dac19b109 Allow viewing evolution chart for individual or multiple sources across categories
Adds multi-source selection to the evolution chart source filter.
Users can now tap multiple sources to compare their evolution side by side,
regardless of which category they belong to.

Fixes #24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:52:38 +01:00
alexandrev-tibco 9d2ed68dcc Require PIN to disable Face ID
When toggling Face ID off, users must now verify their PIN first.
Adds a PinVerifyView component for PIN verification.

Fixes #23

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:50:44 +01:00
alexandrev-tibco 5dc9eb109f Add monthly highlights: best and worst performing sources
Shows a highlights card with the best and worst performing sources by
percentage change, displayed between the summary and reflection cards.

Fixes #22

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:49:00 +01:00
alexandrev-tibco aceed608ed Show source value difference vs previous check-in (green/red coloring)
When a source has been updated this cycle, shows the value change from the
previous snapshot in green (increase) or red (decrease).

Fixes #21

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:49 +01:00
alexandrev-tibco 7d1eb874d8 Add prev/next navigation buttons to Monthly Check-in view
Adds chevron buttons to navigate between months, similar to Lose It Log view.
The next month button is disabled when already at the current month.

Fixes #20

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:46:54 +01:00
alexandrev-tibco 8fa66c1c70 Add dismissible pending updates alert banner at top of Dashboard
Shows a warning-colored banner when sources need updating, with an X to dismiss.
The existing Pending Updates section lower in the dashboard remains unchanged.

Fixes #18

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:45:16 +01:00
alexandrev-tibco d71e98c60c Start dialog: bigger centered title, share icon with more left padding
Replaces confirmationDialog with a custom sheet for better title styling.
Increases share icon trailing padding in TotalValueCard.

Fixes #17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:44:17 +01:00
alexandrev-tibco 14af7deeda Show month name instead of "Monthly Check-in" as card title
Fixes #16

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:43:23 +01:00
alexandrev-tibco 5ceeb93d91 Make Start button bigger and position at the bottom of check-in card
Fixes #15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:42:40 +01:00
alexandrev-tibco 3a72a75e5c Progress bar: red when overdue, orange when 2-3 days from deadline
Fixes #13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:41:20 +01:00
alexandrev-tibco 4761e2e5c8 1.1.0 feature work: Monthly Check-in, Charts, Goals, Share, Reviews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:37:11 +01:00
alexandrev-tibco daaca95913 Fix test target dependencies and version settings 2026-02-01 16:09:32 +01:00
alexandrev-tibco 2437dd647f Add tests for 1.1.0 features 2026-02-01 14:40:10 +01:00
alexandrev-tibco d55b999bef Bump version to 1.1.0 2026-02-01 11:36:56 +01:00
alexandrev-tibco c9dab29612 Improve onboarding iCloud linking 2026-02-01 11:35:50 +01:00
alexandrev-tibco 0ddee49dd5 Tune app review prompt cadence 2026-02-01 11:35:36 +01:00
alexandrev-tibco edfd7a2a56 Add monthly check-in sharing 2026-02-01 11:32:20 +01:00
alexandrev-tibco b5ba6c47a8 Add premium backups with retention and iCloud support 2026-02-01 11:23:41 +01:00
alexandrev-tibco f97f8026bc Improve goal sharing experience 2026-02-01 11:14:48 +01:00
alexandrev-tibco e328767c4a Base fixes and test harness 2026-02-01 11:12:57 +01:00
320 changed files with 34259 additions and 2906 deletions
Vendored
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
# macOS
.DS_Store
# Xcode / build artifacts
build/
DerivedData/
*.xcarchive
*.ipa
*.dSYM
*.dSYM.zip
*.xcuserstate
xcuserdata/
# Signing / credentials (no versionar)
*.mobileprovision
*.p8
*.p12
*.cer
ExportOptions.plist
# fastlane generated
fastlane/report.xml
fastlane/README.md
fastlane/Preview.html
fastlane/test_output/
# Documentos personales / binarios que no son del proyecto
Justificante_*.pdf
*.pkg
og-image.png
# ASO tooling artifacts
Scripts/aso/.venv/
Scripts/aso/_*.png
Scripts/aso/pano_*/
Scripts/aso/originals/
__pycache__/
+20
View File
@@ -0,0 +1,20 @@
# Changelog
All notable changes to Portfolio Journal will be documented in this file.
## [Unreleased]
### Fixed
- **Snapshot View**: Currency and number formatting now respects device locale settings with fallback for mixed locale input
- **Goal Share Button**: Share button in Goals view now works correctly (was being intercepted by row tap gesture)
- **Widget Currency**: Widget now displays correct currency symbol from app settings instead of defaulting to EUR
- **Goal Editor**: Currency symbol prefix now displays correctly and number parsing is locale-aware
### Enhanced
- **Goal Sharing**:
- Added privacy mode option to hide current value when sharing
- Share card now displays app icon (BrandMark)
- Share card now shows target date when set
- Share card now shows estimated completion date when available
- Fallback text sharing includes App Store link
- Dynamic card height based on content
+99
View File
@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Portfolio Journal is a native iOS investment portfolio tracker built with Swift and SwiftUI. It helps users track investments, monitor performance with charts and predictions, set financial goals, and maintain an investment journal.
**Target:** iOS 17.6+ (widget supports iOS 16.0+)
## Build Commands
```bash
# Open project in Xcode
open PortfolioJournal.xcodeproj
# Build for Debug
xcodebuild -scheme PortfolioJournal -configuration Debug build
# Build for Release
xcodebuild -scheme PortfolioJournal -configuration Release build
# Run on simulator
xcodebuild -scheme PortfolioJournal -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 15' build
# Clean build
xcodebuild -scheme PortfolioJournal clean
```
## Architecture
The app uses Clean Architecture with MVVM pattern:
```
PortfolioJournal/
├── App/ # Entry point: PortfolioJournalApp.swift, AppDelegate, ContentView
├── Models/CoreData/ # Core Data entities and CoreDataStack singleton
├── Repositories/ # Data access layer with @MainActor CRUD operations
├── Services/ # Business logic (CalculationService, PredictionEngine, IAPService, etc.)
├── ViewModels/ # MVVM view models for each feature
├── Views/ # SwiftUI views organized by feature
│ ├── Dashboard/ # Main dashboard with evolution charts
│ ├── Charts/ # Financial visualizations (allocation, performance, drawdown)
│ ├── Sources/ # Investment source management
│ ├── Goals/ # Goal tracking and progress
│ ├── Journal/ # Journal entries
│ ├── Settings/ # App settings and import/export
│ ├── Security/ # Face ID/PIN lock (AppLockView)
│ └── Components/ # Shared UI components
├── Utilities/ # Helpers: KeychainService, FreemiumValidator, formatters, extensions
└── Resources/ # Info.plist, GoogleService-Info.plist, assets
PortfolioJournalWidget/ # iOS Home Screen Widget (WidgetKit)
```
### Core Data Model
Key entities: `Account`, `InvestmentSource`, `Snapshot`, `Category`, `Goal`, `Asset`, `Transaction`, `AppSettings`, `PremiumStatus`
Data flows through `CoreDataStack` singleton which manages CloudKit sync and AppGroup shared container for widget access.
### Key Services
- **CalculationService**: Portfolio metrics, returns calculation, allocation analysis
- **PredictionEngine**: Investment forecasting algorithms with caching
- **IAPService**: StoreKit 2 in-app purchases
- **AdMobService**: Google Mobile Ads integration
- **ImportService/ExportService**: CSV data import/export
- **AppLockService**: Biometric/PIN security via Keychain
## Dependencies
Managed via Swift Package Manager:
- Firebase iOS SDK (v12.7.0+) - Analytics
- Google Mobile Ads SDK
Native frameworks: SwiftUI, Combine, CoreData, CloudKit, WidgetKit, StoreKit 2, LocalAuthentication
## App Initialization Flow
```
PortfolioJournalApp (@main)
└── AppDelegate (Firebase, AdMob, Notifications init)
└── ContentView
├── OnboardingView (first launch)
├── AppLockView (if security enabled)
└── TabBar: Dashboard | Sources | Goals | Journal | Settings
```
## Localization
Supported languages: English (`en.lproj`), Spanish (`es-ES.lproj`)
## Development Notes
- Use `SampleDataService` to generate demo data for testing
- Premium features are gated via `FreemiumValidator`
- Widget shares data through AppGroup container
- Sensitive data stored in Keychain via `KeychainService`
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>2825Q76T7H</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>automatic</string>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+338
View File
@@ -0,0 +1,338 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1229.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.97.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.2)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-25
ruby
DEPENDENCIES
fastlane
CHECKSUMS
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1229.0) sha256=4cdba3093cc518e1ffe9f0f35050953cb5fb4a79797e4c928ab1cd3ed369407b
aws-sdk-core (3.244.0) sha256=3e458c078b0c5bdee95bc370c3a483374b3224cf730c1f9f0faf849a5d9a18ea
aws-sdk-kms (1.123.0) sha256=d405f37e82f8fa32045ca8980be266c0b45b37aaf2012afe0254321a1e811f20
aws-sdk-s3 (1.217.0) sha256=6ea709272c666888b14e9c62345abd9a6a967759ae13667c28f01fde6823c24b
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0
faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
fastlane (2.232.2) sha256=978689f60f0fc3d54699de86ef12be4eda9f5b52217c1798965257c390d2b112
fastlane-sirp (1.0.0) sha256=66478f25bcd039ec02ccf65625373fca29646fa73d655eb533c915f106c5e641
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
google-apis-androidpublisher_v3 (0.97.0) sha256=0f3859844872ec09b64dde3bff6dee84458eb61d664337402adcbb4ac912322a
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-iamcredentials_v1 (0.26.0) sha256=3ff70a10a1d6cddf2554e95b7c5df2c26afdeaeb64100048a355194da19e48a3
google-apis-playcustomapp_v1 (0.17.0) sha256=d5bc90b705f3f862bab4998086449b0abe704ee1685a84821daa90ca7fa95a78
google-apis-storage_v1 (0.61.0) sha256=b330e599b58e6a01533c189525398d6dbdbaf101ffb0c60145940b57e1c982e8
google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf
google-cloud-env (2.1.1) sha256=cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999
google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1
google-cloud-storage (1.58.0) sha256=1bedc07a9c75af169e1ede1dd306b9f941f9ffa9e7095d0364c0803c468fdffd
googleauth (1.11.2) sha256=7e6bacaeed7aea3dd66dcea985266839816af6633e9f5983c3c2e0e40a44731e
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
json (2.19.2) sha256=e7e1bd318b2c37c4ceee2444841c86539bc462e81f40d134cf97826cb14e83cf
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
multi_json (1.19.1) sha256=7aefeff8f2c854bf739931a238e4aea64592845e0c0395c8a7d2eea7fdd631b7
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
nkf (0.2.0) sha256=fbc151bda025451f627fafdfcb3f4f13d0b22ae11f58c6d3a2939c76c5f5f126
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
signet (0.21.0) sha256=d617e9fbf24928280d39dcfefba9a0372d1c38187ffffd0a9283957a10a8cd5b
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
sysrandom (1.0.5) sha256=5ac1ac3c2ec64ef76ac91018059f541b7e8f437fbda1ccddb4f2c56a9ccf1e75
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
xcodeproj (1.27.0) sha256=8cc7a73b4505c227deab044dce118ede787041c702bc47636856a2e566f854d3
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
BUNDLED WITH
4.0.8
+135
View File
@@ -0,0 +1,135 @@
# Portfolio Journal Makefile
# Usage: make [target]
#
# Available targets:
# test - Run all unit tests
# test-unit - Run unit tests only
# test-ui - Run UI tests only
# test-coverage - Run tests with code coverage
# build - Build the app for debug
# build-release - Build the app for release
# clean - Clean build artifacts
# setup-tests - Set up test targets in Xcode project
# help - Show this help message
.PHONY: test test-unit test-ui test-coverage build build-release clean setup-tests help
# Configuration
PROJECT = PortfolioJournal.xcodeproj
SCHEME = PortfolioJournal
DEVICE = iPhone 17
DESTINATION = platform=iOS Simulator,name=$(DEVICE)
# Default target
.DEFAULT_GOAL := help
# Help
help:
@echo "Portfolio Journal - Build & Test Commands"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Test targets:"
@echo " test Run all unit tests"
@echo " test-unit Run unit tests only"
@echo " test-ui Run UI tests only"
@echo " test-coverage Run tests with code coverage"
@echo " test-quick Run tests without pretty output (faster)"
@echo ""
@echo "Build targets:"
@echo " build Build the app for debug"
@echo " build-release Build the app for release"
@echo " clean Clean build artifacts"
@echo ""
@echo "Setup targets:"
@echo " setup-tests Set up test targets in Xcode project"
@echo " install-tools Install required development tools"
@echo ""
@echo "Examples:"
@echo " make test # Run all tests"
@echo " make test DEVICE='iPhone 16' # Run tests on specific device"
# Run all tests
test:
@echo "Running all tests on $(DEVICE)..."
@./Scripts/run_tests.sh --unit --device "$(DEVICE)"
# Run unit tests only
test-unit:
@echo "Running unit tests on $(DEVICE)..."
@./Scripts/run_tests.sh --unit --device "$(DEVICE)"
# Run UI tests only
test-ui:
@echo "Running UI tests on $(DEVICE)..."
@./Scripts/run_tests.sh --ui --device "$(DEVICE)"
# Run tests with coverage
test-coverage:
@echo "Running tests with coverage on $(DEVICE)..."
@./Scripts/run_tests.sh --all --coverage --device "$(DEVICE)"
# Quick test run without xcpretty
test-quick:
@echo "Running quick tests on $(DEVICE)..."
xcodebuild test \
-project $(PROJECT) \
-scheme $(SCHEME) \
-destination "$(DESTINATION)" \
-only-testing:PortfolioJournalTests \
| grep -E "(Test Case|passed|failed|error:)" || true
# Build for debug
build:
@echo "Building for Debug..."
xcodebuild \
-project $(PROJECT) \
-scheme $(SCHEME) \
-configuration Debug \
-destination "$(DESTINATION)" \
build
# Build for release
build-release:
@echo "Building for Release..."
xcodebuild \
-project $(PROJECT) \
-scheme $(SCHEME) \
-configuration Release \
build
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
xcodebuild \
-project $(PROJECT) \
-scheme $(SCHEME) \
clean
rm -rf ~/Library/Developer/Xcode/DerivedData/PortfolioJournal-*
# Set up test targets
setup-tests:
@echo "Setting up test targets..."
@if command -v ruby >/dev/null 2>&1; then \
ruby Scripts/setup_tests.rb; \
else \
echo "Ruby not found. Please add test target manually in Xcode:"; \
echo "1. File > New > Target > iOS Unit Testing Bundle"; \
echo "2. Name it 'PortfolioJournalTests'"; \
echo "3. Add test files from PortfolioJournalTests folder"; \
fi
# Install development tools
install-tools:
@echo "Installing development tools..."
@if command -v gem >/dev/null 2>&1; then \
gem install xcpretty xcodeproj; \
else \
echo "RubyGems not found. Please install Ruby first."; \
fi
# Pre-release checks
pre-release: clean build-release test
@echo ""
@echo "✅ Pre-release checks passed!"
@echo "Ready to submit to App Store."
+445 -10
View File
@@ -14,6 +14,8 @@
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E53752C2F0FD08600F31390 /* FirebaseAnalytics */; };
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E53752E2F0FD09F00F31390 /* CoreData.framework */; };
0E5375312F0FD12E00F31390 /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375302F0FD12E00F31390 /* GoogleMobileAds */; };
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0E5375342F0FD14000F31390 /* FirebaseCrashlytics */; };
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -24,6 +26,27 @@
remoteGlobalIDString = 0E241ECB2F0DAA3C00283E2F;
remoteInfo = PortfolioJournalWidgetExtension;
};
0E481F2C2F2E958100CF94C5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0E241E382F0DA93A00283E2F;
remoteInfo = PortfolioJournal;
};
0E481F2D2F2E958100CF94C5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0E241E382F0DA93A00283E2F;
remoteInfo = PortfolioJournal;
};
0EQUPD112F40000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0E241E312F0DA93A00283E2F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0EQUPD022F40000000000001;
remoteInfo = PortfolioJournalQuickUpdate;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -34,6 +57,7 @@
dstSubfolderSpec = 13;
files = (
0E241EE22F0DAA3E00283E2F /* PortfolioJournalWidgetExtension.appex in Embed Foundation Extensions */,
0EQUPD102F40000000000001 /* PortfolioJournalQuickUpdate.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -47,6 +71,10 @@
0E241ED02F0DAA3C00283E2F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalWidgetExtension.entitlements; sourceTree = "<group>"; };
0E53752E2F0FD09F00F31390 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; };
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PortfolioJournalUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PortfolioJournalQuickUpdate.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PortfolioJournalQuickUpdateExtension.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -71,6 +99,13 @@
);
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
};
0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -91,6 +126,24 @@
path = PortfolioJournalWidget;
sourceTree = "<group>";
};
0ETEST0032F31000000000001 /* PortfolioJournalTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = PortfolioJournalTests;
sourceTree = "<group>";
};
0EUITEST032F31000000001 /* PortfolioJournalUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = PortfolioJournalUITests;
sourceTree = "<group>";
};
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
0EQUPD132F40000000000001 /* Exceptions for "PortfolioJournalQuickUpdate" folder in "PortfolioJournalQuickUpdate" target */,
);
path = PortfolioJournalQuickUpdate;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -102,6 +155,7 @@
0E53752D2F0FD08600F31390 /* FirebaseAnalytics in Frameworks */,
0E53752F2F0FD09F00F31390 /* CoreData.framework in Frameworks */,
0E53752B2F0FD08100F31390 /* FirebaseCore in Frameworks */,
0E5375352F0FD14000F31390 /* FirebaseCrashlytics in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -114,6 +168,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0ETEST0042F31000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EUITEST042F31000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EQUPD052F40000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -121,8 +196,12 @@
isa = PBXGroup;
children = (
0E241EED2F0DAC7D00283E2F /* PortfolioJournalWidgetExtension.entitlements */,
0EQUPD142F40000000000001 /* PortfolioJournalQuickUpdateExtension.entitlements */,
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
0E241ED22F0DAA3C00283E2F /* PortfolioJournalWidget */,
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
0E241ECD2F0DAA3C00283E2F /* Frameworks */,
0E241E3A2F0DA93A00283E2F /* Products */,
);
@@ -133,6 +212,9 @@
children = (
0E241E392F0DA93A00283E2F /* PortfolioJournal.app */,
0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */,
0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */,
0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */,
0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -158,11 +240,13 @@
0E241E362F0DA93A00283E2F /* Frameworks */,
0E241EE72F0DAA3E00283E2F /* Embed Foundation Extensions */,
0E8318932F0DB2FB0030C2F9 /* Resources */,
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */,
);
buildRules = (
);
dependencies = (
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */,
0EQUPD122F40000000000001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0E241E3B2F0DA93A00283E2F /* PortfolioJournal */,
@@ -172,6 +256,7 @@
0E53752A2F0FD08100F31390 /* FirebaseCore */,
0E53752C2F0FD08600F31390 /* FirebaseAnalytics */,
0E5375302F0FD12E00F31390 /* GoogleMobileAds */,
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */,
);
productName = PortfolioJournal;
productReference = 0E241E392F0DA93A00283E2F /* PortfolioJournal.app */;
@@ -199,6 +284,74 @@
productReference = 0E241ECC2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
0ETEST0012F31000000000001 /* PortfolioJournalTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0ETEST0072F31000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalTests" */;
buildPhases = (
0ETEST0052F31000000000001 /* Sources */,
0ETEST0042F31000000000001 /* Frameworks */,
0ETEST0062F31000000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
0ETEST0022F31000000000001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0ETEST0032F31000000000001 /* PortfolioJournalTests */,
);
name = PortfolioJournalTests;
packageProductDependencies = (
);
productName = PortfolioJournalTests;
productReference = 0ETEST0002F31000000000001 /* PortfolioJournalTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
0EUITEST012F31000000001 /* PortfolioJournalUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EUITEST072F31000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalUITests" */;
buildPhases = (
0EUITEST052F31000000001 /* Sources */,
0EUITEST042F31000000001 /* Frameworks */,
0EUITEST062F31000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
0EUITEST022F31000000001 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
0EUITEST032F31000000001 /* PortfolioJournalUITests */,
);
name = PortfolioJournalUITests;
packageProductDependencies = (
);
productName = PortfolioJournalUITests;
productReference = 0EUITEST002F31000000001 /* PortfolioJournalUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */;
buildPhases = (
0EQUPD042F40000000000001 /* Sources */,
0EQUPD052F40000000000001 /* Frameworks */,
0EQUPD062F40000000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
0EQUPD032F40000000000001 /* PortfolioJournalQuickUpdate */,
);
name = PortfolioJournalQuickUpdate;
packageProductDependencies = (
);
productName = PortfolioJournalQuickUpdate;
productReference = 0EQUPD012F40000000000001 /* PortfolioJournalQuickUpdate.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -215,6 +368,17 @@
0E241ECB2F0DAA3C00283E2F = {
CreatedOnToolsVersion = 26.2;
};
0EQUPD022F40000000000001 = {
CreatedOnToolsVersion = 26.2;
};
0ETEST0012F31000000000001 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 0E241E382F0DA93A00283E2F;
};
0EUITEST012F31000000001 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 0E241E382F0DA93A00283E2F;
};
};
};
buildConfigurationList = 0E241E342F0DA93A00283E2F /* Build configuration list for PBXProject "PortfolioJournal" */;
@@ -239,6 +403,9 @@
targets = (
0E241E382F0DA93A00283E2F /* PortfolioJournal */,
0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */,
0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */,
0ETEST0012F31000000000001 /* PortfolioJournalTests */,
0EUITEST012F31000000001 /* PortfolioJournalUITests */,
);
};
/* End PBXProject section */
@@ -258,6 +425,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0ETEST0062F31000000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EUITEST062F31000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EQUPD062F40000000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -275,14 +463,73 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
0ETEST0052F31000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EUITEST052F31000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0EQUPD042F40000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0E5375362F0FD14000F31390 /* Upload dSYMs to Crashlytics */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "Upload dSYMs to Crashlytics";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXTargetDependency section */
0E241EE12F0DAA3E00283E2F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0E241ECB2F0DAA3C00283E2F /* PortfolioJournalWidgetExtension */;
targetProxy = 0E241EE02F0DAA3E00283E2F /* PBXContainerItemProxy */;
};
0ETEST0022F31000000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
targetProxy = 0E481F2C2F2E958100CF94C5 /* PBXContainerItemProxy */;
};
0EUITEST022F31000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0E241E382F0DA93A00283E2F /* PortfolioJournal */;
targetProxy = 0E481F2D2F2E958100CF94C5 /* PBXContainerItemProxy */;
};
0EQUPD122F40000000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0EQUPD022F40000000000001 /* PortfolioJournalQuickUpdate */;
targetProxy = 0EQUPD112F40000000000001 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -292,8 +539,9 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -311,7 +559,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -330,8 +578,11 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES;
@@ -349,7 +600,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -488,7 +739,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -501,7 +752,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -520,8 +771,10 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
@@ -534,7 +787,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -547,6 +800,156 @@
};
name = Release;
};
0ETEST0082F31000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PortfolioJournal.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PortfolioJournal";
};
name = Debug;
};
0ETEST0092F31000000000001 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PortfolioJournal.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PortfolioJournal";
};
name = Release;
};
0EUITEST082F31000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = PortfolioJournal;
};
name = Debug;
};
0EUITEST092F31000000001 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = PortfolioJournal;
};
name = Release;
};
0EQUPD082F40000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
0EQUPD092F40000000000001 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = 2825Q76T7H;
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Portfolio Journal";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.6.2;
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -577,6 +980,33 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0ETEST0072F31000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0ETEST0082F31000000000001 /* Debug */,
0ETEST0092F31000000000001 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EUITEST072F31000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EUITEST082F31000000001 /* Debug */,
0EUITEST092F31000000001 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0EQUPD072F40000000000001 /* Build configuration list for PBXNativeTarget "PortfolioJournalQuickUpdate" */ = {
isa = XCConfigurationList;
buildConfigurations = (
0EQUPD082F40000000000001 /* Debug */,
0EQUPD092F40000000000001 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
@@ -614,6 +1044,11 @@
package = 0E241EEC2F0DAC2D00283E2F /* XCRemoteSwiftPackageReference "swift-package-manager-google-mobile-ads" */;
productName = GoogleMobileAds;
};
0E5375342F0FD14000F31390 /* FirebaseCrashlytics */ = {
isa = XCSwiftPackageProductDependency;
package = 0E241EEB2F0DABEC00283E2F /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
productName = FirebaseCrashlytics;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0E241E312F0DA93A00283E2F /* Project object */;
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
@@ -27,8 +27,31 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0ETEST0012F31000000000001"
BuildableName = "PortfolioJournalTests.xctest"
BlueprintName = "PortfolioJournalTests"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EUITEST012F31000000001"
BuildableName = "PortfolioJournalUITests.xctest"
BlueprintName = "PortfolioJournalUITests"
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -50,6 +73,20 @@
ReferencedContainer = "container:PortfolioJournal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.Logging.stderr 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-InitializeCloudKitSchema"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 3 "
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<StoreKitConfigurationFileReference
identifier = "../../PortfolioJournal/Configuration.storekit">
</StoreKitConfigurationFileReference>
@@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>PortfolioJournalQuickUpdate.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>PortfolioJournalWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
BIN
View File
Binary file not shown.
+1
View File
@@ -2,6 +2,7 @@ import UIKit
import UserNotifications
import FirebaseCore
import FirebaseAnalytics
import FirebaseCrashlytics
import GoogleMobileAds
class AppDelegate: NSObject, UIApplicationDelegate {
+158 -2
View File
@@ -1,6 +1,34 @@
import SwiftUI
import CoreData
// MARK: - App Tab
enum AppTab: Int, Hashable, CaseIterable {
case dashboard = 0, sources = 1, charts = 2, journal = 3, settings = 4
var title: String {
switch self {
case .dashboard: String(localized: "tab_dashboard")
case .sources: String(localized: "tab_sources")
case .charts: String(localized: "tab_charts")
case .journal: "Journal"
case .settings: String(localized: "tab_settings")
}
}
var icon: String {
switch self {
case .dashboard: "house.fill"
case .sources: "list.bullet"
case .charts: "chart.xyaxis.line"
case .journal: "book.closed"
case .settings: "gearshape.fill"
}
}
}
// MARK: - Content View
struct ContentView: View {
@EnvironmentObject var iapService: IAPService
@EnvironmentObject var adMobService: AdMobService
@@ -11,21 +39,44 @@ struct ContentView: View {
@AppStorage("pinEnabled") private var pinEnabled = false
@AppStorage("lockOnLaunch") private var lockOnLaunch = true
@AppStorage("lockOnBackground") private var lockOnBackground = false
@AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = ""
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var isUnlocked = false
@State private var resolvedOnboardingCompleted: Bool?
@State private var iCloudCheckDone = false
@State private var loadingMessageKey: LocalizedStringKey = "loading_data"
@State private var sidebarSelection: AppTab? = .dashboard
@State private var showingWhatsNew = false
private var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
}
private var lockEnabled: Bool {
faceIdEnabled || pinEnabled
}
/// True when a fresh install with iCloud available and sync not yet enabled.
/// Only relevant before onboarding is completed.
private var needsICloudCheck: Bool {
guard resolvedOnboardingCompleted == false else { return false }
guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false }
return FileManager.default.ubiquityIdentityToken != nil
}
var body: some View {
ZStack {
Group {
if !isReadyForContent {
AppLaunchLoadingView(messageKey: "loading_data")
AppLaunchLoadingView(messageKey: loadingMessageKey)
} else if needsICloudCheck && !iCloudCheckDone {
// Fresh install with iCloud available: ask before showing onboarding
OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true })
} else if resolvedOnboardingCompleted == false {
OnboardingView(onboardingCompleted: $onboardingCompleted)
} else if horizontalSizeClass == .regular {
iPadMainContent
} else {
mainContent
}
@@ -62,9 +113,51 @@ struct ContentView: View {
}
.task {
await waitForDataAndResolveOnboarding()
// Cumulative What's New: everything between the version the user
// comes from and the one they just installed. Empty catalog diff
// (e.g. patch release without entry) no sheet.
if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion,
!WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty {
// Small delay to let the UI settle before showing sheet
try? await Task.sleep(nanoseconds: 300_000_000)
showingWhatsNew = true
}
}
.onChange(of: onboardingCompleted) { _, completed in
resolvedOnboardingCompleted = completed
// Fresh install: nothing is "new" for a user who just onboarded.
if completed && lastSeenWhatsNewVersion.isEmpty {
lastSeenWhatsNewVersion = currentVersion
// Straight to the aha moment: open Add Source instead of
// dropping the new user on an empty dashboard.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
NotificationCenter.default.post(name: .openAddFirstSource, object: nil)
}
}
}
.sheet(isPresented: $showingWhatsNew, onDismiss: {
lastSeenWhatsNewVersion = currentVersion
}) {
WhatsNewView(releases: WhatsNewCatalog.pendingReleases(
since: lastSeenWhatsNewVersion,
current: currentVersion
))
}
.onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in
tabSelection.selectedTab = 1
sidebarSelection = .sources
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
.onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
}
.onOpenURL { url in
if url.host == "quickupdate" {
tabSelection.selectedTab = 0
sidebarSelection = .dashboard
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
}
}
}
@@ -78,12 +171,31 @@ struct ContentView: View {
try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
}
// If CloudKit is enabled and no local data yet, wait briefly for the
// initial iCloud sync so data from other devices appears before we
// decide whether to show onboarding.
if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() {
await MainActor.run { loadingMessageKey = "checking_icloud" }
await waitForInitialCloudKitSync(timeout: 10)
await MainActor.run { loadingMessageKey = "loading_data" }
}
// Resolve onboarding state on main thread
await MainActor.run {
syncOnboardingState()
}
}
/// Polls for existing data up to `timeout` seconds, returning as soon as
/// any data appears. Used to wait for the initial CloudKit sync on launch.
private func waitForInitialCloudKitSync(timeout: TimeInterval) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if hasExistingData() { return }
try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms
}
}
private func syncOnboardingState() {
let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext)
var resolved = settings.onboardingCompleted || onboardingCompleted
@@ -128,6 +240,49 @@ struct ContentView: View {
return ((try? context.count(for: accountRequest)) ?? 0) > 0
}
// MARK: - iPad Layout
private var iPadMainContent: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in
Label(tab.title, systemImage: tab.icon)
}
.navigationTitle("Portfolio Journal")
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280)
} detail: {
iPadDetailView(for: sidebarSelection ?? .dashboard)
}
.onChange(of: sidebarSelection) { _, tab in
guard let tab else { return }
if tabSelection.selectedTab != tab.rawValue {
tabSelection.selectedTab = tab.rawValue
}
}
.onChange(of: tabSelection.selectedTab) { _, value in
if let tab = AppTab(rawValue: value), sidebarSelection != tab {
sidebarSelection = tab
}
}
}
@ViewBuilder
private func iPadDetailView(for tab: AppTab) -> some View {
switch tab {
case .dashboard:
bannerInsetView(DashboardView())
case .sources:
bannerInsetView(SourceListView(iapService: iapService))
case .charts:
bannerInsetView(ChartsContainerView(iapService: iapService))
case .journal:
bannerInsetView(JournalView())
case .settings:
bannerInsetView(SettingsView(iapService: iapService))
}
}
// MARK: - iPhone Layout
private var mainContent: some View {
ZStack {
TabView(selection: $tabSelection.selectedTab) {
@@ -166,7 +321,7 @@ struct ContentView: View {
private func bannerInsetView<Content: View>(_ content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !iapService.isPremium {
if !iapService.isPremium && adMobService.canShowAds {
BannerAdView()
.frame(height: AppConstants.UI.bannerAdHeight)
.frame(maxWidth: .infinity)
@@ -183,4 +338,5 @@ struct ContentView: View {
.environmentObject(AdMobService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
.environmentObject(BalancePrivacyManager.shared)
}
@@ -1,4 +1,59 @@
import SwiftUI
import CoreData
import TipKit
/// Support for automated App Store screenshot capture. Activated by launching the app
/// with the `--screenshots` argument (used by the UI-test capture flow). Only mutates
/// state when that argument is present, so it never affects normal runs.
enum ScreenshotMode {
static var isActive: Bool { CommandLine.arguments.contains("--screenshots") }
/// Skip onboarding, biometric/PIN lock and the What's New sheet so the app opens
/// straight into content. Called very early, before ContentView reads @AppStorage.
static func applyDefaultsIfNeeded() {
guard isActive else { return }
let d = UserDefaults.standard
d.set(true, forKey: "onboardingCompleted")
d.set(false, forKey: "faceIdEnabled")
d.set(false, forKey: "pinEnabled")
d.set(false, forKey: "lockOnLaunch")
d.set(false, forKey: "lockOnBackground")
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
d.set(version, forKey: "lastSeenWhatsNewVersion")
// Unlock premium (DEBUG-only override) so captures/UI tests can exercise
// the premium charts without a StoreKit purchase. `--no-premium` keeps it
// off to test the free-tier experience (locked charts, teasers).
d.set(!CommandLine.arguments.contains("--no-premium"), forKey: "debugPremiumOverride")
}
/// Seed demo data (no-op if the store already has sources). Called once Core Data
/// has finished loading so the view context is ready.
static func seedIfNeeded() {
guard isActive else { return }
SampleDataService.shared.seedSampleData()
makeCheckInPendingIfNeeded()
}
/// `--pending-checkin`: strip the seeded data back to a "month not done yet"
/// state (recent snapshots + this month's journal completion removed) so the
/// guided check-in flow can be exercised/captured.
private static func makeCheckInPendingIfNeeded() {
guard CommandLine.arguments.contains("--pending-checkin") else { return }
let context = CoreDataStack.shared.viewContext
let cutoff = Calendar.current.date(byAdding: .day, value: -40, to: Date()) ?? Date()
let snapshotRequest = Snapshot.fetchRequest()
snapshotRequest.predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
(try? context.fetch(snapshotRequest))?.forEach(context.delete)
let journalRequest = JournalEntry.fetchRequest()
journalRequest.predicate = NSPredicate(format: "completionTime >= %@", cutoff as NSDate)
(try? context.fetch(journalRequest))?.forEach(context.delete)
try? context.save()
SharedQuickUpdateSync.refreshMirror()
}
}
@main
struct PortfolioJournalApp: App {
@@ -7,14 +62,22 @@ struct PortfolioJournalApp: App {
@StateObject private var adMobService: AdMobService
@StateObject private var accountStore: AccountStore
@StateObject private var tabSelection = TabSelectionStore()
@StateObject private var balancePrivacy = BalancePrivacyManager.shared
@Environment(\.scenePhase) private var scenePhase
let coreDataStack = CoreDataStack.shared
init() {
// Screenshot/UI-capture mode: skip onboarding, lock and What's New so the app
// launches straight into content with demo data (see ScreenshotMode).
ScreenshotMode.applyDefaultsIfNeeded()
// Clean up any duplicate objects from previous bugs before initializing stores
CoreDataStack.shared.cleanupDuplicateObjects()
// Feature-discovery tips (Quick Update, OCR, Siri, chart sharing)
try? Tips.configure([.displayFrequency(.daily)])
let iap = IAPService()
_iapService = StateObject(wrappedValue: iap)
_adMobService = StateObject(wrappedValue: AdMobService())
@@ -30,10 +93,38 @@ struct PortfolioJournalApp: App {
.environmentObject(adMobService)
.environmentObject(accountStore)
.environmentObject(tabSelection)
.environmentObject(balancePrivacy)
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
coreDataStack.refreshWidgetData()
// Re-read all Core Data objects from the persistent store so that
// iCloud changes made on other devices while this device was inactive
// are reflected immediately without waiting for a remote-change notification.
coreDataStack.refreshFromCloudKit()
// Auto-backup: writes a lossless JSON snapshot on the configured cadence.
DispatchQueue.global(qos: .utility).async {
BackupService.shared.runScheduledBackupIfNeeded()
}
NotificationService.shared.scheduleReEngagementNotification()
NotificationService.shared.scheduleMonthlyCheckIn()
NotificationService.shared.scheduleStreakProtectionReminder()
// Share-extension bridge: apply values captured from other apps and
// refresh the source mirror the extension reads.
if coreDataStack.isLoaded {
SharedQuickUpdateSync.ingestPending()
SharedQuickUpdateSync.refreshMirror()
}
} else if newPhase == .background {
guard iapService.isPremium else { return }
guard UserDefaults.standard.bool(forKey: "backupsEnabled") else { return }
let retention = UserDefaults.standard.integer(forKey: "backupRetentionCount")
let keepCount = [5, 10, 20].contains(retention) ? retention : 10
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
_ = BackupService.shared.createBackup(
retentionCount: keepCount,
includeICloud: includeICloud
)
}
}
}
Binary file not shown.
@@ -7,9 +7,21 @@ public class Account: NSManagedObject, Identifiable {
return NSFetchRequest<Account>(entityName: "Account")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var createdAt: Date
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var currency: String?
@NSManaged public var inputMode: String
@NSManaged public var notificationFrequency: String
@@ -34,6 +46,11 @@ public class Account: NSManagedObject, Identifiable {
extension Account {
static let defaultAccountName = "Default"
/// Deterministic UUID for the auto-created "Default" account, so every device
/// creates it as the SAME CloudKit record instead of a per-device duplicate.
/// See StableID.
static let defaultAccountStableID = StableID.v5("account:default")
}
// MARK: - Computed Properties
@@ -8,12 +8,25 @@ public class Category: NSManagedObject, Identifiable {
return NSFetchRequest<Category>(entityName: "Category")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@NSManaged public var colorHex: String
@NSManaged public var icon: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@objc public var colorHex: String {
get { safeValue(forKey: "colorHex", fallback: "#6B7280") }
set { setManagedValue(newValue, forKey: "colorHex") }
}
@objc public var icon: String {
get { safeValue(forKey: "icon", fallback: "questionmark") }
set { setManagedValue(newValue, forKey: "icon") }
}
@NSManaged public var sortOrder: Int16
@NSManaged public var createdAt: Date
@NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
@@ -40,7 +53,8 @@ extension Category {
}
var sourceCount: Int {
sources?.count ?? 0
guard !isDeleted, !isFault else { return 0 }
return sources?.count ?? 0
}
var totalValue: Decimal {
@@ -78,9 +92,17 @@ extension Category {
("ETFs", "#EC4899", "chart.bar.fill")
]
/// Deterministic UUID for a default category, so every device creates each
/// default category as the SAME CloudKit record instead of a per-device
/// duplicate. Only meaningful for the built-in default names. See StableID.
static func stableID(for name: String) -> UUID {
StableID.v5("category:\(name.lowercased())")
}
static func createDefaultCategories(in context: NSManagedObjectContext) {
for (index, categoryData) in defaultCategories.enumerated() {
let category = Category(context: context)
category.id = stableID(for: categoryData.name)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
@@ -7,12 +7,24 @@ public class Goal: NSManagedObject, Identifiable {
return NSFetchRequest<Goal>(entityName: "Goal")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@NSManaged public var targetAmount: NSDecimalNumber?
@NSManaged public var targetDate: Date?
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var account: Account?
public override func awakeFromInsert() {
@@ -7,16 +7,28 @@ public class InvestmentSource: NSManagedObject, Identifiable {
return NSFetchRequest<InvestmentSource>(entityName: "InvestmentSource")
}
@NSManaged public var id: UUID
@NSManaged public var name: String
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var name: String {
get { safeValue(forKey: "name", fallback: "") }
set { setManagedValue(newValue, forKey: "name") }
}
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var isActive: Bool
@NSManaged public var createdAt: Date
@NSManaged public var monthlyContribution: NSDecimalNumber?
// createdAt is optional in the model; a non-optional @NSManaged Date crashes the
// CloudKit merge when an imported record materialises with a transient nil.
// Same nil-tolerant accessor pattern already applied to Snapshot.
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var category: Category?
@NSManaged public var account: Account?
@NSManaged public var snapshots: NSSet?
@NSManaged public var transactions: NSSet?
@NSManaged public var asset: Asset?
public override func awakeFromInsert() {
@@ -28,6 +40,14 @@ public class InvestmentSource: NSManagedObject, Identifiable {
customFrequencyMonths = 1
name = ""
}
public override func awakeFromFetch() {
super.awakeFromFetch()
// Defensive: ensure id exists for legacy rows where id may be nil.
if value(forKey: "id") == nil {
setValue(UUID(), forKey: "id")
}
}
}
// MARK: - Notification Frequency
@@ -96,13 +116,6 @@ extension InvestmentSource {
snapshots?.count ?? 0
}
/// Returns transactions sorted by date descending
/// Performance note: This sorts on every call. For repeated access, cache the result.
var transactionsArray: [Transaction] {
let set = transactions as? Set<Transaction> ?? []
return set.sorted { $0.date > $1.date }
}
var frequency: NotificationFrequency {
NotificationFrequency(rawValue: notificationFrequency) ?? .monthly
}
@@ -160,37 +173,6 @@ extension InvestmentSource {
}
}
/// Performance: Iterates transactions directly without sorting
var totalInvested: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
let amount = transaction.decimalAmount
switch transaction.transactionType {
case .buy:
return result + amount
case .sell:
return result - amount
default:
return result
}
}
}
/// Performance: Iterates transactions directly without sorting
var totalDividends: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
transaction.transactionType == .dividend ? result + transaction.decimalAmount : result
}
}
/// Performance: Iterates transactions directly without sorting
var totalFees: Decimal {
guard let set = transactions as? Set<Transaction> else { return Decimal.zero }
return set.reduce(Decimal.zero) { result, transaction in
transaction.transactionType == .fee ? result + transaction.decimalAmount : result
}
}
}
// MARK: - Account Scheduling
@@ -225,15 +207,4 @@ extension InvestmentSource {
@objc(removeSnapshots:)
@NSManaged public func removeFromSnapshots(_ values: NSSet)
@objc(addTransactionsObject:)
@NSManaged public func addToTransactions(_ value: Transaction)
@objc(removeTransactionsObject:)
@NSManaged public func removeFromTransactions(_ value: Transaction)
@objc(addTransactions:)
@NSManaged public func addToTransactions(_ values: NSSet)
@objc(removeTransactions:)
@NSManaged public func removeFromTransactions(_ values: NSSet)
}
@@ -0,0 +1,38 @@
import Foundation
import CoreData
@objc(JournalEntry)
public class JournalEntry: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<JournalEntry> {
return NSFetchRequest<JournalEntry>(entityName: "JournalEntry")
}
@NSManaged public var id: UUID?
@NSManaged public var monthKey: String?
@NSManaged public var note: String?
@NSManaged public var moodRaw: String?
@NSManaged public var rating: Int16
@NSManaged public var completionTime: Date?
@NSManaged public var createdAt: Date?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
}
var mood: MonthlyCheckInMood? {
get { moodRaw.flatMap { MonthlyCheckInMood(rawValue: $0) } }
set { moodRaw = newValue?.rawValue }
}
var ratingValue: Int? {
get { rating > 0 ? Int(rating) : nil }
set { rating = Int16(newValue.map { min(max(1, $0), 5) } ?? 0) }
}
var completionDate: Date? {
get { completionTime }
set { completionTime = newValue }
}
}
@@ -0,0 +1,34 @@
import CoreData
// MARK: - Nil-tolerant accessors for CloudKit-synced attributes
//
// Attributes declared non-optional in Swift (`id: UUID`, `date: Date`, ) CAN
// be nil at runtime: NSPersistentCloudKitContainer merges records from other
// devices in stages, and deleted objects may still be referenced by live views.
// Reading them through `@NSManaged` force-bridges nil and crashes
// (`UUID/Date._unconditionallyBridgeFromObjectiveC`) seen in production in
// SwiftUI ForEach identity (nil id) and ChartsViewModel.monthlyTotals (nil
// date). These helpers read the primitive value and fall back safely.
extension NSManagedObject {
/// Reads a primitive attribute, returning `fallback` when nil. With
/// `healing: true` the fallback is also written back to the primitive
/// (without dirtying the object) so identity stays stable for the session.
func safeValue<T>(forKey key: String, fallback: @autoclosure () -> T, healing: Bool = false) -> T {
willAccessValue(forKey: key)
defer { didAccessValue(forKey: key) }
if let value = primitiveValue(forKey: key) as? T {
return value
}
let healed = fallback()
if healing {
setPrimitiveValue(healed, forKey: key)
}
return healed
}
func setManagedValue<T>(_ value: T, forKey key: String) {
willChangeValue(forKey: key)
setPrimitiveValue(value, forKey: key)
didChangeValue(forKey: key)
}
}
@@ -21,23 +21,25 @@
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="account" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
</entity>
<entity name="Category" representedClassName="Category" syncable="YES">
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="monthlyContribution" optional="YES" attributeType="Decimal"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
@@ -75,7 +77,7 @@
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="YES">
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>
<attribute name="calculatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
@@ -98,8 +100,18 @@
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isEstimated" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
</entity>
<entity name="JournalEntry" representedClassName="JournalEntry" syncable="YES">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="monthKey" optional="YES" attributeType="String"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="moodRaw" optional="YES" attributeType="String"/>
<attribute name="rating" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="completionTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
</model>
@@ -7,12 +7,25 @@ public class Snapshot: NSManagedObject, Identifiable {
return NSFetchRequest<Snapshot>(entityName: "Snapshot")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@objc public var id: UUID {
get { safeValue(forKey: "id", fallback: UUID(), healing: true) }
set { setManagedValue(newValue, forKey: "id") }
}
@objc public var date: Date {
get { safeValue(forKey: "date", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "date") }
}
@NSManaged public var value: NSDecimalNumber?
@NSManaged public var contribution: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
/// True for auto-generated (interpolated) snapshots that fill a gap between two
/// real snapshots. Estimated snapshots are shown with a distinct marker and can
/// be removed in bulk. Defaults to false (model default) for all real snapshots.
@NSManaged public var isEstimated: Bool
@objc public var createdAt: Date {
get { safeValue(forKey: "createdAt", fallback: .distantPast) }
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
@@ -21,11 +34,35 @@ public class Snapshot: NSManagedObject, Identifiable {
date = Date()
createdAt = Date()
}
public override func awakeFromFetch() {
super.awakeFromFetch()
// Defensive: ensure id exists for legacy rows where id may be nil.
if value(forKey: "id") == nil {
setValue(UUID(), forKey: "id")
}
}
}
// MARK: - Computed Properties
extension Snapshot {
var safeId: UUID {
if let existing = primitiveValue(forKey: "id") as? UUID {
return existing
}
let newId = UUID()
setPrimitiveValue(newId, forKey: "id")
return newId
}
var safeDate: Date {
if let dateValue = primitiveValue(forKey: "date") as? Date {
return dateValue
}
return Date()
}
var decimalValue: Decimal {
value?.decimalValue ?? Decimal.zero
}
@@ -1,75 +0,0 @@
import Foundation
import CoreData
@objc(Transaction)
public class Transaction: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Transaction> {
return NSFetchRequest<Transaction>(entityName: "Transaction")
}
@NSManaged public var id: UUID
@NSManaged public var date: Date
@NSManaged public var type: String
@NSManaged public var shares: NSDecimalNumber?
@NSManaged public var price: NSDecimalNumber?
@NSManaged public var amount: NSDecimalNumber?
@NSManaged public var notes: String?
@NSManaged public var createdAt: Date
@NSManaged public var source: InvestmentSource?
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
createdAt = Date()
date = Date()
type = TransactionType.buy.rawValue
}
}
enum TransactionType: String, CaseIterable, Identifiable {
case buy
case sell
case dividend
case fee
case transfer
var id: String { rawValue }
var displayName: String {
switch self {
case .buy: return "Buy"
case .sell: return "Sell"
case .dividend: return "Dividend"
case .fee: return "Fee"
case .transfer: return "Transfer"
}
}
var isInvestmentFlow: Bool {
self == .buy || self == .sell
}
}
// MARK: - Computed Properties
extension Transaction {
var decimalShares: Decimal {
shares?.decimalValue ?? Decimal.zero
}
var decimalPrice: Decimal {
price?.decimalValue ?? Decimal.zero
}
var decimalAmount: Decimal {
if let amount = amount?.decimalValue, amount != 0 {
return amount
}
return decimalShares * decimalPrice
}
var transactionType: TransactionType {
TransactionType(rawValue: type) ?? .buy
}
}
+618 -25
View File
@@ -3,6 +3,10 @@ import CloudKit
import Combine
import WidgetKit
extension Notification.Name {
static let cloudKitForceReload = Notification.Name("cloudKitForceReload")
}
class CoreDataStack: ObservableObject {
static let shared = CoreDataStack()
@@ -115,6 +119,44 @@ class CoreDataStack: ObservableObject {
}
@Published private(set) var isLoaded = false
@Published private(set) var lastImportDate: Date?
@Published private(set) var lastExportDate: Date?
@Published private(set) var isSyncing = false
@Published private(set) var lastSyncError: String?
/// Full recursive dump of the last sync error tree (leaf CKError codes, record
/// types, retry-after, raw userInfo keys). Surfaced in the copyable diagnostics
/// so we can see WHICH record CloudKit rejects, not just the opaque code 2.
@Published private(set) var lastSyncErrorDetail: String?
/// Human-readable, actionable explanation of the last sync error (nil if none).
@Published private(set) var lastSyncErrorHint: String?
/// True only when sync is genuinely broken (never synced + total failure).
/// Partial failures after a working sync are informational, not alarming.
@Published private(set) var syncErrorIsCritical = false
var lastSyncDate: Date? { lastImportDate }
var localSourceCount: Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "InvestmentSource")
return (try? viewContext.count(for: request)) ?? 0
}
var localSnapshotCount: Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
return (try? viewContext.count(for: request)) ?? 0
}
private init() {
// Register CloudKit event observer BEFORE the container is created so we
// never miss an import/export event that fires during store loading.
if Self.cloudKitEnabled {
NotificationCenter.default.addObserver(
self,
selector: #selector(cloudKitEventChanged(_:)),
name: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil
)
}
}
lazy var persistentContainer: NSPersistentContainer = {
let container: NSPersistentContainer
@@ -131,12 +173,15 @@ class CoreDataStack: ObservableObject {
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
// Always enable history tracking so data created before CloudKit was enabled
// is visible to NSPersistentCloudKitContainer when sync is later turned on.
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
if Self.cloudKitEnabled {
description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
containerIdentifier: Self.cloudKitContainerIdentifier
)
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}
container.persistentStoreDescriptions = [description]
@@ -147,9 +192,43 @@ class CoreDataStack: ObservableObject {
print("Core Data failed to load: \(error), \(error.userInfo)")
} else {
print("Core Data loaded successfully at: \(description.url?.path ?? "unknown")")
#if DEBUG
// Dev-only, opt-in: push the FULL model schema to the Development CloudKit
// environment so optional fields that were never exported by a Debug run
// (e.g. monthlyContribution on InvestmentSource, allocationTarget on Category)
// get materialised otherwise they can't be deployed to Production and every
// Production export rejects those records (bare CKErrorPartialFailure).
// Run ONCE from Xcode with launch arg -InitializeCloudKitSchema, watch for the
// log, then deploy to Production in the CloudKit Console and remove the arg.
if ProcessInfo.processInfo.arguments.contains("-InitializeCloudKitSchema"),
let ckContainer = container as? NSPersistentCloudKitContainer {
do {
try ckContainer.initializeCloudKitSchema(options: [])
print("✅ CloudKit schema initialized in Development. Now open the CloudKit Console → Deploy Schema Changes → Production.")
} catch {
print("❌ initializeCloudKitSchema failed: \(error)")
}
}
#endif
}
DispatchQueue.main.async {
self?.isLoaded = true
// Merge the pre-stable-UUID default duplicates. Safe now: the merge
// reassigns children then deletes losers, and the relevant relationships
// use the Nullify delete rule so a propagated parent-delete can never
// cascade-wipe children (unlike the build-68 Cascade disaster).
self?.mergeDefaultDuplicates()
MonthlyCheckInStore.migrateIfNeeded()
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
AllocationTargetStore.migrateIfNeeded(context: container.viewContext)
// Seed demo data when running in screenshot capture mode.
ScreenshotMode.seedIfNeeded()
// Apply share-extension captures and publish the source mirror.
Task { @MainActor in
SharedQuickUpdateSync.ingestPending()
SharedQuickUpdateSync.refreshMirror()
}
}
}
@@ -177,42 +256,44 @@ class CoreDataStack: ObservableObject {
return persistentContainer.viewContext
}
private init() {}
// MARK: - Cleanup Duplicates
/// Removes duplicate objects that have the same UUID, keeping only the oldest one.
/// This fixes data corruption from race conditions during object creation.
func cleanupDuplicateObjects() {
/// This fixes data corruption from race conditions during object creation, and also
/// handles the case where a CloudKit first-time sync imports records that already exist
/// locally (because they were created before CloudKit was enabled).
@discardableResult
func cleanupDuplicateObjects() -> Int {
var totalRemoved = 0
let context = viewContext
context.performAndWait {
// Clean up duplicate Goals
cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
// Clean up duplicate Accounts (already handled in AccountRepository but added here for safety)
cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
// Clean up duplicate InvestmentSources
cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
// Clean up duplicate Categories
cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
// Clean up Snapshots first before cascade rules from InvestmentSource fire,
// so we deduplicate by UUID and not rely solely on cascade.
totalRemoved += cleanupDuplicates(entityName: "Snapshot", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Goal", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Account", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "InvestmentSource", idKey: "id", context: context)
totalRemoved += cleanupDuplicates(entityName: "Category", idKey: "id", context: context)
if context.hasChanges {
try? context.save()
}
}
return totalRemoved
}
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) {
@discardableResult
private func cleanupDuplicates(entityName: String, idKey: String, context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
guard let objects = try? context.fetch(request) else { return }
guard let objects = try? context.fetch(request) else { return 0 }
var seenIds = Set<UUID>()
var objectsToDelete: [NSManagedObject] = []
for object in objects {
guard let objectId = object.value(forKey: idKey) as? UUID else { continue }
if seenIds.contains(objectId) {
objectsToDelete.append(object)
} else {
@@ -221,11 +302,247 @@ class CoreDataStack: ObservableObject {
}
if !objectsToDelete.isEmpty {
print("Cleaning up \(objectsToDelete.count) duplicate \(entityName) objects")
for object in objectsToDelete {
context.delete(object)
print("[Dedup] Removing \(objectsToDelete.count) duplicate \(entityName) objects")
for object in objectsToDelete { context.delete(object) }
}
return objectsToDelete.count
}
// MARK: - Logical Duplicate Cleanup (cross-device / cross-environment)
/// Removes *logical* duplicates: records that represent the same real-world
/// entity but carry DIFFERENT UUIDs. These appear when the same data is
/// created independently on two devices or in the Development vs Production
/// CloudKit environments and then merged by CloudKit, which keys on the
/// record name (our UUID) and therefore never recognises them as the same,
/// keeping both. The classic trigger here: `createDefaultAccountIfNeeded` and
/// `ensureDefaultCategoriesExist` seed defaults on every device at launch
/// *before* the first import arrives, so each device contributes its own
/// "Default" account + 8 categories they pile up (8 accounts, 23 categories).
///
/// The UUID-based `cleanupDuplicateObjects()` cannot catch these because the
/// UUIDs differ. Here we group by a stable *business key* and keep a single
/// deterministic winner (earliest `createdAt`, tie-broken by lowest UUID
/// both synced fields, so every device independently picks the SAME survivor
/// and the deletes converge instead of racing each other into data loss),
/// reassigning children onto the survivor before deleting the losers.
@discardableResult
func cleanupLogicalDuplicates() -> Int {
var removed = 0
let context = viewContext
context.performAndWait {
// Parents first: re-point children onto the surviving parent so the
// child keys (which embed the parent's id) stay stable for later passes.
removed += mergeLogicalDuplicates(
entityName: "Account",
childRelationships: ["sources", "goals"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return name.isEmpty ? "" : "account|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "Category",
childRelationships: ["sources"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return name.isEmpty ? "" : "category|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "InvestmentSource",
childRelationships: ["snapshots", "transactions"],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { return "" }
let acc = (obj.value(forKey: "account") as? NSManagedObject)?
.value(forKey: "id") as? UUID
return "source|\(acc?.uuidString ?? "none")|\(name)"
})
removed += mergeLogicalDuplicates(
entityName: "Snapshot",
childRelationships: [],
context: context,
key: { obj in
// Only dedup snapshots that share the same source, same day AND
// same value a true copy. Orphans (nil source) are left alone.
guard let src = (obj.value(forKey: "source") as? NSManagedObject)?
.value(forKey: "id") as? UUID,
let date = obj.value(forKey: "date") as? Date else { return "" }
let day = (date.timeIntervalSinceReferenceDate / 86_400).rounded(.down)
let value = (obj.value(forKey: "value") as? NSDecimalNumber)?
.stringValue ?? "nil"
return "snapshot|\(src.uuidString)|\(day)|\(value)"
})
removed += mergeLogicalDuplicates(
entityName: "Goal",
childRelationships: [],
context: context,
key: { obj in
let name = (obj.value(forKey: "name") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { return "" }
let target = (obj.value(forKey: "targetAmount") as? NSDecimalNumber)?
.stringValue ?? "nil"
return "goal|\(name)|\(target)"
})
removed += mergeLogicalDuplicates(
entityName: "JournalEntry",
childRelationships: [],
context: context,
key: { obj in
let monthKey = (obj.value(forKey: "monthKey") as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return monthKey.isEmpty ? "" : "journal|\(monthKey)"
})
if context.hasChanges {
try? context.save()
}
}
return removed
}
/// Groups objects of `entityName` by `key`, keeps one deterministic winner
/// per group, moves every child in `childRelationships` from the losers onto
/// the winner, then deletes the losers. An empty key means "never dedup this
/// object" (degenerate identity e.g. no name, orphan snapshot).
@discardableResult
private func mergeLogicalDuplicates(
entityName: String,
childRelationships: [String],
context: NSManagedObjectContext,
key: (NSManagedObject) -> String
) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
guard let objects = try? context.fetch(request), !objects.isEmpty else { return 0 }
var groups: [String: [NSManagedObject]] = [:]
for obj in objects {
let k = key(obj)
guard !k.isEmpty else { continue }
groups[k, default: []].append(obj)
}
var removed = 0
for (_, group) in groups where group.count > 1 {
// Deterministic ordering same winner on every device.
let sorted = group.sorted { a, b in
let ca = (a.value(forKey: "createdAt") as? Date) ?? .distantPast
let cb = (b.value(forKey: "createdAt") as? Date) ?? .distantPast
if ca != cb { return ca < cb }
let ia = (a.value(forKey: "id") as? UUID)?.uuidString ?? ""
let ib = (b.value(forKey: "id") as? UUID)?.uuidString ?? ""
return ia < ib
}
let winner = sorted[0]
for loser in sorted.dropFirst() {
for rel in childRelationships {
let children = loser.mutableSetValue(forKey: rel)
guard children.count > 0 else { continue }
let winnerChildren = winner.mutableSetValue(forKey: rel)
// Snapshot children first so the winner is a superset before delete.
for child in children.allObjects {
winnerChildren.add(child) // reassigns the modeled inverse
}
}
context.delete(loser)
removed += 1
}
}
if removed > 0 {
print("[LogicalDedup] \(entityName): removed \(removed) duplicate(s)")
}
return removed
}
// MARK: - Merge Default Duplicates (CloudKit-safe)
/// Merges the duplicate default Accounts and Categories that piled up before
/// the stable-UUID fix (per-device "Default" accounts + default categories with
/// random UUIDs). CloudKit-safe now because Account.sources/goals and
/// Category.sources use the Nullify delete rule so a merged-away parent's
/// delete can never cascade-wipe children on any device (the build-68 disaster
/// was Cascade + cross-device import ordering). Children are reassigned to a
/// DETERMINISTIC winner (the stable-UUID record if present, else earliest
/// createdAt / lowest UUID) so every device converges on the same survivor.
@discardableResult
func mergeDefaultDuplicates() -> Int {
var merged = 0
let context = viewContext
context.performAndWait {
merged += mergeDefaultAccounts(in: context)
merged += mergeDuplicateCategories(in: context)
if context.hasChanges { try? context.save() }
}
return merged
}
private func deterministicWinner(_ objects: [NSManagedObject], stableID: UUID?) -> NSManagedObject? {
if let stableID, let canonical = objects.first(where: { ($0.value(forKey: "id") as? UUID) == stableID }) {
return canonical
}
return objects.sorted {
let a = ($0.value(forKey: "createdAt") as? Date) ?? .distantPast
let b = ($1.value(forKey: "createdAt") as? Date) ?? .distantPast
if a != b { return a < b }
let ida = ($0.value(forKey: "id") as? UUID)?.uuidString ?? ""
let idb = ($1.value(forKey: "id") as? UUID)?.uuidString ?? ""
return ida < idb
}.first
}
private func reassign(_ children: Any?, key: String, to winner: NSManagedObject) {
guard let set = children as? Set<NSManagedObject> else { return }
for child in Array(set) { child.setValue(winner, forKey: key) }
}
private func mergeDefaultAccounts(in context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Account")
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
guard let accounts = try? context.fetch(request), accounts.count > 1,
let winner = deterministicWinner(accounts, stableID: Account.defaultAccountStableID) else { return 0 }
var removed = 0
for loser in accounts where loser !== winner {
reassign(loser.value(forKey: "sources"), key: "account", to: winner)
reassign(loser.value(forKey: "goals"), key: "account", to: winner)
context.delete(loser)
removed += 1
}
if removed > 0 { print("[MergeDefaults] accounts: removed \(removed)") }
return removed
}
private func mergeDuplicateCategories(in context: NSManagedObjectContext) -> Int {
let request = NSFetchRequest<NSManagedObject>(entityName: "Category")
guard let categories = try? context.fetch(request), !categories.isEmpty else { return 0 }
var byName: [String: [NSManagedObject]] = [:]
for c in categories {
let name = (c.value(forKey: "name") as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !name.isEmpty else { continue }
byName[name, default: []].append(c)
}
var removed = 0
for (name, group) in byName where group.count > 1 {
let stable = Category.stableID(for: name)
guard let winner = deterministicWinner(group, stableID: stable) else { continue }
for loser in group where loser !== winner {
reassign(loser.value(forKey: "sources"), key: "category", to: winner)
context.delete(loser)
removed += 1
}
}
if removed > 0 { print("[MergeDefaults] categories: removed \(removed)") }
return removed
}
// MARK: - Save Context
@@ -258,18 +575,293 @@ class CoreDataStack: ObservableObject {
// MARK: - Remote Change Handling
@objc private func processRemoteChanges(_ notification: Notification) {
// Process remote changes on main context
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
// Ensure changes are persisted to disk before refreshing widget
self?.save()
self?.refreshWidgetData()
guard let self else { return }
// Force viewContext to re-read all objects from the persistent store.
self.viewContext.refreshAllObjects()
// Remove any duplicates that CloudKit import may have introduced.
// This handles the first-time sync case where records existed locally before
// CloudKit was enabled: the initial export+import creates duplicate objects.
let removed = self.cleanupDuplicateObjects()
if removed > 0 {
print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import")
}
// Merge default duplicates after each import (CloudKit-safe via Nullify
// rules + deterministic winner devices converge, no cascade wipes).
let mergedDefaults = self.mergeDefaultDuplicates()
if mergedDefaults > 0 {
print("[RemoteChanges] Merged \(mergedDefaults) default duplicates after CloudKit import")
}
// Notify repositories to re-fetch unconditionally.
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
self.objectWillChange.send()
self.save()
self.refreshWidgetData()
}
}
@objc private func cloudKitEventChanged(_ notification: Notification) {
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let isActive = event.endDate == nil
self.isSyncing = isActive
if !isActive {
if event.succeeded, let endDate = event.endDate {
self.lastSyncError = nil
switch event.type {
case .import: self.lastImportDate = endDate
case .export: self.lastExportDate = endDate
default: break
}
} else if let error = event.error {
let typeLabel = event.type == .import ? "import" : event.type == .export ? "export" : "setup"
self.lastSyncError = "\(typeLabel): \(Self.describeError(error))"
self.lastSyncErrorDetail = "\(typeLabel)\n\(Self.deepErrorReport(error))"
self.lastSyncErrorHint = Self.hint(for: error)
// Severity: a partial failure AFTER sync has worked at least
// once is background noise (a subset of records CloudKit
// retries), NOT a broken-sync alarm. Only a total failure with
// no data ever synced is critical.
let hasSyncedBefore = self.lastImportDate != nil || self.lastExportDate != nil
let isPartial = (error as NSError).code == 2 // CKErrorPartialFailure
self.syncErrorIsCritical = !(hasSyncedBefore || isPartial)
print("CloudKit \(typeLabel) full error:\n\(error)\nuserInfo: \((error as NSError).userInfo)")
}
}
}
}
private static func describeError(_ error: Error, depth: Int = 0) -> String {
guard depth < 3 else { return "" }
let ns = error as NSError
var parts: [String] = ["\(ns.domain)(\(ns.code))"]
// At depth 0, always include the localised description so the user sees
// a human-readable message even when userInfo has no other keys.
if depth == 0, let msg = ns.userInfo[NSLocalizedDescriptionKey] as? String {
parts.append(msg)
}
for (key, value) in ns.userInfo {
let k = "\(key)"
if k == NSLocalizedDescriptionKey || k == "NSLocalizedDescription" { continue }
if let nestedError = value as? Error {
parts.append("\(k):\(describeError(nestedError, depth: depth + 1))")
} else if let dict = value as? [AnyHashable: Any], !dict.isEmpty {
let pairs = dict.prefix(3).map { kk, vv -> String in
if let e = vv as? Error { return "\(kk)\(describeError(e, depth: depth + 1))" }
return "\(kk)=\(vv)"
}
parts.append("\(k){\(pairs.joined(separator: ", "))}")
} else if !(value is [AnyHashable: Any]) {
parts.append("\(k)=\(value)")
}
}
return parts.joined(separator: "\n")
}
/// Turns an opaque CloudKit error into a human-readable, actionable hint by
/// digging into CKPartialErrorsByItemIDKey the per-record failures hidden
/// inside a CKErrorPartialFailure (code 2). The dominant underlying code
/// tells us the real problem: schema not deployed (unknownItem/invalidArguments)
/// vs. quota vs. transient network.
static func hint(for error: Error) -> String? {
let ns = error as NSError
// Collect all leaf CKError codes across a possible partial-failure tree.
var codes: [Int] = []
func collect(_ e: NSError) {
if e.domain == "CKErrorDomain", e.code != 2 { codes.append(e.code) }
if let partial = e.userInfo["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
for case let sub as NSError in partial.values { collect(sub) }
}
for value in e.userInfo.values {
if let sub = value as? NSError, sub !== e { collect(sub) }
}
}
collect(ns)
// CKError codes: 14 = unknownItem, 12 = invalidArguments, 6 = partialFailure,
// 25 = quotaExceeded, 4 = networkFailure/unavailable, 3 = networkUnavailable,
// 15 = serverRejectedRequest, 26 = operationCancelled.
if codes.contains(14) || codes.contains(12) || codes.contains(15) {
return String(localized: "icloud_hint_schema")
}
if codes.contains(25) {
return String(localized: "icloud_hint_quota")
}
if codes.contains(3) || codes.contains(4) {
return String(localized: "icloud_hint_network")
}
if ns.code == 2 {
return String(localized: "icloud_hint_partial_generic")
}
return nil
}
/// Full recursive walk of a CloudKit/Core Data error tree. Unlike `describeError`
/// (a compact one-liner), this follows NSUnderlyingError chains and
/// CKPartialErrorsByItemIDKey, and pulls the failing record TYPE out of any
/// embedded CKRecord which is what tells us WHICH entity CloudKit rejects.
static func deepErrorReport(_ error: Error) -> String {
var lines: [String] = []
var leafCodes = Set<Int>()
var recordTypes = Set<String>()
var visited = 0
func walk(_ err: Error, indent: Int) {
guard visited < 300 else { return }
visited += 1
let ns = err as NSError
let pad = String(repeating: " ", count: indent)
lines.append("\(pad)\(ns.domain)(\(ns.code))")
if ns.domain == "CKErrorDomain", ns.code != 2 { leafCodes.insert(ns.code) }
let info = ns.userInfo
if let reason = info[NSLocalizedFailureReasonErrorKey] as? String {
lines.append("\(pad) reason: \(reason)")
}
if let desc = info[NSLocalizedDescriptionKey] as? String {
lines.append("\(pad) desc: \(desc)")
}
if let retry = info["CKErrorRetryAfterKey"] {
lines.append("\(pad) retryAfter: \(retry)")
}
for key in ["CKRecordChangedErrorServerRecordKey",
"CKRecordChangedErrorClientRecordKey",
"CKRecordChangedErrorAncestorRecordKey"] {
if let rec = info[key] as? CKRecord {
recordTypes.insert(rec.recordType)
lines.append("\(pad) record: \(rec.recordType) [\(rec.recordID.recordName)]")
}
}
if let partial = info["CKPartialErrorsByItemIDKey"] as? [AnyHashable: Any] {
lines.append("\(pad) partialErrors: \(partial.count)")
for (rid, value) in partial.prefix(25) {
if let name = (rid as? CKRecord.ID)?.recordName {
lines.append("\(pad) item: \(name)")
}
if let sub = value as? Error { walk(sub, indent: indent + 3) }
}
}
if let underlying = info[NSUnderlyingErrorKey] as? Error {
lines.append("\(pad) underlying:")
walk(underlying, indent: indent + 2)
}
for (k, v) in info {
let ks = "\(k)"
if ks == NSUnderlyingErrorKey || ks == "CKPartialErrorsByItemIDKey" { continue }
if let sub = v as? Error, (sub as NSError) !== ns {
lines.append("\(pad) [\(ks)]:")
walk(sub, indent: indent + 2)
}
}
let keys = info.keys.map { "\($0)" }.sorted()
if !keys.isEmpty { lines.append("\(pad) keys: \(keys.joined(separator: ", "))") }
}
walk(error, indent: 0)
var summary = ""
if !leafCodes.isEmpty {
summary += "leafCKCodes: [\(leafCodes.sorted().map(String.init).joined(separator: ", "))]\n"
}
if !recordTypes.isEmpty {
summary += "recordTypes: [\(recordTypes.sorted().joined(separator: ", "))]\n"
}
return summary + lines.joined(separator: "\n")
}
/// Local Core Data integrity probe: per-entity counts + records with nil id /
/// nil createdAt / orphan relationships that can silently block a CloudKit export.
func integrityReport() -> String {
let ctx = viewContext
var lines: [String] = []
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal", "JournalEntry"]
for name in entities {
let count = (try? ctx.count(for: NSFetchRequest<NSManagedObject>(entityName: name))) ?? -1
lines.append("\(name): \(count)")
}
func flag(_ label: String, _ n: Int) { if n > 0 { lines.append("⚠︎ \(label): \(n)") } }
func fetch(_ name: String) -> [NSManagedObject] {
(try? ctx.fetch(NSFetchRequest<NSManagedObject>(entityName: name))) ?? []
}
let sources = fetch("InvestmentSource")
flag("sources nil id", sources.filter { $0.value(forKey: "id") == nil }.count)
flag("sources nil createdAt", sources.filter { $0.value(forKey: "createdAt") == nil }.count)
let snaps = fetch("Snapshot")
flag("snapshots nil id", snaps.filter { $0.value(forKey: "id") == nil }.count)
flag("snapshots nil createdAt", snaps.filter { $0.value(forKey: "createdAt") == nil }.count)
flag("orphan snapshots (nil source)", snaps.filter { $0.value(forKey: "source") == nil }.count)
let goals = fetch("Goal")
flag("goals nil id", goals.filter { $0.value(forKey: "id") == nil }.count)
flag("goals nil createdAt", goals.filter { $0.value(forKey: "createdAt") == nil }.count)
return lines.joined(separator: "\n")
}
func forceReload() {
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .cloudKitForceReload, object: nil)
}
}
}
/// Forces NSPersistentCloudKitContainer to export all local data to iCloud.
///
/// Strategy: advance `createdAt` by 1 ms for every record. This guarantees
/// a *real* value change that the SQLite persistent store will write as a
/// persistent-history transaction which is what NSPersistentCloudKitContainer
/// needs to discover records and enqueue them for CloudKit export.
///
/// Setting a property to the *same* value may be silently discarded by the
/// SQLite layer (no SQL UPDATE issued no history entry nothing to export).
func forceExportToiCloud(completion: @escaping (Int) -> Void) {
guard Self.cloudKitEnabled else { completion(0); return }
let entities = ["Account", "Category", "InvestmentSource", "Snapshot", "Goal"]
let context = newBackgroundContext()
context.perform { [weak self] in
var totalTouched = 0
for entityName in entities {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.fetchBatchSize = 50
guard let objects = try? context.fetch(request) else { continue }
for object in objects {
// Advance createdAt by 1 ms always a genuine value change.
let t = (object.value(forKey: "createdAt") as? Date) ?? Date()
object.setValue(t.addingTimeInterval(0.001), forKey: "createdAt")
totalTouched += 1
}
}
if context.hasChanges {
try? context.save()
}
DispatchQueue.main.async {
completion(totalTouched)
}
}
}
/// Forces an immediate re-read of all Core Data objects from the persistent store.
/// Call this when the app returns to the foreground so any iCloud changes made on
/// other devices (while this device was inactive) are picked up right away.
func refreshFromCloudKit() {
guard Self.cloudKitEnabled else { return }
viewContext.perform { [weak self] in
self?.viewContext.refreshAllObjects()
}
}
// MARK: - Widget Data Refresh
/// When true, `refreshWidgetData()` becomes a no-op. Set during batch operations
/// (e.g. CSV import of hundreds of snapshots) so we don't run a synchronous WAL
/// checkpoint + widget reload per row that hammered the main thread into an ANR.
/// Call `refreshWidgetData()` once when the batch finishes.
var suppressWidgetRefresh = false
func refreshWidgetData() {
if suppressWidgetRefresh { return }
if #available(iOS 14.0, *) {
if Self.appGroupEnabled {
checkpointWAL()
@@ -288,6 +880,7 @@ class CoreDataStack: ObservableObject {
viewContext.refreshAllObjects()
}
}
}
// MARK: - Shared Container for Widgets
@@ -15,6 +15,12 @@ struct MonthlySummary {
return NSDecimalNumber(decimal: netPerformance / base).doubleValue * 100
}
var formattedMonthYear: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: startDate)
}
var formattedStartingValue: String {
CurrencyFormatter.format(startingValue, style: .currency, maximumFractionDigits: 0)
}
@@ -0,0 +1,47 @@
import SwiftUI
/// Tone / severity of an insight, used to pick an accent color and to
/// prioritize which insights are surfaced first.
enum InsightTone {
case positive
case neutral
case attention
/// Higher priority insights are shown first when capping the list.
var priority: Int {
switch self {
case .attention: return 2
case .positive: return 1
case .neutral: return 0
}
}
}
struct PortfolioInsight: Identifiable {
let id: String
let systemImage: String
let title: String
let value: String
let accentColor: Color
/// Optional supporting line shown under the value in the richer card.
let detail: String?
let tone: InsightTone
init(
id: String,
systemImage: String,
title: String,
value: String,
accentColor: Color,
detail: String? = nil,
tone: InsightTone = .neutral
) {
self.id = id
self.systemImage = systemImage
self.title = title
self.value = value
self.accentColor = accentColor
self.detail = detail
self.tone = tone
}
}
@@ -23,6 +23,11 @@ class AccountRepository: ObservableObject {
self?.fetchAccounts()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchAccounts() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -91,9 +96,12 @@ class AccountRepository: ObservableObject {
return existing
}
// No accounts exist, create Default account
// No accounts exist, create Default account.
// Use a DETERMINISTIC id so every device creates the same CloudKit record
// instead of a per-device duplicate (root-cause fix for the dedup pile-up).
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.id = Account.defaultAccountStableID
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
@@ -25,6 +25,11 @@ class CategoryRepository: ObservableObject {
self.fetchCategories()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchCategories() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -104,6 +109,8 @@ class CategoryRepository: ObservableObject {
}
let category = Category(context: context)
// Deterministic id same CloudKit record on every device (no duplicates).
category.id = Category.stableID(for: categoryData.name)
category.name = categoryData.name
category.colorHex = categoryData.colorHex
category.icon = categoryData.icon
@@ -23,6 +23,11 @@ class GoalRepository: ObservableObject {
self?.fetchGoals()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchGoals() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -24,6 +24,11 @@ class InvestmentSourceRepository: ObservableObject {
self.fetchSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchSources() }
.store(in: &cancellables)
}
// MARK: - Fetch
@@ -162,7 +167,12 @@ class InvestmentSourceRepository: ObservableObject {
// MARK: - Delete
func deleteSource(_ source: InvestmentSource) {
context.delete(source)
if source.managedObjectContext == context {
context.delete(source)
} else {
let objectInContext = context.object(with: source.objectID)
context.delete(objectInContext)
}
save()
}
@@ -102,7 +102,8 @@ class SnapshotRepository: ObservableObject {
date: Date = Date(),
value: Decimal,
contribution: Decimal? = nil,
notes: String? = nil
notes: String? = nil,
batch: Bool = false
) -> Snapshot {
let snapshot = Snapshot(context: context)
snapshot.source = source
@@ -113,10 +114,65 @@ class SnapshotRepository: ObservableObject {
}
snapshot.notes = notes
save()
// In batch mode (CSV import), skip the per-row save + widget refresh the
// caller saves the whole context once at the end. Saving per row triggered a
// main-thread WAL checkpoint each time ANR on large imports.
if !batch {
save()
}
return snapshot
}
// MARK: - Gap Auto-Fill (estimated snapshots)
/// Creates ESTIMATED snapshots for the missing months of an internal gap,
/// linearly interpolated between the source's value at `from` and at `to`.
/// They are marked `isEstimated` so they read as clearly distinct from real
/// data and can be removed in bulk. Idempotent (skips months that already have
/// a snapshot). Returns how many were created.
@discardableResult
func autoFillGap(source: InvestmentSource, from: Date, to: Date, missingMonthDates: [Date]) -> Int {
let snaps = source.snapshotsArray
func value(atMonth month: Date) -> Decimal? {
snaps.filter { $0.date.startOfMonth == month.startOfMonth }
.max(by: { $0.date < $1.date })?.decimalValue
}
guard let v0 = value(atMonth: from),
let v1 = value(atMonth: to),
!missingMonthDates.isEmpty else { return 0 }
let steps = missingMonthDates.count + 1 // segments between v0 and v1
var created = 0
for (i, month) in missingMonthDates.enumerated() {
let monthStart = month.startOfMonth
if snaps.contains(where: { $0.date.startOfMonth == monthStart }) { continue }
let fraction = Decimal(i + 1) / Decimal(steps)
let interpolated = v0 + (v1 - v0) * fraction
let snapshot = Snapshot(context: context)
snapshot.source = source
snapshot.date = monthStart
snapshot.value = NSDecimalNumber(decimal: interpolated)
snapshot.isEstimated = true
snapshot.notes = String(localized: "gaps_estimated_note")
created += 1
}
if created > 0 { save() }
return created
}
/// Deletes all estimated (auto-filled) snapshots, optionally scoped to one
/// source. Returns how many were removed.
@discardableResult
func deleteEstimatedSnapshots(for source: InvestmentSource? = nil) -> Int {
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
request.predicate = NSPredicate(format: "isEstimated == YES")
guard let estimated = try? context.fetch(request) else { return 0 }
let targets = source == nil ? estimated : estimated.filter { $0.source?.id == source?.id }
for snapshot in targets { context.delete(snapshot) }
if !targets.isEmpty { save() }
return targets.count
}
// MARK: - Update
func updateSnapshot(
@@ -148,6 +204,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) {
@@ -1,62 +0,0 @@
import Foundation
import CoreData
class TransactionRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
}
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
request.predicate = NSPredicate(format: "source == %@", source)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
]
return (try? context.fetch(request)) ?? []
}
@discardableResult
func createTransaction(
source: InvestmentSource,
type: TransactionType,
date: Date,
shares: Decimal?,
price: Decimal?,
amount: Decimal?,
notes: String?
) -> Transaction {
let transaction = Transaction(context: context)
transaction.source = source
transaction.type = type.rawValue
transaction.date = date
if let shares = shares {
transaction.shares = NSDecimalNumber(decimal: shares)
}
if let price = price {
transaction.price = NSDecimalNumber(decimal: price)
}
if let amount = amount {
transaction.amount = NSDecimalNumber(decimal: amount)
}
transaction.notes = notes
save()
return transaction
}
func deleteTransaction(_ transaction: Transaction) {
context.delete(transaction)
save()
}
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
} catch {
print("Failed to save transaction: \(error)")
}
}
}
@@ -0,0 +1,350 @@
{
"sourceLanguage" : "en",
"strings" : {
"Add a snapshot in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Füge einen Eintrag in ${applicationName} hinzu"
}
},
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Añade un registro en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajoute un relevé dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiungi una rilevazione in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}でスナップショットを追加"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Adicione um registro no ${applicationName}"
}
}
}
},
"Check my portfolio in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Zeige mein Portfolio in ${applicationName}"
}
},
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Check my portfolio in ${applicationName}",
"Show my net worth in ${applicationName}"
]
}
},
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Consulta mi cartera en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Consulte mon portefeuille dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Controlla il mio portafoglio in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}でポートフォリオを確認"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Verifique minha carteira no ${applicationName}"
}
}
}
},
"Log a value in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Erfasse einen Wert in ${applicationName}"
}
},
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Log a value in ${applicationName}",
"Log my ${source} balance in ${applicationName}",
"Update ${source} in ${applicationName}"
]
}
},
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registra un valor en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Enregistre une valeur dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registra un valore in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}で金額を記録"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Registre um valor no ${applicationName}"
}
}
}
},
"Log my ${source} balance in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Erfasse den Saldo von ${source} in ${applicationName}"
}
},
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra el saldo de ${source} en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enregistre le solde de ${source} dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registra il saldo di ${source} in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で${source}の残高を記録"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Registre o saldo de ${source} no ${applicationName}"
}
}
}
},
"Show my net worth in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zeige mein Vermögen in ${applicationName}"
}
},
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Muestra mi patrimonio en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Affiche mon patrimoine dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostra il mio patrimonio in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で純資産を表示"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostre meu patrimônio no ${applicationName}"
}
}
}
},
"Update ${source} in ${applicationName}" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aktualisiere ${source} in ${applicationName}"
}
},
"es-ES" : {
"stringUnit" : {
"state" : "translated",
"value" : "Actualiza ${source} en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mets à jour ${source} dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aggiorna ${source} in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "${applicationName}で${source}を更新"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Atualize ${source} no ${applicationName}"
}
}
}
},
"What's my net worth in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"What's my net worth in ${applicationName}",
"How much is my portfolio worth in ${applicationName}",
"Get my net worth from ${applicationName}"
]
}
}
}
},
"What's my streak in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"What's my streak in ${applicationName}",
"Check my check-in streak in ${applicationName}"
]
}
}
}
},
"Update my portfolio in ${applicationName}" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Aktualisiere mein Portfolio in ${applicationName}"
}
},
"en" : {
"stringSet" : {
"state" : "new",
"values" : [
"Update my portfolio in ${applicationName}",
"Add a snapshot in ${applicationName}"
]
}
},
"es-ES" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Actualiza mi cartera en ${applicationName}"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Mets à jour mon portefeuille dans ${applicationName}"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Aggiorna il mio portafoglio in ${applicationName}"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "${applicationName}でポートフォリオを更新"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Atualize minha carteira no ${applicationName}"
}
}
}
}
},
"version" : "1.0"
}
+23 -21
View File
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,11 +30,32 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9632507420</string>
<key>GADDelayAppMeasurementInit</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<false/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<false/>
</dict>
<key>NSCalendarsUsageDescription</key>
<string>Used to set investment update reminders.</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your portfolio data.</string>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUserTrackingUsageDescription</key>
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
<key>SKAdNetworkItems</key>
<array>
<dict>
@@ -298,25 +319,6 @@
<string>275upjj5gd.skadnetwork</string>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<false/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<false/>
</dict>
<key>NSCalendarsUsageDescription</key>
<string>Used to set investment update reminders.</string>
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to unlock your portfolio data.</string>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUserTrackingUsageDescription</key>
<string>This app uses tracking to provide personalized ads and improve your experience. Your data is not sold to third parties.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -0,0 +1,679 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Abbrechen";
"save" = "Speichern";
"delete" = "Löschen";
"edit" = "Bearbeiten";
"add" = "Hinzufügen";
"done" = "Fertig";
"close" = "Schließen";
"continue" = "Weiter";
"skip" = "Überspringen";
"error" = "Fehler";
"success" = "Erfolg";
"loading" = "Lädt...";
"loading_data" = "Deine Daten werden geladen...";
"tab_dashboard" = "Start";
"tab_sources" = "Quellen";
"tab_charts" = "Charts";
"tab_settings" = "Einstellungen";
"dashboard_title" = "Start";
"total_portfolio_value" = "Gesamtwert des Portfolios";
"today" = "heute";
"returns" = "Rendite";
"by_category" = "Nach Kategorie";
"pending_updates" = "Ausstehende Updates";
"see_all" = "Alle anzeigen";
"sources_title" = "Quellen";
"add_source" = "Quelle hinzufügen";
"source_name" = "Quellenname";
"select_category" = "Kategorie auswählen";
"initial_value" = "Anfangswert";
"initial_value_optional" = "Anfangswert (optional)";
"reminder_frequency" = "Erinnerungsintervall";
"source_limit_warning" = "Quellenlimit erreicht. Upgrade auf Premium für unbegrenzte Quellen.";
"no_sources" = "Keine Investmentquellen";
"no_sources_message" = "Füge deine erste Investmentquelle hinzu, um dein Portfolio zu verfolgen.";
"add_snapshot" = "Snapshot hinzufügen";
"edit_snapshot" = "Snapshot bearbeiten";
"snapshot_date" = "Datum";
"snapshot_value" = "Wert";
"snapshot_contribution" = "Beitrag";
"contribution_optional" = "Beitrag (optional)";
"notes" = "Notizen";
"notes_optional" = "Notizen (optional)";
"previous_value" = "Vorheriger Wert: %@";
"change_from_previous" = "Änderung zum vorherigen Wert";
"charts_title" = "Charts";
"evolution" = "Entwicklung";
"allocation" = "Allokation";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilität";
"prediction" = "Prognose";
"portfolio_evolution" = "Portfolioentwicklung";
"asset_allocation" = "Asset-Allokation";
"performance_by_category" = "Performance nach Kategorie";
"drawdown_analysis" = "Drawdown-Analyse";
"prediction_12_month" = "12-Monats-Prognose";
"not_enough_data" = "Nicht genügend Daten";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Max. Drawdown";
"sharpe_ratio" = "Sharpe Ratio";
"win_rate" = "Trefferquote";
"avg_monthly" = "Monatsdurchschnitt";
"best_month" = "Bester Monat";
"worst_month" = "Schlechtester Monat";
"premium" = "Premium";
"upgrade_to_premium" = "Auf Premium upgraden";
"unlock_full_potential" = "Volles Potenzial freischalten";
"one_time_purchase" = "Einmalkauf";
"includes_family_sharing" = "Inklusive Familienfreigabe";
"upgrade_now" = "Jetzt upgraden";
"restore_purchases" = "Käufe wiederherstellen";
"premium_active" = "Premium aktiv";
"premium_feature" = "Premium-Funktion";
"unlock" = "Freischalten";
"feature_unlimited_sources" = "Unbegrenzte Quellen";
"feature_unlimited_sources_desc" = "Verfolge so viele Investments, wie du möchtest";
"feature_full_history" = "Vollständige Historie";
"feature_full_history_desc" = "Greife auf deine komplette Investmenthistorie zu";
"feature_advanced_charts" = "Erweiterte Charts";
"feature_advanced_charts_desc" = "5 Arten detaillierter Analysecharts";
"feature_predictions" = "Prognosen";
"feature_predictions_desc" = "KI-gestützte 12-Monats-Prognosen";
"feature_export" = "Daten exportieren";
"feature_export_desc" = "Export nach CSV und JSON";
"feature_no_ads" = "Keine Werbung";
"feature_no_ads_desc" = "Dauerhaft werbefrei";
"paywall_benefit_history_title" = "Deine vollständige Historie";
"paywall_benefit_history_subtitle" = "Jeder Snapshot, Beitrag und Gewinn seit Tag eins";
"paywall_benefit_charts_title" = "Charts, die Muster zeigen";
"paywall_benefit_charts_subtitle" = "Allokation, Drawdown, Performance, alles an einem Ort";
"paywall_benefit_forecasts_title" = "12-Monats-Prognosen";
"paywall_benefit_forecasts_subtitle" = "Sieh, wohin sich dein Portfolio wahrscheinlich entwickelt";
"paywall_benefit_noads_title" = "Keine Werbung, jemals";
"paywall_benefit_noads_subtitle" = "Klares, fokussiertes Erlebnis ohne Ablenkung";
"settings_title" = "Einstellungen";
"subscription" = "Abo";
"notifications" = "Benachrichtigungen";
"default_reminder_time" = "Standard-Erinnerungszeit";
"data" = "Daten";
"export_data" = "Daten exportieren";
"total_sources" = "Anzahl Quellen";
"total_snapshots" = "Anzahl Snapshots";
"storage_used" = "Verwendeter Speicher";
"about" = "Info";
"version" = "Version";
"privacy_policy" = "Datenschutzerklärung";
"terms_of_service" = "Nutzungsbedingungen";
"support" = "Support";
"rate_app" = "App bewerten";
"danger_zone" = "Gefahrenzone";
"reset_all_data" = "Alle Daten zurücksetzen";
"reset_confirmation" = "Dadurch werden alle Investmentdaten dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
"frequency_monthly" = "Monatlich";
"frequency_quarterly" = "Vierteljährlich";
"frequency_semiannual" = "Halbjährlich";
"frequency_annual" = "Jährlich";
"frequency_custom" = "Benutzerdefiniert";
"frequency_never" = "Nie";
"every_n_months" = "Alle %d Monate";
"category_stocks" = "Aktien";
"category_bonds" = "Anleihen";
"category_real_estate" = "Immobilien";
"category_crypto" = "Krypto";
"category_cash" = "Cash";
"category_etfs" = "ETFs";
"category_retirement" = "Altersvorsorge";
"category_other" = "Sonstiges";
"uncategorized" = "Nicht kategorisiert";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1J";
"time_all" = "Alle";
"export_format" = "Format auswählen";
"export_csv" = "CSV";
"export_csv_desc" = "Kompatibel mit Excel und Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Vollständige Datenstruktur für Backups";
"onboarding_track_title" = "Verfolge deine Investments";
"onboarding_track_desc" = "Behalte alle deine Investmentquellen an einem Ort im Blick. Aktien, Anleihen, Immobilien, Krypto und mehr.";
"onboarding_visualize_title" = "Visualisiere dein Wachstum";
"onboarding_visualize_desc" = "Schöne Charts zeigen die Entwicklung, Allokation und Performance deines Portfolios im Zeitverlauf.";
"onboarding_reminders_title" = "Verpasse nie ein Update";
"onboarding_reminders_desc" = "Setze Erinnerungen, um deine Investments regelmäßig zu verfolgen. Monatlich, vierteljährlich oder individuell.";
"onboarding_sync_title" = "Überall synchron";
"onboarding_sync_desc" = "Deine Daten synchronisieren automatisch via iCloud auf all deinen Apple-Geräten.";
"get_started" = "Loslegen";
"onboarding_clarity_title" = "Wisse genau, wo du stehst";
"onboarding_clarity_desc" = "Sieh dein Gesamtvermögen, echte Renditen und Allokation, immer aktuell.";
"onboarding_habit_title" = "5 Minuten pro Monat reichen";
"onboarding_habit_desc" = "Erfasse deine Werte einmal im Monat. Portfolio Journal übernimmt die Berechnungen und zeigt deinen Fortschritt.";
"onboarding_calm_title" = "Ignoriere den Lärm. Verfolge den Trend.";
"onboarding_calm_desc" = "Tägliche Schwankungen erzählen nicht die echte Geschichte. Dein Wachstum über Monate und Jahre schon.";
"onboarding_goals_title" = "Erreiche deine finanziellen Ziele";
"onboarding_goals_desc" = "Setze Ziele, verfolge Meilensteine und sieh genau, wie weit du gekommen bist.";
"error_generic" = "Ein Fehler ist aufgetreten. Bitte versuche es erneut.";
"error_no_purchases" = "Keine Käufe zum Wiederherstellen gefunden";
"error_purchase_failed" = "Kauf fehlgeschlagen: %@";
"error_export_failed" = "Export fehlgeschlagen. Bitte versuche es erneut.";
"placeholder_source_name" = "z. B. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Notizen hinzufügen...";
"mood_energized_title" = "Voller Energie";
"mood_confident_title" = "Selbstsicher";
"mood_balanced_title" = "Ausgeglichen";
"mood_cautious_title" = "Vorsichtig";
"mood_stressed_title" = "Gestresst";
"mood_energized_detail" = "Fühle mich unschlagbar";
"mood_confident_detail" = "Auf Kurs und gelassen";
"mood_balanced_detail" = "Ruhig und geduldig";
"mood_cautious_detail" = "Beobachte die Bewegungen";
"mood_stressed_detail" = "Brauche einen Reset";
"achievement_streak_3_title" = "3-Monats-Serie";
"achievement_streak_3_detail" = "Du hast drei Monate in Folge pünktlich eingecheckt.";
"achievement_streak_6_title" = "Halbjahres-Serie";
"achievement_streak_6_detail" = "Sechs pünktliche Check-ins in Folge.";
"achievement_streak_12_title" = "Ein Jahr Momentum";
"achievement_streak_12_detail" = "Ein ganzes Jahr ohne Fristversäumnis.";
"achievement_perfect_on_time_title" = "Nie zu spät";
"achievement_perfect_on_time_detail" = "Jeder Check-in kam vor der Frist an.";
"achievement_clutch_finish_title" = "Last-Minute-Erfolg";
"achievement_clutch_finish_detail" = "Mit wenigen Stunden Puffer, aber noch rechtzeitig abgegeben.";
"achievement_early_bird_title" = "Frühstarter";
"achievement_early_bird_detail" = "Im Schnitt beendest du deine Check-ins mit viel Zeit übrig.";
"achievements_title" = "Erfolge";
"achievements_view_all" = "Alle Erfolge anzeigen";
"achievements_nav_title" = "Erfolge";
"achievements_progress_title" = "Fortschritt";
"achievements_unlocked_title" = "Freigeschaltet";
"achievements_unlocked_empty" = "Schließe Check-ins ab, um Erfolge freizuschalten.";
"achievements_locked_title" = "Gesperrt";
"achievements_locked_empty" = "Alle Erfolge freigeschaltet. Gute Arbeit.";
"rating_accessibility" = "Bewertung %d von 5";
"achievements_unlocked_count" = "%d von %d freigeschaltet";
"last_check_in" = "Letzter Check-in: %@";
"next_check_in" = "Nächster Check-in: %@";
"on_time_rate" = "%@ pünktlich";
"on_time_count" = "%d/%d pünktlich";
"tightest_finish" = "Knappster Abschluss: %@ vor der Frist.";
"date_today" = "Heute";
"date_yesterday" = "Gestern";
"date_never" = "Nie";
"calendar_event_title" = "%@: Monatlicher Check-in";
"calendar_event_notes" = "Öffne %@ und erledige deinen monatlichen Check-in.";
"checkin_enjoying_dialog_title" = "Wie sehr gefällt dir Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Glückwunsch zu deinem neuen Erfolg. Dein Feedback hilft uns, besser zu werden.";
"not_now" = "Nicht jetzt";
"rating_1_star" = "1 Stern";
"rating_n_stars" = "%d Sterne";
"app_store_review_title" = "Möchtest du eine Bewertung im App Store hinterlassen?";
"app_store_review_message" = "Danke für die 5 Sterne. Das hilft anderen Investoren sehr, die App zu entdecken.";
"write_review" = "Bewertung schreiben";
"save_1_snapshot" = "1 Snapshot speichern";
"save_n_snapshots" = "%d Snapshots speichern";
"checkin_update_month" = "%@ aktualisieren";
"checkin_start_new" = "Starten";
"Home" = "Start";
"Sources" = "Quellen";
"Charts" = "Charts";
"Settings" = "Einstellungen";
"Journal" = "Journal";
"Search monthly notes" = "Monatliche Notizen suchen";
"Monthly Check-ins" = "Monatliche Check-ins";
"No monthly notes yet." = "Noch keine monatlichen Notizen.";
"No matching notes." = "Keine passenden Notizen.";
"Jump to month" = "Zum Monat springen";
"Today" = "Heute";
"Mood not set" = "Stimmung nicht gesetzt";
"No rating" = "Keine Bewertung";
"No note yet." = "Noch keine Notiz.";
"Monthly Note" = "Monatliche Notiz";
"Open Full Note" = "Vollständige Notiz öffnen";
"Duplicate Previous" = "Vorherigen duplizieren";
"Save" = "Speichern";
"Monthly Check-in" = "Monatlicher Check-in";
"This Month" = "Diesen Monat";
"No check-in yet this month" = "Diesen Monat noch kein Check-in";
"Start your first check-in anytime." = "Starte deinen ersten Check-in jederzeit.";
"Mark Check-in Complete" = "Check-in als abgeschlossen markieren";
"Editing stays open. New check-ins unlock after 70% of the month." = "Die Bearbeitung bleibt offen. Neue Check-ins werden nach 70 % des Monats freigeschaltet.";
"Momentum & Streaks" = "Momentum & Serien";
"Log a check-in to start a streak" = "Erfasse einen Check-in, um eine Serie zu starten";
"Streak" = "Serie";
"On-time in a row" = "Pünktlich in Folge";
"Best" = "Best";
"Personal best" = "Persönlicher Bestwert";
"Avg early" = "Ø Puffer";
"vs deadline" = "vs. Frist";
"On-time score" = "Pünktlichkeitsscore";
"Achievements" = "Erfolge";
"View all achievements" = "Alle Erfolge anzeigen";
"Monthly Pulse" = "Monatlicher Puls";
"Optional" = "Optional";
"Rate this month" = "Diesen Monat bewerten";
"How did it feel?" = "Wie hat es sich angefühlt?";
"Monthly Summary" = "Monatsübersicht";
"Starting" = "Start";
"Ending" = "Ende";
"Contributions" = "Beiträge";
"Net Performance" = "Nettoperformance";
"Update Sources" = "Quellen aktualisieren";
"Add sources to start your monthly check-in." = "Füge Quellen hinzu, um deinen monatlichen Check-in zu starten.";
"Updated this cycle" = "In diesem Zyklus aktualisiert";
"Needs update" = "Benötigt Update";
"Snapshot Notes" = "Snapshot-Notizen";
"No snapshot notes for this month." = "Keine Snapshot-Notizen in diesem Monat.";
"Source" = "Quelle";
"Your full portfolio,\nfully clear" = "Dein komplettes Portfolio,\nvöllig klar";
"One payment. Every feature. Forever." = "Eine Zahlung. Jede Funktion. Für immer.";
"Get Full Access" = "Vollen Zugriff erhalten";
"Restore Purchases" = "Käufe wiederherstellen";
"Payment charged to your Apple ID account." = "Die Zahlung wird deinem Apple-ID-Konto belastet.";
"Terms" = "Bedingungen";
"Privacy" = "Datenschutz";
"· one-time · Family Sharing" = "· einmalig · Familienfreigabe";
"Full access, one payment" = "Voller Zugriff, eine Zahlung";
"Unlimited sources, advanced charts & more" = "Unbegrenzte Quellen, erweiterte Charts und mehr";
"See full access" = "Vollen Zugriff ansehen";
"Batch Update" = "Sammelupdate";
"Current value" = "Aktueller Wert";
"Contribution this period (optional)" = "Beitrag in diesem Zeitraum (optional)";
"Include Contribution" = "Beitrag einbeziehen";
"New capital added" = "Neues Kapital hinzugefügt";
"Contribution (Optional)" = "Beitrag (optional)";
"Track new capital added to separate it from investment growth." = "Erfasse neues Kapital, um es vom Investmentwachstum zu trennen.";
"Monthly Highlights" = "Monatliche Highlights";
"Best Performer" = "Bester Performer";
"Worst Performer" = "Schlechtester Performer";
"Best Contributor" = "Größter Beitrag";
"Update Check-in" = "Check-in aktualisieren";
"Completed %@" = "Abgeschlossen %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Wird geprüft…";
"Clear Filters" = "Filter löschen";
"Force Upload to iCloud" = "Zu iCloud hochladen";
"No export yet" = "Noch nicht exportiert";
"Not synced yet" = "Noch nicht synchronisiert";
"Refresh" = "Aktualisieren";
"Search sources" = "Quellen suchen";
"Syncing with iCloud..." = "Mit iCloud synchronisieren...";
"Uploading..." = "Wird hochgeladen...";
"Verify iCloud Setup" = "iCloud-Einrichtung prüfen";
"add_source_name_footer" = "Eine Quelle ist jede Investition, die du verfolgen möchtest: Aktien, ETFs, Sparkonten, Immobilien, Krypto und mehr.";
"add_source_name_placeholder" = "z.B. MSCI World ETF, Sparkonto, Wohnung...";
"categories_empty" = "Noch keine Kategorien.";
"category_has_sources_warning" = "Eine Kategorie mit Quellen kann nicht gelöscht werden. Entferne oder weise alle Quellen zuerst um.";
"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";
"contributions_vs_returns_title" = "Investiert vs. Rendite";
"csv_enter_value" = "Wert eingeben";
"csv_field_category" = "Kategorie";
"csv_field_contribution" = "Einlage";
"csv_field_date" = "Datum";
"csv_field_notes" = "Notizen";
"csv_field_source" = "Quellenname (erforderlich)";
"csv_field_value" = "Wert (erforderlich)";
"csv_mapping_subtitle" = "Ordne deine CSV-Spalten den Portfolio Journal-Feldern zu";
"csv_no_column" = "— Nicht zugeordnet —";
"csv_no_date_hint" = "Alle Zeilen werden als Snapshot für heute importiert.";
"csv_optional_section" = "Optionale Felder";
"csv_preview_section" = "CSV-Vorschau";
"csv_required_section" = "Pflichtfelder";
"csv_use_today" = "Heutiges Datum verwenden";
"goal_achieved_notification_body" = "Du hast dein Ziel erreicht: %@";
"goal_achieved_notification_title" = "Ziel erreicht! 🎉";
"goal_archive" = "Archivieren";
"goal_delete_confirm" = "Löschen";
"goal_delete_message" = "Dieses Ziel wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.";
"goal_delete_title" = "Ziel löschen";
"goal_unarchive" = "Wiederherstellen";
"goals_all_active_achieved" = "Alle aktiven Ziele wurden erreicht.";
"goals_empty_archived" = "Keine archivierten Ziele.";
"goals_filter_active" = "Aktiv";
"goals_filter_all" = "Alle";
"goals_filter_archived" = "Archiviert";
"icloud_check_description" = "Wenn du Daten auf einem anderen Gerät hast, aktiviere iCloud, um sie hier wiederherzustellen.";
"icloud_check_title" = "Nutzt du Portfolio Journal bereits?";
"icloud_enabled_description" = "Schließe die App und öffne sie erneut. Deine Daten werden automatisch von iCloud geladen.";
"icloud_enabled_title" = "iCloud aktiviert";
"monthly_checkin_notification_body" = "Trage die Werte dieses Monats ein und verfolge das Wachstum deines Portfolios.";
"monthly_checkin_notification_title" = "Zeit für dein monatliches Update";
"onboarding_add_first_source" = "Meine erste Investition hinzufügen";
"onboarding_import_data" = "Vorhandene Daten importieren";
"onboarding_quickstart_subtitle" = "Füge deine erste Investitionsquelle hinzu, um dein Portfolio zu verfolgen.";
"onboarding_quickstart_title" = "Fast fertig";
"reengagement_body" = "Ein paar Minuten reichen, um deine Investitionen im Blick zu behalten.";
"reengagement_goal_title" = "Du bist auf Kurs 📈";
"reengagement_goal_body" = "In diesem Tempo erreichst du \"%@\" bis %@. Sieh, wie nah du bist.";
"reengagement_yoy_title" = "Sieh dir dieses Wachstum an 👀";
"reengagement_yoy_body" = "Du liegst im letzten Jahr %@ im Plus. Sieh dir den Trend an.";
"reengagement_month_title" = "Guter Monat 📈";
"reengagement_month_body" = "Dein Portfolio ist diesen Monat um %@ gewachsen. Wirf einen Blick darauf.";
"reengagement_milestone_title" = "Fast geschafft 🎯";
"reengagement_milestone_body" = "Du bist fast bei %@. Wie nah bist du heute?";
"reengagement_streak_title" = "Halte die Serie am Leben 🔥";
"reengagement_streak_body" = "Du hast %d Monate in Folge erfasst. Hör jetzt nicht auf.";
"reengagement_neutral_title" = "Gibt's was Neues? 👀";
"reengagement_neutral_body" = "In deinem Portfolio hat sich etwas getan. Sieh, was sich geändert hat.";
"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";
"sources_filter_all" = "Alle";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Deine monatliche Portfolio-Zusammenfassung";
"monthly_summary_notification_body" = "Sieh nach, wie deine Investitionen diesen Monat abgeschnitten haben.";
"streak_badge" = "%d-Monats-Serie";
"goals_empty_add_cta" = "Mein erstes Ziel hinzufügen";
"journal_empty_title" = "Noch keine Einträge";
"journal_empty_body" = "Snapshot-Aktualisierungen erstellen automatisch monatliche Einträge. Füge deine erste Quelle hinzu, um zu beginnen.";
"quick_update_title" = "Schnellaktualisierung";
"quick_update_section_header" = "Aktuelle Werte eingeben";
"quick_update_section_footer" = "Es werden nur Quellen aktualisiert, für die ein Wert eingegeben wurde.";
"quick_update_placeholder" = "Neuer Wert";
"quick_update_save" = "Alle speichern";
"quick_update_no_sources" = "Keine Quellen";
"quick_update_no_sources_body" = "Füge zuerst Investitionsquellen hinzu, um die Schnellaktualisierung zu nutzen.";
"update_available_title" = "Update verfügbar";
"update_available_body" = "Version %@ ist im App Store verfügbar.";
"whats_new_title" = "Neuheiten in 1.4";
"whats_new_subtitle" = "Verbesserungen, die dir helfen, auf Kurs zu bleiben.";
"whats_new_quick_update_title" = "Schnelles Portfolio-Update";
"whats_new_quick_update_body" = "Aktualisiere alle deine Quellen mit einem Tippen auf einem einzigen Bildschirm.";
"whats_new_streak_title" = "Update-Serie";
"whats_new_streak_body" = "Verfolge, wie viele Monate du dein Portfolio in Folge aktuell gehalten hast.";
"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";
"insight_allocation_title" = "Allokationsabweichung";
"insight_allocation_over_value" = "%1$@ liegt bei %2$.0f%% — über deinem Ziel von %3$.0f%%";
"insight_allocation_under_value" = "%1$@ liegt bei %2$.0f%% — unter deinem Ziel von %3$.0f%%";
"insight_allocation_detail" = "Zum Plan zurück ausbalancieren";
"insight_concentration_title" = "Klumpenrisiko";
"insight_concentration_value" = "%1$@ macht %2$.0f%% deines Portfolios aus";
"insight_concentration_detail" = "Erwäge eine breitere Streuung";
"insight_best_performer_title" = "Bester Wert";
"insight_worst_performer_title" = "Braucht Aufmerksamkeit";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversifikation";
"insight_diversification_single_value" = "Alles in einer Quelle — erwäge eine weitere";
"insight_diversification_good_value" = "Auf %d Kategorien verteilt";
"goal_projection_projected" = "Prognose: %@";
"goal_projection_add_contributions" = "Beiträge hinzufügen, um es zu erreichen";
"goal_chip_ahead" = "Voraus";
"goal_chip_on_track" = "Im Plan";
"goal_chip_behind" = "Im Rückstand";
"goal_chip_at_this_pace" = "In diesem Tempo…";
"notification_milestone_title" = "Portfolio-Meilenstein! 🎉";
"notification_milestone_body" = "Dein Portfolio hat gerade %@ überschritten. Herzlichen Glückwunsch!";
"streak_protection_notification_title" = "Brich deine Serie nicht 🔥";
"streak_protection_notification_body" = "Dein monatlicher Check-in steht noch aus. 2 Minuten genügen, um deine Serie zu halten.";
"paywall_benefit_accounts_title" = "Mehrere Konten";
"paywall_benefit_accounts_subtitle" = "Getrennte Portfolios für Familie oder Business";
"paywall_benefit_family_title" = "Familienfreigabe";
"paywall_benefit_family_subtitle" = "Ein Kauf, bis zu 5 Familienmitglieder";
"paywall_plan_annual" = "Jährlich";
"paywall_plan_annual_per_year" = "%@ / Jahr";
"paywall_plan_lifetime" = "Lebenslang";
"paywall_plan_lifetime_badge" = "BESTER WERT";
"paywall_trial_days" = "Tage";
"paywall_trial_weeks" = "Wochen";
"paywall_trial_months" = "Monate";
"paywall_trial_years" = "Jahre";
"paywall_trial_format" = "%d %@ gratis";
"chart_history_locked" = "%d weitere Monate Verlauf — mit Premium freischalten";
"chart_group_overview" = "Überblick";
"chart_group_analyze" = "Analyse";
"chart_group_risk" = "Risiko";
"chart_group_forecast" = "Prognose";
"kpi_total_value" = "Gesamtwert";
"kpi_period_return" = "Zeitraum-Rendite";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "Volatilität";
"kpi_max_drawdown" = "Max. Drawdown";
"chart_zoom_in" = "Vergrößern";
"chart_zoom_out" = "Verkleinern";
"chart_zoom_reset" = "Zoom zurücksetzen";
"quick_update_paste_suggestion" = "%@ in %@ einfügen";
"quick_update_next_badge" = "NÄCHSTE";
// App Intents (1.4.2)
"intent_logged_dialog" = "%1$@ für %2$@ gespeichert.";
"intent_amount_invalid" = "Der Betrag muss größer als null sein. Wie viel?";
"Log Portfolio Value" = "Portfoliowert erfassen";
"Record a value for one of your sources without opening the app." = "Erfasse einen Wert für eine deiner Quellen, ohne die App zu öffnen.";
"Amount" = "Betrag";
"How much?" = "Wie viel?";
"Log Value" = "Wert erfassen";
"Update Portfolio" = "Portfolio aktualisieren";
"Check Portfolio" = "Portfolio ansehen";
// Chart share (1.4.2)
"chart_share_button" = "Diagramm teilen";
"chart_share_text" = "Mein %@-Diagramm — erstellt mit Portfolio Journal";
"chart_share_tagline" = "Privates, ruhiges Investment-Tracking für iPhone & iPad.";
"chart_share_scan" = "Zum Laden scannen";
"chart_locked_title" = "%@ ist ein Premium-Diagramm";
"chart_locked_cta" = "Premium freischalten";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in %@ erledigt";
"checkin_done_next" = "Nächster: %@";
"checkin_next_due" = "Nächster Check-in: %@";
"checkin_days_left" = "noch %d Tage";
"checkin_due_today" = "Heute fällig";
"checkin_overdue" = "Überfällig";
"dash_chart_yoy" = "Jahr für Jahr";
"home_chart_locked_sub" = "Mit Premium freischalten, um dieses Diagramm zu sehen.";
"checkin_sources_progress" = "%1$d von %2$d Quellen aktualisiert";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Letzter Monat";
"guided_paste_amount" = "%@ einfügen";
"guided_next" = "Weiter";
"guided_same_as_last" = "Wie letzten Monat";
"guided_skip" = "Überspringen";
"guided_reflection_title" = "Wie fühlt sich dieser Monat an?";
"guided_reflection_subtitle" = "Eine kurze Notiz für dein zukünftiges Ich.";
"guided_note_placeholder" = "Eine ehrliche Zeile zu diesem Monat…";
"guided_finish" = "Check-in abschließen";
"guided_done_title" = "Check-in %@ erledigt";
"guided_streak" = "%d Monate in Folge";
"guided_share_month" = "Diesen Monat teilen";
"guided_done_cta" = "Fertig";
"journal_no_note" = "Noch keine Notiz.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "Ein ruhigeres, klareres Design";
"whats_new_150_redesign_body" = "Neu gestaltetes Home mit klarem Fokus, deinem Check-in im Mittelpunkt und weniger Ablenkung.";
"whats_new_150_guided_title" = "Geführter Monats-Check-in";
"whats_new_150_guided_body" = "Eine Quelle pro Schritt, Live-Deltas, kurze Reflexion — dein 5-Minuten-Ritual, neu gedacht.";
"whats_new_150_rings_title" = "Ziel-Ringe & Journal-Timeline";
"whats_new_150_rings_body" = "Fortschrittsringe im Fitness-Stil für Ziele und eine Stimmungs-Timeline für deine Investment-Geschichte.";
"whats_new_142_ocr_title" = "Update per Screenshot";
"whats_new_142_ocr_body" = "Teile einen Screenshot deiner Bank-App — der Saldo wird auf dem Gerät erkannt, nichts verlässt dein iPhone.";
"whats_new_142_siri_title" = "Siri auf Deutsch";
"whats_new_142_siri_body" = "\"Erfasse den Saldo von Indexa\" — Werte per Sprache oder Kurzbefehle erfassen, ohne die App zu öffnen.";
"whats_new_142_charts_title" = "Diagramme, neu gebaut";
"whats_new_142_charts_body" = "Native Seitenleiste auf dem iPad, passende KPIs pro Diagramm und Teilen als schönes Bild.";
"whats_new_141_extension_title" = "Aktualisieren ohne App-Wechsel";
"whats_new_141_extension_body" = "Teile einen Wert aus deiner Bank-App direkt in Portfolio Journal mit der neuen Erweiterung.";
"whats_new_141_ipad_title" = "Besser auf dem iPad";
"whats_new_141_ipad_body" = "Neu gestaltete Diagramme für den großen Bildschirm, mit Zoom und Wertbeschriftungen.";
"whats_new_141_icloud_title" = "Mehr Sync";
"whats_new_141_icloud_body" = "Monatliche Beiträge und Allokationsziele werden jetzt über iCloud synchronisiert.";
"whats_new_title_format" = "Neu in %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Alles in Sekunden aktualisieren";
"tip_quick_update_body" = "Quick Update zeigt alle Quellen auf einem Bildschirm — Werte aus der Zwischenablage einfügen, fertig.";
"tip_ocr_title" = "Oder teile einfach einen Screenshot";
"tip_ocr_body" = "Aus deiner Bank-App: Screenshot → Teilen → Portfolio Journal. Der Saldo wird auf dem Gerät gelesen.";
"tip_siri_title" = "Probier es nächstes Mal per Sprache";
"tip_siri_body" = "Sag Siri \"Erfasse den Saldo meines Kontos in Portfolio Journal\" — ohne die App zu öffnen.";
"tip_chart_share_title" = "Teile dieses Diagramm";
"tip_chart_share_body" = "Erzeugt ein schönes Bild mit deinen Daten, bereit für X oder WhatsApp.";
"allocation_total" = "Gesamt";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud hat einige Datensätze abgelehnt. Meist wird das iCloud-Schema noch eingerichtet — löst sich oft innerhalb weniger Stunden oder nach dem nächsten App-Update.";
"icloud_hint_quota" = "Dein iCloud-Speicher ist voll. Gib Speicher frei und versuche es erneut.";
"icloud_hint_network" = "iCloud nicht erreichbar. Prüfe deine Verbindung und versuche es erneut.";
"icloud_hint_partial_generic" = "Einige Datensätze konnten diesmal nicht synchronisiert werden. Die App versucht es automatisch erneut.";
"icloud_status_error" = "iCloud-Synchronisierungsproblem";
"icloud_status_syncing" = "Synchronisierung mit iCloud";
"icloud_show_details" = "Technische Details anzeigen";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Bestes Jahr";
"yoy_worst_year" = "Schlechtestes Jahr";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "%@ einfügen";
"quick_update_scan" = "Scannen";
"quick_update_done_kbd" = "Fertig";
"quick_update_scanning" = "Bild wird gelesen…";
"quick_update_pick_amount" = "Welcher Betrag?";
"quick_update_no_amount_found" = "Kein Betrag im Bild erkannt.";
"icloud_copy_diagnostics" = "iCloud-Diagnose kopieren";
"icloud_diagnostics_copied" = "Diagnose in Zwischenablage kopiert";
"Smooth Gaps in Charts" = "Lücken in Diagrammen glätten";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Für %d Monate fehlen Daten";
"gaps_missing_count" = "%d fehlen";
"gaps_range_between" = "Zwischen %@ und %@";
"Fill in missing data" = "Fehlende Daten ergänzen";
"Missing Data" = "Fehlende Daten";
"Fill Gap" = "Lücke füllen";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Das Diagramm schätzt diese Monate. Füge echte Werte hinzu, damit dein Verlauf genau bleibt.";
"Dismiss" = "Ausblenden";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d Mon.";
"Monthly Summary" = "Monatsübersicht";
"Portfolio Value" = "Portfoliowert";
"Tracked with" = "Erfasst mit";
"Share monthly summary" = "Monatsübersicht teilen";
"since last check-in" = "seit dem letzten Check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nichts Neues zu importieren. Deine Daten sind bereits aktuell.";
"import_preview_sources" = "%d Quellen erstellen";
"import_preview_snapshots_new" = "%d Einträge erstellen";
"import_preview_snapshots_update" = "%d Einträge aktualisieren";
"import_preview_categories" = "%d Kategorien erstellen";
"import_preview_goals" = "%d Ziele erstellen";
"import_preview_journal_new" = "%d Journaleinträge erstellen";
"import_preview_journal_update" = "%d Journaleinträge aktualisieren";
"Review Import" = "Import prüfen";
"Reviewing import…" = "Import wird geprüft…";
"Import" = "Importieren";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Geschätzt";
"gaps_autofill_cta" = "%d Monate automatisch füllen";
"Add manually" = "Manuell hinzufügen";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Das automatische Füllen schätzt die fehlenden Monate zwischen zwei Snapshots (als geschätzt markiert). Oder trage die echten Werte selbst ein.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Automatisches Backup";
"Off" = "Aus";
"Daily" = "Täglich";
"Weekly" = "Wöchentlich";
"Monthly" = "Monatlich";
"Last auto backup" = "Letztes Auto-Backup";
/* 1.6.0 hide balances */
"Hide balances" = "Guthaben ausblenden";
"Show balances" = "Guthaben anzeigen";
"Balance hidden" = "Guthaben ausgeblendet";
"Reveal your balances" = "Guthaben anzeigen";
"Privacy" = "Datenschutz";
"Hide Balances" = "Guthaben ausblenden";
"Require Face ID to Reveal" = "Face ID zum Anzeigen erforderlich";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Blendet alle Beträge hinter einer Unschärfe aus. Ideal für die App in der Öffentlichkeit oder für Screenshots. Optional Face ID zum Anzeigen erforderlich.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Du hast noch keinen Portfoliowert erfasst.";
"intent_networth_dialog" = "Dein Vermögen beträgt %@.";
"intent_streak" = "Du hast eine Serie von %d Monaten.";
"intent_streak_with_date" = "Du hast eine Serie von %d Monaten. Dein letzter Check-in war am %@.";
"intent_streak_none" = "Du hast noch keine aktive Serie. Erfasse einen Wert, um zu starten.";
// Charts workable (1.6.0)
"chart_range_button" = "Benutzerdefinierter Zeitraum";
"chart_range_from" = "Von";
"chart_range_to" = "Bis";
"chart_filtered_prefix" = "Gefiltert: %@";
"chart_overlay_contributions" = "vs. Einzahlungen";
"chart_insight_best_month" = "Bester Monat im Zeitraum: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Teile deinen Fortschritt";
"share_percent_only" = "Nur Prozente";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Teilen";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Gesamtrendite";
"share_metric_yoy" = "Dieses Jahr";
"share_metric_since_inception" = "Seit Beginn";
"share_cta_headline" = "Verfolge deine — kostenlos";
"share_cta_sub" = "Scannen für Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Stand %@";
"share_theme_dark" = "Dunkel";
"share_theme_light" = "Hell";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Heutiger Wert";
"add_source_value_footer" = "Wird als dein erster Check-in gespeichert dein Fortschritt beginnt heute.";
"quick_update_add_first_source" = "Erste Quelle hinzufügen";
@@ -112,6 +112,16 @@
"feature_no_ads" = "No Ads";
"feature_no_ads_desc" = "Ad-free experience forever";
// Paywall benefits (1.2.0)
"paywall_benefit_history_title" = "Your complete history";
"paywall_benefit_history_subtitle" = "Every snapshot, contribution, and gain since day one";
"paywall_benefit_charts_title" = "Charts that reveal patterns";
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance — all in one place";
"paywall_benefit_forecasts_title" = "12-month forecasts";
"paywall_benefit_forecasts_subtitle" = "See where your portfolio is likely heading";
"paywall_benefit_noads_title" = "No ads, ever";
"paywall_benefit_noads_subtitle" = "Clean, focused experience with no distractions";
// MARK: - Settings
"settings_title" = "Settings";
"subscription" = "Subscription";
@@ -177,6 +187,23 @@
"onboarding_sync_desc" = "Your data syncs automatically via iCloud across all your Apple devices.";
"get_started" = "Get Started";
// Onboarding pages (1.2.0)
"onboarding_clarity_title" = "Know exactly where you stand";
"onboarding_clarity_desc" = "See your total wealth, real returns, and allocation — always up to date.";
"onboarding_habit_title" = "5 minutes a month is enough";
"onboarding_habit_desc" = "Log your values once a month. Portfolio Journal handles the math and shows your progress.";
"onboarding_calm_title" = "Ignore the noise. Track the trend.";
"onboarding_calm_desc" = "Daily swings don't tell the real story. Your growth over months and years does.";
"onboarding_goals_title" = "Reach your financial goals";
"onboarding_goals_desc" = "Set targets, track milestones, and see exactly how far you've come.";
// iCloud check before onboarding (1.2.1)
"icloud_check_title" = "Already use Portfolio Journal?";
"icloud_check_description" = "If you have data on another device, enable iCloud to restore it here.";
"icloud_enabled_title" = "iCloud Enabled";
"icloud_enabled_description" = "Close the app and reopen it. Your data will load automatically from iCloud.";
"checking_icloud" = "Checking iCloud...";
// MARK: - Errors
"error_generic" = "An error occurred. Please try again.";
"error_no_purchases" = "No purchases found to restore";
@@ -235,3 +262,419 @@
"date_never" = "Never";
"calendar_event_title" = "%@: Monthly Check-in";
"calendar_event_notes" = "Open %@ and complete your monthly check-in.";
// MARK: - Satisfaction & Review Dialogs
"checkin_enjoying_dialog_title" = "How much are you enjoying Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Congrats on your new achievement! Your feedback helps us improve.";
"not_now" = "Not Now";
"rating_1_star" = "1 Star";
"rating_n_stars" = "%d Stars";
"app_store_review_title" = "Would you like to leave an App Store review?";
"app_store_review_message" = "Thanks for the 5 stars. It really helps other investors discover the app.";
"write_review" = "Write a Review";
// MARK: - Batch Update Actions
"save_1_snapshot" = "Save 1 Snapshot";
"save_n_snapshots" = "Save %d Snapshots";
// MARK: - Monthly Check-in Card
"checkin_update_month" = "Update %@";
"checkin_start_new" = "Start";
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
"reengagement_title" = "Your portfolio is waiting";
"reengagement_body" = "A few minutes is all it takes to stay on top of your investments.";
"reengagement_goal_title" = "You're on track 📈";
"reengagement_goal_body" = "At this pace you'll reach \"%@\" by %@. See how close you are.";
"reengagement_yoy_title" = "Look at that growth 👀";
"reengagement_yoy_body" = "You're up %@ over the last year. Check the trend.";
"reengagement_month_title" = "Nice month 📈";
"reengagement_month_body" = "Your portfolio grew %@ this month. Take a look.";
"reengagement_milestone_title" = "So close 🎯";
"reengagement_milestone_body" = "You're almost at %@. How close are you today?";
"reengagement_streak_title" = "Keep the streak alive 🔥";
"reengagement_streak_body" = "You've logged %d months in a row. Don't stop now.";
"reengagement_neutral_title" = "Anything new? 👀";
"reengagement_neutral_body" = "There's been movement in your portfolio. See what changed.";
"monthly_checkin_notification_title" = "Time for your monthly update";
"monthly_checkin_notification_body" = "Log this month's values and track your portfolio growth.";
// MARK: - Onboarding QuickStart (1.3.0)
"onboarding_quickstart_title" = "Almost there";
"onboarding_quickstart_subtitle" = "Add your first investment source to start tracking your portfolio.";
"onboarding_add_first_source" = "Add My First Investment";
"onboarding_import_data" = "Import Existing Data";
// MARK: - iCloud Sync Status (1.3.1)
"Force Upload to iCloud" = "Force Upload to iCloud";
"Uploading..." = "Uploading...";
"No export yet" = "No export yet";
"Syncing with iCloud..." = "Syncing with iCloud...";
"Not synced yet" = "Not synced yet";
"Refresh" = "Refresh";
// MARK: - iCloud Diagnostics (1.3.1)
"Verify iCloud Setup" = "Verify iCloud Setup";
"Checking…" = "Checking…";
"context.hasChanges: YES ✓" = "context.hasChanges: YES ✓";
"context.hasChanges: NO ✗ — objects may not have been queued" = "context.hasChanges: NO ✗ — objects may not have been queued";
// MARK: - Sources Filter & Search (1.3.1)
"sources_filter_all" = "All";
"Search sources" = "Search sources";
"Clear Filters" = "Clear Filters";
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "e.g. MSCI World ETF, ING Savings, Apartment...";
"add_source_name_footer" = "A source is any investment you want to track: stocks, ETFs, savings accounts, real estate, crypto, and more.";
// MARK: - Goals Archive (1.3.2)
"goals_filter_active" = "Active";
"goals_filter_archived" = "Archived";
"goals_filter_all" = "All";
"goal_archive" = "Archive";
"goal_unarchive" = "Unarchive";
"goals_empty_archived" = "No archived goals.";
"goals_all_active_achieved" = "All active goals are achieved.";
// MARK: - Category Management (1.3.2)
"category_name_placeholder" = "e.g. Emergency Fund";
"category_has_sources_warning" = "Cannot delete a category that has sources. Remove or reassign all sources first.";
"categories_empty" = "No categories yet.";
// MARK: - CSV Column Mapping (1.3.2)
"csv_mapping_subtitle" = "Match your CSV columns to Portfolio Journal fields";
"csv_field_source" = "Source Name (required)";
"csv_field_value" = "Value (required)";
"csv_field_date" = "Date";
"csv_field_category" = "Category";
"csv_field_contribution" = "Contribution";
"csv_field_notes" = "Notes";
"csv_no_column" = "— Not mapped —";
"csv_enter_value" = "Enter value";
"csv_use_today" = "Use today's date";
"csv_no_date_hint" = "All rows will be imported as a snapshot for today.";
"csv_preview_section" = "CSV Preview";
"csv_required_section" = "Required Fields";
"csv_optional_section" = "Optional Fields";
// MARK: - 1.3.2 Features
"goal_achieved_notification_title" = "Goal achieved! 🎉";
"goal_achieved_notification_body" = "You reached your goal: %@";
"snapshot_duplicate_title" = "Snapshot Already Exists";
"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)
"goal_delete_title" = "Delete Goal";
"goal_delete_message" = "This goal will be permanently deleted. This action cannot be undone.";
"goal_delete_confirm" = "Delete";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Your Monthly Portfolio Summary";
"monthly_summary_notification_body" = "Check how your investments performed this month.";
"streak_badge" = "%d-month streak";
"goals_empty_add_cta" = "Add My First Goal";
"journal_empty_title" = "No check-ins yet";
"journal_empty_body" = "Snapshot updates automatically create monthly entries. Add your first source to get started.";
"quick_update_title" = "Quick Update";
"quick_update_section_header" = "Enter current values";
"quick_update_section_footer" = "Only sources with a value entered will be updated.";
"quick_update_placeholder" = "New value";
"quick_update_save" = "Save All";
"quick_update_no_sources" = "No sources";
"quick_update_no_sources_body" = "Add investment sources first to use Quick Update.";
"update_available_title" = "Update Available";
"update_available_body" = "Version %@ is ready on the App Store.";
"whats_new_title" = "What's New in 1.4";
"whats_new_subtitle" = "Improvements to help you stay on track.";
"whats_new_quick_update_title" = "Quick Portfolio Update";
"whats_new_quick_update_body" = "Update all your sources from a single screen with one tap.";
"whats_new_streak_title" = "Update Streak";
"whats_new_streak_body" = "Track how many months in a row you've kept your portfolio up to date.";
"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";
"insight_allocation_title" = "Allocation drift";
"insight_allocation_over_value" = "%1$@ is %2$.0f%% — above your %3$.0f%% target";
"insight_allocation_under_value" = "%1$@ is %2$.0f%% — below your %3$.0f%% target";
"insight_allocation_detail" = "Rebalance to stay on plan";
"insight_concentration_title" = "Concentration risk";
"insight_concentration_value" = "%1$@ is %2$.0f%% of your portfolio";
"insight_concentration_detail" = "Consider spreading it out";
"insight_best_performer_title" = "Best performer";
"insight_worst_performer_title" = "Needs attention";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversification";
"insight_diversification_single_value" = "All in one source — consider adding another";
"insight_diversification_good_value" = "Spread across %d categories";
"goal_projection_projected" = "Projected: %@";
"goal_projection_add_contributions" = "Add contributions to reach this";
"goal_chip_ahead" = "Ahead of schedule";
"goal_chip_on_track" = "On track";
"goal_chip_behind" = "Behind";
"goal_chip_at_this_pace" = "At this pace…";
"notification_milestone_title" = "Portfolio Milestone! 🎉";
"notification_milestone_body" = "Your portfolio just passed %@. Congrats!";
"streak_protection_notification_title" = "Don't break your streak 🔥";
"streak_protection_notification_body" = "Your monthly check-in is still pending. It takes 2 minutes to keep your streak alive.";
"paywall_benefit_accounts_title" = "Multiple Accounts";
"paywall_benefit_accounts_subtitle" = "Separate portfolios for family or business";
"paywall_benefit_family_title" = "Family Sharing";
"paywall_benefit_family_subtitle" = "One purchase, up to 5 family members";
"paywall_plan_annual" = "Annual";
"paywall_plan_annual_per_year" = "%@ / year";
"paywall_plan_lifetime" = "Lifetime";
"paywall_plan_lifetime_badge" = "BEST VALUE";
"paywall_trial_days" = "days";
"paywall_trial_weeks" = "weeks";
"paywall_trial_months" = "months";
"paywall_trial_years" = "years";
"paywall_trial_format" = "%d %@ free";
"chart_history_locked" = "%d more months of history — unlock with Premium";
"chart_group_overview" = "Overview";
"chart_group_analyze" = "Analyze";
"chart_group_risk" = "Risk";
"chart_group_forecast" = "Forecast";
"kpi_total_value" = "Total Value";
"kpi_period_return" = "Period Return";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "Volatility";
"kpi_max_drawdown" = "Max Drawdown";
"chart_zoom_in" = "Zoom in";
"chart_zoom_out" = "Zoom out";
"chart_zoom_reset" = "Reset zoom";
"quick_update_paste_suggestion" = "Paste %@ into %@";
"quick_update_next_badge" = "NEXT";
// App Intents (1.4.2)
"intent_logged_dialog" = "Saved %1$@ for %2$@.";
"intent_amount_invalid" = "The amount must be greater than zero. How much?";
// Chart share (1.4.2)
"chart_share_button" = "Share chart";
"chart_share_text" = "My %@ chart — tracked with Portfolio Journal";
"chart_share_tagline" = "Private, calm investment tracking for iPhone & iPad.";
"chart_share_scan" = "Scan to download";
"chart_locked_title" = "%@ is a Premium chart";
"chart_locked_cta" = "Unlock Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "%@ check-in done";
"checkin_done_next" = "Next one: %@";
"checkin_next_due" = "Next check-in: %@";
"checkin_days_left" = "%d days left";
"checkin_due_today" = "Due today";
"checkin_overdue" = "Overdue";
"dash_chart_yoy" = "Year vs Year";
"home_chart_locked_sub" = "Unlock with Premium to explore this chart.";
"checkin_sources_progress" = "%1$d of %2$d sources updated";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Last month";
"guided_paste_amount" = "Paste %@";
"guided_next" = "Next";
"guided_same_as_last" = "Same as last month";
"guided_skip" = "Skip";
"guided_reflection_title" = "How does this month feel?";
"guided_reflection_subtitle" = "A quick note for your future self.";
"guided_note_placeholder" = "One honest line about this month…";
"guided_finish" = "Finish check-in";
"guided_done_title" = "%@ check-in done";
"guided_streak" = "%d months in a row";
"guided_share_month" = "Share this month";
"guided_done_cta" = "Done";
"journal_no_note" = "No note yet.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "A calmer, cleaner look";
"whats_new_150_redesign_body" = "Redesigned Home with a clear hero, your check-in front and center, and less noise everywhere.";
"whats_new_150_guided_title" = "Guided monthly check-in";
"whats_new_150_guided_body" = "One source per step, live deltas, a quick reflection — your 5-minute ritual, reimagined.";
"whats_new_150_rings_title" = "Goal rings & journal timeline";
"whats_new_150_rings_body" = "Fitness-style progress rings for goals and a mood timeline for your investing story.";
"whats_new_142_ocr_title" = "Update from a screenshot";
"whats_new_142_ocr_body" = "Share a screenshot of your bank app — the balance is recognized on-device, nothing leaves your phone.";
"whats_new_142_siri_title" = "Siri in your language";
"whats_new_142_siri_body" = "\"Log my Indexa balance\" — record values by voice or from Shortcuts, no app needed.";
"whats_new_142_charts_title" = "Charts, rebuilt";
"whats_new_142_charts_body" = "Native sidebar on iPad, smart KPIs per chart, and a share button with beautiful branded images.";
"whats_new_141_extension_title" = "Update without switching apps";
"whats_new_141_extension_body" = "Share a value from your bank app straight into Portfolio Journal with the new share extension.";
"whats_new_141_ipad_title" = "Better on iPad";
"whats_new_141_ipad_body" = "Redesigned charts for the big screen, with pinch-to-zoom and value labels.";
"whats_new_141_icloud_title" = "More in sync";
"whats_new_141_icloud_body" = "Monthly contributions and allocation targets now sync across your devices via iCloud.";
"whats_new_title_format" = "What's New in %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Update everything in seconds";
"tip_quick_update_body" = "Quick Update lists all your sources in one screen — paste values from your clipboard and go.";
"tip_ocr_title" = "Or just share a screenshot";
"tip_ocr_body" = "From your bank app: screenshot → Share → Portfolio Journal. The balance is read on-device.";
"tip_siri_title" = "Next time, try your voice";
"tip_siri_body" = "Say \"Log my account balance in Portfolio Journal\" to Siri — no need to open the app.";
"tip_chart_share_title" = "Share this chart";
"tip_chart_share_body" = "Renders a beautiful image with your data, ready for X or WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "Some records were rejected by iCloud. This usually means the app's iCloud schema hasn't finished setting up — it often resolves within a few hours of first use, or after the next app update.";
"icloud_hint_quota" = "Your iCloud storage is full. Free up space in iCloud settings and try again.";
"icloud_hint_network" = "Couldn't reach iCloud. Check your connection and try again.";
"icloud_hint_partial_generic" = "Some records couldn't sync this time. The app will retry automatically.";
"icloud_status_error" = "iCloud sync problem";
"icloud_status_syncing" = "Syncing with iCloud";
"icloud_show_details" = "Show technical details";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Best year";
"yoy_worst_year" = "Worst year";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Paste %@";
"quick_update_scan" = "Scan";
"quick_update_done_kbd" = "Done";
"quick_update_scanning" = "Reading image…";
"quick_update_pick_amount" = "Which amount?";
"quick_update_no_amount_found" = "No amount recognized in that image.";
"icloud_copy_diagnostics" = "Copy iCloud diagnostics";
"icloud_diagnostics_copied" = "Diagnostics copied to clipboard";
"Smooth Gaps in Charts" = "Smooth Gaps in Charts";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "%d months are missing data";
"gaps_missing_count" = "%d missing";
"gaps_range_between" = "Between %@ and %@";
"Fill in missing data" = "Fill in missing data";
"Missing Data" = "Missing Data";
"Fill Gap" = "Fill Gap";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "The chart estimates these months. Add real snapshots to keep your history accurate.";
"Dismiss" = "Dismiss";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mo";
"Monthly Summary" = "Monthly Summary";
"Portfolio Value" = "Portfolio Value";
"Tracked with" = "Tracked with";
"Share monthly summary" = "Share monthly summary";
"since last check-in" = "since last check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nothing new to import. Your data is already up to date.";
"import_preview_sources" = "Create %d sources";
"import_preview_snapshots_new" = "Create %d snapshots";
"import_preview_snapshots_update" = "Update %d snapshots";
"import_preview_categories" = "Create %d categories";
"import_preview_goals" = "Create %d goals";
"import_preview_journal_new" = "Create %d journal entries";
"import_preview_journal_update" = "Update %d journal entries";
"Review Import" = "Review Import";
"Reviewing import…" = "Reviewing import…";
"Import" = "Import";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimated";
"gaps_autofill_cta" = "Auto-fill %d months";
"Add manually" = "Add manually";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Auto Backup";
"Off" = "Off";
"Daily" = "Daily";
"Weekly" = "Weekly";
"Monthly" = "Monthly";
"Last auto backup" = "Last auto backup";
/* 1.6.0 hide balances */
"Hide balances" = "Hide balances";
"Show balances" = "Show balances";
"Balance hidden" = "Balance hidden";
"Reveal your balances" = "Reveal your balances";
"Privacy" = "Privacy";
"Hide Balances" = "Hide Balances";
"Require Face ID to Reveal" = "Require Face ID to Reveal";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "You don't have any portfolio value recorded yet.";
"intent_networth_dialog" = "Your net worth is %@.";
"intent_streak" = "You're on a %d-month streak.";
"intent_streak_with_date" = "You're on a %d-month streak. Your last check-in was %@.";
"intent_streak_none" = "You don't have an active streak yet. Log a value to start one.";
// Charts workable (1.6.0)
"chart_range_button" = "Custom range";
"chart_range_from" = "From";
"chart_range_to" = "To";
"chart_filtered_prefix" = "Filtered: %@";
"chart_overlay_contributions" = "vs Contributions";
"chart_insight_best_month" = "Best month in range: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Share your progress";
"share_percent_only" = "Only percentages";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Share";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Total return";
"share_metric_yoy" = "This year";
"share_metric_since_inception" = "Since inception";
"share_cta_headline" = "Track yours — free";
"share_cta_sub" = "Scan to get Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "As of %@";
"share_theme_dark" = "Dark";
"share_theme_light" = "Light";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Today's Value";
"add_source_value_footer" = "Saved as your first check-in — your progress starts today.";
"quick_update_add_first_source" = "Add Your First Source";
@@ -123,3 +123,509 @@
// MARK: - Accessibility
"rating_accessibility" = "Valoración %d de 5";
"achievements_unlocked_count" = "%d de %d desbloqueados";
// MARK: - Satisfaction & Review Dialogs
"checkin_enjoying_dialog_title" = "¿Cuánto disfrutas Portfolio Journal?";
"checkin_enjoying_dialog_message" = "¡Felicidades por tu nuevo logro! Tu opinión nos ayuda a mejorar.";
"not_now" = "Ahora no";
"rating_1_star" = "1 estrella";
"rating_n_stars" = "%d estrellas";
"app_store_review_title" = "¿Te gustaría dejar una reseña en el App Store?";
"app_store_review_message" = "Gracias por las 5 estrellas. Ayuda mucho a que otros inversores descubran la app.";
"write_review" = "Escribir reseña";
// MARK: - Paywall (1.2.0)
"Your full portfolio,\nfully clear" = "Tu cartera completa,\ntotalmente clara";
"One payment. Every feature. Forever." = "Un pago. Todas las funciones. Para siempre.";
"Get Full Access" = "Obtener acceso completo";
"Restore Purchases" = "Restaurar compras";
"Payment charged to your Apple ID account." = "El pago se cargará a tu cuenta de Apple ID.";
"Terms" = "Términos";
"Privacy" = "Privacidad";
"· one-time · Family Sharing" = "· pago único · Family Sharing";
"Full access, one payment" = "Acceso completo, un solo pago";
"Unlimited sources, advanced charts & more" = "Fuentes ilimitadas, gráficos avanzados y más";
"See full access" = "Ver acceso completo";
// Paywall benefits (1.2.0)
"paywall_benefit_history_title" = "Tu historial completo";
"paywall_benefit_history_subtitle" = "Cada snapshot, aportación y ganancia desde el primer día";
"paywall_benefit_charts_title" = "Gráficos que revelan patrones";
"paywall_benefit_charts_subtitle" = "Asignación, drawdown, rendimiento — todo en un lugar";
"paywall_benefit_forecasts_title" = "Previsiones a 12 meses";
"paywall_benefit_forecasts_subtitle" = "Ve a dónde se dirige tu cartera";
"paywall_benefit_noads_title" = "Sin anuncios, nunca";
"paywall_benefit_noads_subtitle" = "Una experiencia limpia y sin distracciones";
// MARK: - Onboarding pages (1.2.0)
"onboarding_clarity_title" = "Sabe exactamente dónde estás";
"onboarding_clarity_desc" = "Consulta tu patrimonio total, rendimientos reales y distribución — siempre actualizado.";
"onboarding_habit_title" = "5 minutos al mes son suficientes";
"onboarding_habit_desc" = "Registra tus valores una vez al mes. Portfolio Journal hace los cálculos y muestra tu progreso.";
"onboarding_calm_title" = "Ignora el ruido. Sigue la tendencia.";
"onboarding_calm_desc" = "Las variaciones diarias no cuentan la historia real. Tu crecimiento en meses y años sí.";
"onboarding_goals_title" = "Alcanza tus metas financieras";
"onboarding_goals_desc" = "Define objetivos, sigue los hitos y ve exactamente hasta dónde has llegado.";
// iCloud check antes del onboarding (1.2.1)
"icloud_check_title" = "¿Ya usas Portfolio Journal?";
"icloud_check_description" = "Si tienes datos en otro dispositivo, activa iCloud para restaurarlos aquí.";
"icloud_enabled_title" = "iCloud activado";
"icloud_enabled_description" = "Cierra la app y ábrela de nuevo. Tus datos se cargarán automáticamente desde iCloud.";
"checking_icloud" = "Comprobando iCloud...";
// Literales de botones (SwiftUI auto-lookup)
"Restore from iCloud" = "Restaurar desde iCloud";
"Start Fresh" = "Empezar desde cero";
"Got it" = "Entendido";
// MARK: - Batch Update (1.2.0)
"Batch Update" = "Actualización en bloque";
"Current value" = "Valor actual";
"Contribution this period (optional)" = "Aportación en este periodo (opcional)";
"save_1_snapshot" = "Guardar 1 snapshot";
"save_n_snapshots" = "Guardar %d snapshots";
// MARK: - Monthly Check-in Card
"checkin_update_month" = "Actualizar %@";
"checkin_start_new" = "Iniciar";
// MARK: - Contribution (1.2.0)
"Include Contribution" = "Incluir aportación";
"New capital added" = "Capital nuevo añadido";
"Contribution (Optional)" = "Aportación (opcional)";
"Track new capital added to separate it from investment growth." = "Registra el capital nuevo para separarlo del crecimiento de la inversión.";
// MARK: - Monthly Highlights (pre-existing gaps)
"Monthly Highlights" = "Aspectos destacados del mes";
"Best Performer" = "Mejor rendimiento";
"Worst Performer" = "Peor rendimiento";
"Best Contributor" = "Mayor aportador";
"Update Check-in" = "Actualizar chequeo";
"Completed %@" = "Completado %@";
// MARK: - Paywall literals (1.2.1)
"Your full portfolio,\nfully clear" = "Tu portfolio completo,\nen todo su potencial";
"One payment. Every feature. Forever." = "Un solo pago. Todas las funciones. Para siempre.";
"Get Full Access" = "Obtener acceso completo";
"Restore Purchases" = "Restaurar compras";
"Payment charged to your Apple ID account." = "El cobro se realizará a tu cuenta de Apple ID.";
// MARK: - Re-engagement & Monthly Notifications (1.2.1)
"reengagement_title" = "Tu portfolio te espera";
"reengagement_body" = "Unos minutos son suficientes para mantener el control de tus inversiones.";
"reengagement_goal_title" = "Vas por buen camino 📈";
"reengagement_goal_body" = "A este ritmo alcanzarás \"%@\" en %@. Mira lo cerca que estás.";
"reengagement_yoy_title" = "Menudo crecimiento 👀";
"reengagement_yoy_body" = "Llevas un %@ en el último año. Mira la tendencia.";
"reengagement_month_title" = "Buen mes 📈";
"reengagement_month_body" = "Tu cartera creció %@ este mes. Échale un ojo.";
"reengagement_milestone_title" = "Muy cerca 🎯";
"reengagement_milestone_body" = "Estás a punto de llegar a %@. ¿Cuánto te falta hoy?";
"reengagement_streak_title" = "No pierdas la racha 🔥";
"reengagement_streak_body" = "Llevas %d meses seguidos registrando. No pares ahora.";
"reengagement_neutral_title" = "¿Algo nuevo? 👀";
"reengagement_neutral_body" = "Ha habido movimiento en tu cartera. Mira qué ha cambiado.";
"monthly_checkin_notification_title" = "Ya puedes actualizar tu portfolio";
"monthly_checkin_notification_body" = "Registra los valores de este mes y sigue la evolución de tu cartera.";
// MARK: - Onboarding QuickStart (1.3.0)
"onboarding_quickstart_title" = "Ya casi está";
"onboarding_quickstart_subtitle" = "Añade tu primera fuente de inversión para empezar a seguir tu portfolio.";
"onboarding_add_first_source" = "Añadir mi primera inversión";
"onboarding_import_data" = "Importar datos existentes";
// MARK: - iCloud Sync Status (1.3.1)
"Force Upload to iCloud" = "Forzar subida a iCloud";
"Uploading..." = "Subiendo...";
"No export yet" = "Aún no exportado";
"Syncing with iCloud..." = "Sincronizando con iCloud...";
"Not synced yet" = "Aún no sincronizado";
"Refresh" = "Actualizar";
// MARK: - iCloud Diagnostics (1.3.1)
"Verify iCloud Setup" = "Verificar configuración iCloud";
"Checking…" = "Comprobando…";
// MARK: - Sources Filter & Search (1.3.1)
"sources_filter_all" = "Todas";
"Search sources" = "Buscar fuentes";
"Clear Filters" = "Limpiar filtros";
// MARK: - AddSourceView (1.3.0)
"add_source_name_placeholder" = "p.ej. ETF MSCI World, Cuenta ING, Piso...";
"add_source_name_footer" = "Una fuente es cualquier inversión que quieras seguir: acciones, ETFs, cuentas de ahorro, inmuebles, cripto y más.";
// MARK: - Goals Archive (1.3.2)
"goals_filter_active" = "Activos";
"goals_filter_archived" = "Archivados";
"goals_filter_all" = "Todos";
"goal_archive" = "Archivar";
"goal_unarchive" = "Restaurar";
"goals_empty_archived" = "Sin objetivos archivados.";
"goals_all_active_achieved" = "Todos los objetivos activos están logrados.";
// MARK: - Category Management (1.3.2)
"category_name_placeholder" = "p.ej. Fondo de emergencia";
"category_has_sources_warning" = "No se puede eliminar una categoría que tiene fuentes. Elimina o reasigna todas las fuentes primero.";
"categories_empty" = "Sin categorías aún.";
// MARK: - CSV Column Mapping (1.3.2)
"csv_mapping_subtitle" = "Relaciona tus columnas CSV con los campos de Portfolio Journal";
"csv_field_source" = "Nombre de fuente (obligatorio)";
"csv_field_value" = "Valor (obligatorio)";
"csv_field_date" = "Fecha";
"csv_field_category" = "Categoría";
"csv_field_contribution" = "Aportación";
"csv_field_notes" = "Notas";
"csv_no_column" = "— Sin mapear —";
"csv_enter_value" = "Valor fijo";
"csv_use_today" = "Fecha de hoy";
"csv_no_date_hint" = "Todas las filas se importarán como un snapshot de hoy.";
"csv_preview_section" = "Vista previa CSV";
"csv_required_section" = "Campos obligatorios";
"csv_optional_section" = "Campos opcionales";
// MARK: - Category Management SwiftUI literals (1.3.2)
"Categories" = "Categorías";
"Add Category" = "Añadir categoría";
"Edit Category" = "Editar categoría";
"Category Name" = "Nombre de categoría";
"Color" = "Color";
"Icon" = "Icono";
"Map Columns" = "Mapear columnas";
"Has Header Row" = "Tiene fila de encabezado";
"Required" = "Obligatorios";
"Optional" = "Opcionales";
"Preview" = "Vista previa";
"Any CSV" = "Cualquier CSV";
// MARK: - 1.3.2 Features
"goal_achieved_notification_title" = "¡Objetivo alcanzado! 🎉";
"goal_achieved_notification_body" = "Has alcanzado tu objetivo: %@";
"snapshot_duplicate_title" = "Ya existe un snapshot";
"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";
// MARK: - Goal Delete Confirmation (1.3.2)
"goal_delete_title" = "Eliminar objetivo";
"goal_delete_message" = "Este objetivo se eliminará permanentemente. Esta acción no se puede deshacer.";
"goal_delete_confirm" = "Eliminar";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Tu resumen mensual del portfolio";
"monthly_summary_notification_body" = "Comprueba cómo han rendido tus inversiones este mes.";
"streak_badge" = "Racha de %d meses";
"goals_empty_add_cta" = "Añadir mi primer objetivo";
"journal_empty_title" = "Sin registros aún";
"journal_empty_body" = "Las actualizaciones de snapshots crean entradas mensuales automáticamente. Añade tu primera fuente para empezar.";
"quick_update_title" = "Actualización rápida";
"quick_update_section_header" = "Introduce los valores actuales";
"quick_update_section_footer" = "Solo se actualizarán las fuentes con un valor introducido.";
"quick_update_placeholder" = "Nuevo valor";
"quick_update_save" = "Guardar todo";
"quick_update_no_sources" = "Sin fuentes";
"quick_update_no_sources_body" = "Añade fuentes de inversión primero para usar la actualización rápida.";
"update_available_title" = "Actualización disponible";
"update_available_body" = "La versión %@ está disponible en el App Store.";
"whats_new_title" = "Novedades en 1.4";
"whats_new_subtitle" = "Mejoras para ayudarte a mantener el rumbo.";
"whats_new_quick_update_title" = "Actualización rápida del portfolio";
"whats_new_quick_update_body" = "Actualiza todas tus fuentes desde una sola pantalla con un toque.";
"whats_new_streak_title" = "Racha de actualizaciones";
"whats_new_streak_body" = "Sigue cuántos meses consecutivos has mantenido tu portfolio al día.";
"whats_new_goals_title" = "Objetivos y diario más inteligentes";
"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";
"insight_allocation_title" = "Desvío de asignación";
"insight_allocation_over_value" = "%1$@ está al %2$.0f%% — por encima de tu objetivo del %3$.0f%%";
"insight_allocation_under_value" = "%1$@ está al %2$.0f%% — por debajo de tu objetivo del %3$.0f%%";
"insight_allocation_detail" = "Reequilibra para seguir tu plan";
"insight_concentration_title" = "Riesgo de concentración";
"insight_concentration_value" = "%1$@ es el %2$.0f%% de tu cartera";
"insight_concentration_detail" = "Plantéate diversificar";
"insight_best_performer_title" = "Mejor rendimiento";
"insight_worst_performer_title" = "Requiere atención";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificación";
"insight_diversification_single_value" = "Todo en una fuente — considera añadir otra";
"insight_diversification_good_value" = "Repartido en %d categorías";
"goal_projection_projected" = "Previsión: %@";
"goal_projection_add_contributions" = "Añade aportaciones para alcanzarlo";
"goal_chip_ahead" = "Por delante";
"goal_chip_on_track" = "En camino";
"goal_chip_behind" = "Con retraso";
"goal_chip_at_this_pace" = "A este ritmo…";
"notification_milestone_title" = "¡Hito de cartera! 🎉";
"notification_milestone_body" = "Tu cartera acaba de superar %@. ¡Enhorabuena!";
"streak_protection_notification_title" = "No rompas tu racha 🔥";
"streak_protection_notification_body" = "Aún tienes pendiente el check-in de este mes. Son 2 minutos y tu racha sigue viva.";
"paywall_benefit_accounts_title" = "Varias cuentas";
"paywall_benefit_accounts_subtitle" = "Carteras separadas para familia o negocio";
"paywall_benefit_family_title" = "En familia";
"paywall_benefit_family_subtitle" = "Una compra, hasta 5 miembros de la familia";
"paywall_plan_annual" = "Anual";
"paywall_plan_annual_per_year" = "%@ / año";
"paywall_plan_lifetime" = "Para siempre";
"paywall_plan_lifetime_badge" = "MEJOR VALOR";
"paywall_trial_days" = "días";
"paywall_trial_weeks" = "semanas";
"paywall_trial_months" = "meses";
"paywall_trial_years" = "años";
"paywall_trial_format" = "%d %@ gratis";
"chart_history_locked" = "%d meses más de historial — desbloquéalos con Premium";
"chart_group_overview" = "Resumen";
"chart_group_analyze" = "Análisis";
"chart_group_risk" = "Riesgo";
"chart_group_forecast" = "Previsión";
"kpi_total_value" = "Valor total";
"kpi_period_return" = "Retorno periodo";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "Volatilidad";
"kpi_max_drawdown" = "Caída máx.";
"chart_zoom_in" = "Acercar";
"chart_zoom_out" = "Alejar";
"chart_zoom_reset" = "Restablecer zoom";
"quick_update_paste_suggestion" = "Pegar %@ en %@";
"quick_update_next_badge" = "SIGUIENTE";
// App Intents (1.4.2)
"intent_logged_dialog" = "Guardado %1$@ para %2$@.";
"intent_amount_invalid" = "El importe debe ser mayor que cero. ¿Cuánto?";
"Log Portfolio Value" = "Registrar valor de cartera";
"Record a value for one of your sources without opening the app." = "Registra un valor para una de tus fuentes sin abrir la app.";
"Amount" = "Importe";
"How much?" = "¿Cuánto?";
"Log Value" = "Registrar valor";
"Update Portfolio" = "Actualizar cartera";
"Check Portfolio" = "Consultar cartera";
// Chart share (1.4.2)
"chart_share_button" = "Compartir gráfica";
"chart_share_text" = "Mi gráfica de %@ — hecha con Portfolio Journal";
"chart_share_tagline" = "Seguimiento de inversiones privado y tranquilo para iPhone y iPad.";
"chart_share_scan" = "Escanea para descargar";
"chart_locked_title" = "%@ es una gráfica Premium";
"chart_locked_cta" = "Desbloquear Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ completado";
"checkin_done_next" = "Próximo: %@";
"checkin_next_due" = "Próximo check-in: %@";
"checkin_days_left" = "faltan %d días";
"checkin_due_today" = "Hoy";
"checkin_overdue" = "Atrasado";
"dash_chart_yoy" = "Año vs Año";
"home_chart_locked_sub" = "Desbloquéalo con Premium para ver este gráfico.";
"checkin_sources_progress" = "%1$d de %2$d fuentes actualizadas";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mes anterior";
"guided_paste_amount" = "Pegar %@";
"guided_next" = "Siguiente";
"guided_same_as_last" = "Igual que el mes pasado";
"guided_skip" = "Omitir";
"guided_reflection_title" = "¿Cómo sientes este mes?";
"guided_reflection_subtitle" = "Una nota rápida para tu yo del futuro.";
"guided_note_placeholder" = "Una línea honesta sobre este mes…";
"guided_finish" = "Terminar check-in";
"guided_done_title" = "Check-in de %@ completado";
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartir este mes";
"guided_done_cta" = "Hecho";
"journal_no_note" = "Sin nota todavía.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versión %@";
"whats_new_150_redesign_title" = "Un look más limpio y tranquilo";
"whats_new_150_redesign_body" = "Home rediseñado con tu valor total protagonista, el check-in en el centro y menos ruido en todas partes.";
"whats_new_150_guided_title" = "Check-in mensual guiado";
"whats_new_150_guided_body" = "Una fuente por paso, deltas en vivo y una reflexión rápida — tu ritual de 5 minutos, reimaginado.";
"whats_new_150_rings_title" = "Anillos de metas y timeline";
"whats_new_150_rings_body" = "Anillos de progreso estilo Fitness para tus metas y un timeline con tu historia como inversor.";
"whats_new_142_ocr_title" = "Actualiza desde un pantallazo";
"whats_new_142_ocr_body" = "Comparte una captura de tu banco — el saldo se reconoce en el dispositivo, nada sale de tu iPhone.";
"whats_new_142_siri_title" = "Siri en tu idioma";
"whats_new_142_siri_body" = "\"Registra el saldo de Indexa\" — apunta valores por voz o desde Atajos, sin abrir la app.";
"whats_new_142_charts_title" = "Gráficas renovadas";
"whats_new_142_charts_body" = "Sidebar nativa en iPad, KPIs inteligentes por gráfica y botón de compartir con imágenes con estilo.";
"whats_new_141_extension_title" = "Actualiza sin cambiar de app";
"whats_new_141_extension_body" = "Comparte un valor desde la app de tu banco directo a Portfolio Journal con la nueva extensión.";
"whats_new_141_ipad_title" = "Mejor en iPad";
"whats_new_141_ipad_body" = "Gráficas rediseñadas para pantalla grande, con zoom y etiquetas de valor.";
"whats_new_141_icloud_title" = "Más sincronizado";
"whats_new_141_icloud_body" = "Las aportaciones mensuales y los objetivos de asignación ahora se sincronizan vía iCloud.";
"whats_new_title_format" = "Novedades de la %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Actualiza todo en segundos";
"tip_quick_update_body" = "Quick Update lista todas tus fuentes en una pantalla — pega valores del portapapeles y listo.";
"tip_ocr_title" = "O comparte un pantallazo";
"tip_ocr_body" = "Desde tu app del banco: captura → Compartir → Portfolio Journal. El saldo se lee en el dispositivo.";
"tip_siri_title" = "La próxima vez, prueba con la voz";
"tip_siri_body" = "Dile a Siri \"Registra el saldo de mi cuenta en Portfolio Journal\" — sin abrir la app.";
"tip_chart_share_title" = "Comparte esta gráfica";
"tip_chart_share_body" = "Genera una imagen bonita con tus datos, lista para X o WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud rechazó algunos registros. Suele significar que el esquema de iCloud aún se está configurando — normalmente se resuelve en unas horas de uso, o tras la próxima actualización de la app.";
"icloud_hint_quota" = "Tu almacenamiento de iCloud está lleno. Libera espacio en ajustes de iCloud e inténtalo de nuevo.";
"icloud_hint_network" = "No se pudo conectar con iCloud. Revisa tu conexión e inténtalo de nuevo.";
"icloud_hint_partial_generic" = "Algunos registros no se sincronizaron esta vez. La app lo reintentará automáticamente.";
"icloud_status_error" = "Problema de sincronización con iCloud";
"icloud_status_syncing" = "Sincronizando con iCloud";
"icloud_show_details" = "Ver detalles técnicos";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Mejor año";
"yoy_worst_year" = "Peor año";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Pegar %@";
"quick_update_scan" = "Escanear";
"quick_update_done_kbd" = "Listo";
"quick_update_scanning" = "Leyendo imagen…";
"quick_update_pick_amount" = "¿Qué importe?";
"quick_update_no_amount_found" = "No se reconoció ningún importe en esa imagen.";
"icloud_copy_diagnostics" = "Copiar diagnóstico de iCloud";
"icloud_diagnostics_copied" = "Diagnóstico copiado al portapapeles";
"Smooth Gaps in Charts" = "Suavizar huecos en gráficas";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Faltan datos de %d meses";
"gaps_missing_count" = "%d sin datos";
"gaps_range_between" = "Entre %@ y %@";
"Fill in missing data" = "Completa los datos que faltan";
"Missing Data" = "Datos faltantes";
"Fill Gap" = "Rellenar hueco";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "La gráfica estima estos meses. Añade valores reales para mantener tu historial preciso.";
"Dismiss" = "Descartar";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d m";
"Monthly Summary" = "Resumen mensual";
"Portfolio Value" = "Valor de la cartera";
"Tracked with" = "Registrado con";
"Share monthly summary" = "Compartir resumen mensual";
"since last check-in" = "desde el último chequeo";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "No hay nada nuevo que importar. Tus datos ya están actualizados.";
"import_preview_sources" = "Crear %d fuentes";
"import_preview_snapshots_new" = "Crear %d registros";
"import_preview_snapshots_update" = "Actualizar %d registros";
"import_preview_categories" = "Crear %d categorías";
"import_preview_goals" = "Crear %d objetivos";
"import_preview_journal_new" = "Crear %d entradas del diario";
"import_preview_journal_update" = "Actualizar %d entradas del diario";
"Review Import" = "Revisar importación";
"Reviewing import…" = "Revisando importación…";
"Import" = "Importar";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimado";
"gaps_autofill_cta" = "Autocompletar %d meses";
"Add manually" = "Añadir manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "El autocompletado estima los meses que faltan entre dos snapshots (marcados como estimados). O añade los valores reales tú mismo.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Copia automática";
"Off" = "Desactivado";
"Daily" = "Diario";
"Weekly" = "Semanal";
"Monthly" = "Mensual";
"Last auto backup" = "Última copia automática";
/* 1.6.0 hide balances */
"Hide balances" = "Ocultar saldos";
"Show balances" = "Mostrar saldos";
"Balance hidden" = "Saldo oculto";
"Reveal your balances" = "Mostrar tus saldos";
"Privacy" = "Privacidad";
"Hide Balances" = "Ocultar saldos";
"Require Face ID to Reveal" = "Requerir Face ID para mostrar";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todas las cifras tras un difuminado. Ideal para usar la app en público o hacer capturas. Opcionalmente, requiere Face ID para mostrarlas.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Aún no tienes ningún valor de cartera registrado.";
"intent_networth_dialog" = "Tu patrimonio es %@.";
"intent_streak" = "Llevas una racha de %d meses.";
"intent_streak_with_date" = "Llevas una racha de %d meses. Tu último check-in fue el %@.";
"intent_streak_none" = "Aún no tienes una racha activa. Registra un valor para empezar una.";
// Charts workable (1.6.0)
"chart_range_button" = "Rango personalizado";
"chart_range_from" = "Desde";
"chart_range_to" = "Hasta";
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportaciones";
"chart_insight_best_month" = "Mejor mes del rango: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Comparte tu progreso";
"share_percent_only" = "Solo porcentajes";
"share_format_post" = "Post";
"share_format_story" = "Historia";
"share_card_cta" = "Compartir";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rentabilidad total";
"share_metric_yoy" = "Este año";
"share_metric_since_inception" = "Desde el inicio";
"share_cta_headline" = "Controla la tuya — gratis";
"share_cta_sub" = "Escanea para descargar Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "A %@";
"share_theme_dark" = "Oscuro";
"share_theme_light" = "Claro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valor de hoy";
"add_source_value_footer" = "Se guarda como tu primer check-in: tu progreso empieza hoy.";
"quick_update_add_first_source" = "Añade tu primera fuente";
@@ -0,0 +1,679 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Annuler";
"save" = "Enregistrer";
"delete" = "Supprimer";
"edit" = "Modifier";
"add" = "Ajouter";
"done" = "Terminé";
"close" = "Fermer";
"continue" = "Continuer";
"skip" = "Passer";
"error" = "Erreur";
"success" = "Succès";
"loading" = "Chargement...";
"loading_data" = "Chargement de vos données...";
"tab_dashboard" = "Accueil";
"tab_sources" = "Sources";
"tab_charts" = "Graphiques";
"tab_settings" = "Réglages";
"dashboard_title" = "Accueil";
"total_portfolio_value" = "Valeur totale du portefeuille";
"today" = "aujourd'hui";
"returns" = "Rendements";
"by_category" = "Par catégorie";
"pending_updates" = "Mises à jour en attente";
"see_all" = "Voir tout";
"sources_title" = "Sources";
"add_source" = "Ajouter une source";
"source_name" = "Nom de la source";
"select_category" = "Sélectionner une catégorie";
"initial_value" = "Valeur initiale";
"initial_value_optional" = "Valeur initiale (optionnelle)";
"reminder_frequency" = "Fréquence des rappels";
"source_limit_warning" = "Limite de sources atteinte. Passez à Premium pour des sources illimitées.";
"no_sources" = "Aucune source d'investissement";
"no_sources_message" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
"add_snapshot" = "Ajouter un snapshot";
"edit_snapshot" = "Modifier le snapshot";
"snapshot_date" = "Date";
"snapshot_value" = "Valeur";
"snapshot_contribution" = "Contribution";
"contribution_optional" = "Contribution (optionnelle)";
"notes" = "Notes";
"notes_optional" = "Notes (optionnelles)";
"previous_value" = "Précédent : %@";
"change_from_previous" = "Variation par rapport au précédent";
"charts_title" = "Graphiques";
"evolution" = "Évolution";
"allocation" = "Allocation";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilité";
"prediction" = "Prévision";
"portfolio_evolution" = "Évolution du portefeuille";
"asset_allocation" = "Allocation des actifs";
"performance_by_category" = "Performance par catégorie";
"drawdown_analysis" = "Analyse du drawdown";
"prediction_12_month" = "Prévision sur 12 mois";
"not_enough_data" = "Pas assez de données";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown max";
"sharpe_ratio" = "Ratio de Sharpe";
"win_rate" = "Taux de réussite";
"avg_monthly" = "Moyenne mensuelle";
"best_month" = "Meilleur mois";
"worst_month" = "Pire mois";
"premium" = "Premium";
"upgrade_to_premium" = "Passer à Premium";
"unlock_full_potential" = "Débloquez tout le potentiel";
"one_time_purchase" = "Achat unique";
"includes_family_sharing" = "Inclut le partage familial";
"upgrade_now" = "Passer maintenant";
"restore_purchases" = "Restaurer les achats";
"premium_active" = "Premium actif";
"premium_feature" = "Fonction Premium";
"unlock" = "Débloquer";
"feature_unlimited_sources" = "Sources illimitées";
"feature_unlimited_sources_desc" = "Suivez autant d'investissements que vous le souhaitez";
"feature_full_history" = "Historique complet";
"feature_full_history_desc" = "Accédez à tout votre historique d'investissement";
"feature_advanced_charts" = "Graphiques avancés";
"feature_advanced_charts_desc" = "5 types de graphiques analytiques détaillés";
"feature_predictions" = "Prévisions";
"feature_predictions_desc" = "Prévisions IA sur 12 mois";
"feature_export" = "Exporter les données";
"feature_export_desc" = "Export vers CSV et JSON";
"feature_no_ads" = "Sans publicité";
"feature_no_ads_desc" = "Expérience sans publicité à vie";
"paywall_benefit_history_title" = "Votre historique complet";
"paywall_benefit_history_subtitle" = "Chaque snapshot, contribution et gain depuis le premier jour";
"paywall_benefit_charts_title" = "Des graphiques qui révèlent des tendances";
"paywall_benefit_charts_subtitle" = "Allocation, drawdown, performance, le tout au même endroit";
"paywall_benefit_forecasts_title" = "Prévisions à 12 mois";
"paywall_benefit_forecasts_subtitle" = "Voyez où votre portefeuille pourrait aller";
"paywall_benefit_noads_title" = "Aucune pub, jamais";
"paywall_benefit_noads_subtitle" = "Une expérience propre et centrée, sans distractions";
"settings_title" = "Réglages";
"subscription" = "Abonnement";
"notifications" = "Notifications";
"default_reminder_time" = "Heure de rappel par défaut";
"data" = "Données";
"export_data" = "Exporter les données";
"total_sources" = "Nombre total de sources";
"total_snapshots" = "Nombre total de snapshots";
"storage_used" = "Stockage utilisé";
"about" = "À propos";
"version" = "Version";
"privacy_policy" = "Politique de confidentialité";
"terms_of_service" = "Conditions d'utilisation";
"support" = "Support";
"rate_app" = "Noter l'app";
"danger_zone" = "Zone de danger";
"reset_all_data" = "Réinitialiser toutes les données";
"reset_confirmation" = "Cela supprimera définitivement toutes vos données d'investissement. Cette action est irréversible.";
"frequency_monthly" = "Mensuel";
"frequency_quarterly" = "Trimestriel";
"frequency_semiannual" = "Semestriel";
"frequency_annual" = "Annuel";
"frequency_custom" = "Personnalisé";
"frequency_never" = "Jamais";
"every_n_months" = "Tous les %d mois";
"category_stocks" = "Actions";
"category_bonds" = "Obligations";
"category_real_estate" = "Immobilier";
"category_crypto" = "Crypto";
"category_cash" = "Espèces";
"category_etfs" = "ETF";
"category_retirement" = "Retraite";
"category_other" = "Autre";
"uncategorized" = "Non catégorisé";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tout";
"export_format" = "Sélectionner le format";
"export_csv" = "CSV";
"export_csv_desc" = "Compatible avec Excel et Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Structure complète pour sauvegarde";
"onboarding_track_title" = "Suivez vos investissements";
"onboarding_track_desc" = "Surveillez toutes vos sources d'investissement au même endroit. Actions, obligations, immobilier, crypto et plus.";
"onboarding_visualize_title" = "Visualisez votre croissance";
"onboarding_visualize_desc" = "De beaux graphiques montrent l'évolution, l'allocation et la performance de votre portefeuille dans le temps.";
"onboarding_reminders_title" = "Ne manquez jamais une mise à jour";
"onboarding_reminders_desc" = "Définissez des rappels pour suivre régulièrement vos investissements. Mensuels, trimestriels ou personnalisés.";
"onboarding_sync_title" = "Synchronisez partout";
"onboarding_sync_desc" = "Vos données se synchronisent automatiquement via iCloud sur tous vos appareils Apple.";
"get_started" = "Commencer";
"onboarding_clarity_title" = "Sachez exactement où vous en êtes";
"onboarding_clarity_desc" = "Voyez votre patrimoine total, vos rendements réels et votre allocation, toujours à jour.";
"onboarding_habit_title" = "5 minutes par mois suffisent";
"onboarding_habit_desc" = "Saisissez vos valeurs une fois par mois. Portfolio Journal s'occupe des calculs et montre vos progrès.";
"onboarding_calm_title" = "Ignorez le bruit. Suivez la tendance.";
"onboarding_calm_desc" = "Les variations quotidiennes ne racontent pas la vraie histoire. Votre croissance sur des mois et des années, si.";
"onboarding_goals_title" = "Atteignez vos objectifs financiers";
"onboarding_goals_desc" = "Fixez des objectifs, suivez les étapes et voyez exactement jusqu'où vous êtes arrivé.";
"error_generic" = "Une erreur s'est produite. Veuillez réessayer.";
"error_no_purchases" = "Aucun achat à restaurer";
"error_purchase_failed" = "Achat échoué : %@";
"error_export_failed" = "L'export a échoué. Veuillez réessayer.";
"placeholder_source_name" = "ex. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Ajouter des notes...";
"mood_energized_title" = "En feu";
"mood_confident_title" = "Confiant";
"mood_balanced_title" = "Stable";
"mood_cautious_title" = "Prudent";
"mood_stressed_title" = "Stressé";
"mood_energized_detail" = "Je me sens imbattable";
"mood_confident_detail" = "Sur la bonne voie et serein";
"mood_balanced_detail" = "Calme et patient";
"mood_cautious_detail" = "J'observe les mouvements";
"mood_stressed_detail" = "Besoin de souffler";
"achievement_streak_3_title" = "Série de 3 mois";
"achievement_streak_3_detail" = "Vous avez gardé vos check-ins à l'heure pendant trois mois d'affilée.";
"achievement_streak_6_title" = "Série de six mois";
"achievement_streak_6_detail" = "Six check-ins consécutifs à l'heure.";
"achievement_streak_12_title" = "Une année d'élan";
"achievement_streak_12_detail" = "Une année complète sans manquer la date limite.";
"achievement_perfect_on_time_title" = "Jamais en retard";
"achievement_perfect_on_time_detail" = "Chaque check-in a été envoyé avant la date limite.";
"achievement_clutch_finish_title" = "Final serré";
"achievement_clutch_finish_detail" = "Envoyé avec quelques heures d'avance, mais toujours à temps.";
"achievement_early_bird_title" = "Lève-tôt";
"achievement_early_bird_detail" = "En moyenne, vous terminez avec une belle marge.";
"achievements_title" = "Succès";
"achievements_view_all" = "Voir tous les succès";
"achievements_nav_title" = "Succès";
"achievements_progress_title" = "Progression";
"achievements_unlocked_title" = "Débloqués";
"achievements_unlocked_empty" = "Complétez des check-ins pour débloquer des succès.";
"achievements_locked_title" = "Verrouillés";
"achievements_locked_empty" = "Tous les succès sont débloqués. Beau travail.";
"rating_accessibility" = "Note %d sur 5";
"achievements_unlocked_count" = "%d sur %d débloqués";
"last_check_in" = "Dernier check-in : %@";
"next_check_in" = "Prochain check-in : %@";
"on_time_rate" = "%@ à l'heure";
"on_time_count" = "%d/%d à l'heure";
"tightest_finish" = "Fin la plus serrée : %@ avant la date limite.";
"date_today" = "Aujourd'hui";
"date_yesterday" = "Hier";
"date_never" = "Jamais";
"calendar_event_title" = "%@: Check-in mensuel";
"calendar_event_notes" = "Ouvrez %@ et terminez votre check-in mensuel.";
"checkin_enjoying_dialog_title" = "À quel point appréciez-vous Portfolio Journal ?";
"checkin_enjoying_dialog_message" = "Félicitations pour votre nouveau succès. Votre avis nous aide à nous améliorer.";
"not_now" = "Pas maintenant";
"rating_1_star" = "1 étoile";
"rating_n_stars" = "%d étoiles";
"app_store_review_title" = "Souhaitez-vous laisser un avis sur l'App Store ?";
"app_store_review_message" = "Merci pour les 5 étoiles. Cela aide vraiment d'autres investisseurs à découvrir l'app.";
"write_review" = "Écrire un avis";
"save_1_snapshot" = "Enregistrer 1 snapshot";
"save_n_snapshots" = "Enregistrer %d snapshots";
"checkin_update_month" = "Mettre à jour %@";
"checkin_start_new" = "Commencer";
"Home" = "Accueil";
"Sources" = "Sources";
"Charts" = "Graphiques";
"Settings" = "Réglages";
"Journal" = "Journal";
"Search monthly notes" = "Rechercher des notes mensuelles";
"Monthly Check-ins" = "Check-ins mensuels";
"No monthly notes yet." = "Aucune note mensuelle pour l'instant.";
"No matching notes." = "Aucune note correspondante.";
"Jump to month" = "Aller au mois";
"Today" = "Aujourd'hui";
"Mood not set" = "Humeur non définie";
"No rating" = "Aucune note";
"No note yet." = "Pas encore de note.";
"Monthly Note" = "Note mensuelle";
"Open Full Note" = "Ouvrir la note complète";
"Duplicate Previous" = "Dupliquer le précédent";
"Save" = "Enregistrer";
"Monthly Check-in" = "Check-in mensuel";
"This Month" = "Ce mois-ci";
"No check-in yet this month" = "Aucun check-in ce mois-ci";
"Start your first check-in anytime." = "Commencez votre premier check-in quand vous voulez.";
"Mark Check-in Complete" = "Marquer le check-in comme terminé";
"Editing stays open. New check-ins unlock after 70% of the month." = "L'édition reste ouverte. Les nouveaux check-ins se débloquent après 70 % du mois.";
"Momentum & Streaks" = "Élan et séries";
"Log a check-in to start a streak" = "Enregistrez un check-in pour démarrer une série";
"Streak" = "Série";
"On-time in a row" = "À l'heure d'affilée";
"Best" = "Meilleur";
"Personal best" = "Meilleur record";
"Avg early" = "Marge moyenne";
"vs deadline" = "vs date limite";
"On-time score" = "Score de ponctualité";
"Achievements" = "Succès";
"View all achievements" = "Voir tous les succès";
"Monthly Pulse" = "Pouls mensuel";
"Optional" = "Optionnel";
"Rate this month" = "Notez ce mois";
"How did it feel?" = "Comment cela s'est-il passé ?";
"Monthly Summary" = "Résumé mensuel";
"Starting" = "Départ";
"Ending" = "Fin";
"Contributions" = "Contributions";
"Net Performance" = "Performance nette";
"Update Sources" = "Mettre à jour les sources";
"Add sources to start your monthly check-in." = "Ajoutez des sources pour commencer votre check-in mensuel.";
"Updated this cycle" = "Mis à jour sur ce cycle";
"Needs update" = "Nécessite une mise à jour";
"Snapshot Notes" = "Notes des snapshots";
"No snapshot notes for this month." = "Aucune note de snapshot pour ce mois.";
"Source" = "Source";
"Your full portfolio,\nfully clear" = "Votre portefeuille complet,\nen toute clarté";
"One payment. Every feature. Forever." = "Un paiement. Toutes les fonctionnalités. Pour toujours.";
"Get Full Access" = "Obtenir l'accès complet";
"Restore Purchases" = "Restaurer les achats";
"Payment charged to your Apple ID account." = "Le paiement sera facturé à votre compte Apple ID.";
"Terms" = "Conditions";
"Privacy" = "Confidentialité";
"· one-time · Family Sharing" = "· achat unique · Partage familial";
"Full access, one payment" = "Accès complet, un paiement";
"Unlimited sources, advanced charts & more" = "Sources illimitées, graphiques avancés et plus";
"See full access" = "Voir l'accès complet";
"Batch Update" = "Mise à jour groupée";
"Current value" = "Valeur actuelle";
"Contribution this period (optional)" = "Contribution sur cette période (optionnelle)";
"Include Contribution" = "Inclure une contribution";
"New capital added" = "Nouveau capital ajouté";
"Contribution (Optional)" = "Contribution (optionnelle)";
"Track new capital added to separate it from investment growth." = "Suivez le nouveau capital ajouté pour le distinguer de la croissance de l'investissement.";
"Monthly Highlights" = "Temps forts du mois";
"Best Performer" = "Meilleure performance";
"Worst Performer" = "Pire performance";
"Best Contributor" = "Meilleur contributeur";
"Update Check-in" = "Mettre à jour le check-in";
"Completed %@" = "Terminé %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Vérification…";
"Clear Filters" = "Effacer les filtres";
"Force Upload to iCloud" = "Forcer l'envoi vers iCloud";
"No export yet" = "Pas encore exporté";
"Not synced yet" = "Pas encore synchronisé";
"Refresh" = "Actualiser";
"Search sources" = "Rechercher des sources";
"Syncing with iCloud..." = "Synchronisation avec iCloud...";
"Uploading..." = "Envoi en cours...";
"Verify iCloud Setup" = "Vérifier la configuration iCloud";
"add_source_name_footer" = "Une source est tout investissement que vous souhaitez suivre : actions, ETFs, comptes d'épargne, immobilier, crypto et plus.";
"add_source_name_placeholder" = "ex. ETF MSCI World, Livret A, Appartement...";
"categories_empty" = "Aucune catégorie pour l'instant.";
"category_has_sources_warning" = "Impossible de supprimer une catégorie qui contient des sources. Supprimez ou réaffectez d'abord toutes les sources.";
"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é";
"contributions_vs_returns_title" = "Investi vs. Rendement";
"csv_enter_value" = "Saisir une valeur";
"csv_field_category" = "Catégorie";
"csv_field_contribution" = "Contribution";
"csv_field_date" = "Date";
"csv_field_notes" = "Notes";
"csv_field_source" = "Nom de la source (requis)";
"csv_field_value" = "Valeur (requise)";
"csv_mapping_subtitle" = "Associez vos colonnes CSV aux champs de Portfolio Journal";
"csv_no_column" = "— Non mappé —";
"csv_no_date_hint" = "Toutes les lignes seront importées comme un snapshot d'aujourd'hui.";
"csv_optional_section" = "Champs optionnels";
"csv_preview_section" = "Aperçu CSV";
"csv_required_section" = "Champs obligatoires";
"csv_use_today" = "Utiliser la date du jour";
"goal_achieved_notification_body" = "Vous avez atteint votre objectif : %@";
"goal_achieved_notification_title" = "Objectif atteint ! 🎉";
"goal_archive" = "Archiver";
"goal_delete_confirm" = "Supprimer";
"goal_delete_message" = "Cet objectif sera définitivement supprimé. Cette action est irréversible.";
"goal_delete_title" = "Supprimer l'objectif";
"goal_unarchive" = "Restaurer";
"goals_all_active_achieved" = "Tous les objectifs actifs sont atteints.";
"goals_empty_archived" = "Aucun objectif archivé.";
"goals_filter_active" = "Actifs";
"goals_filter_all" = "Tous";
"goals_filter_archived" = "Archivés";
"icloud_check_description" = "Si vous avez des données sur un autre appareil, activez iCloud pour les restaurer ici.";
"icloud_check_title" = "Vous utilisez déjà Portfolio Journal ?";
"icloud_enabled_description" = "Fermez l'application et rouvrez-la. Vos données se chargeront automatiquement depuis iCloud.";
"icloud_enabled_title" = "iCloud activé";
"monthly_checkin_notification_body" = "Enregistrez les valeurs de ce mois et suivez la croissance de votre portefeuille.";
"monthly_checkin_notification_title" = "C'est l'heure de votre mise à jour mensuelle";
"onboarding_add_first_source" = "Ajouter mon premier investissement";
"onboarding_import_data" = "Importer des données existantes";
"onboarding_quickstart_subtitle" = "Ajoutez votre première source d'investissement pour commencer à suivre votre portefeuille.";
"onboarding_quickstart_title" = "Presque prêt";
"reengagement_body" = "Quelques minutes suffisent pour rester au top de vos investissements.";
"reengagement_goal_title" = "Vous êtes sur la bonne voie 📈";
"reengagement_goal_body" = "À ce rythme, vous atteindrez \"%@\" d'ici %@. Voyez où vous en êtes.";
"reengagement_yoy_title" = "Regardez cette progression 👀";
"reengagement_yoy_body" = "Vous êtes en hausse de %@ sur un an. Consultez la tendance.";
"reengagement_month_title" = "Beau mois 📈";
"reengagement_month_body" = "Votre portefeuille a gagné %@ ce mois-ci. Jetez-y un œil.";
"reengagement_milestone_title" = "Si près 🎯";
"reengagement_milestone_body" = "Vous approchez de %@. À combien êtes-vous aujourd'hui ?";
"reengagement_streak_title" = "Gardez votre série 🔥";
"reengagement_streak_body" = "Vous avez enregistré %d mois d'affilée. Ne vous arrêtez pas.";
"reengagement_neutral_title" = "Du nouveau ? 👀";
"reengagement_neutral_body" = "Il y a eu du mouvement dans votre portefeuille. Voyez ce qui a changé.";
"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";
"sources_filter_all" = "Toutes";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Votre résumé mensuel de portefeuille";
"monthly_summary_notification_body" = "Vérifiez comment vos investissements ont performé ce mois-ci.";
"streak_badge" = "Série de %d mois";
"goals_empty_add_cta" = "Ajouter mon premier objectif";
"journal_empty_title" = "Aucune entrée pour l'instant";
"journal_empty_body" = "Les mises à jour de snapshots créent automatiquement des entrées mensuelles. Ajoutez votre première source pour commencer.";
"quick_update_title" = "Mise à jour rapide";
"quick_update_section_header" = "Saisir les valeurs actuelles";
"quick_update_section_footer" = "Seules les sources avec une valeur saisie seront mises à jour.";
"quick_update_placeholder" = "Nouvelle valeur";
"quick_update_save" = "Tout enregistrer";
"quick_update_no_sources" = "Aucune source";
"quick_update_no_sources_body" = "Ajoutez d'abord des sources d'investissement pour utiliser la mise à jour rapide.";
"update_available_title" = "Mise à jour disponible";
"update_available_body" = "La version %@ est disponible sur l'App Store.";
"whats_new_title" = "Nouveautés dans 1.4";
"whats_new_subtitle" = "Améliorations pour vous aider à rester sur la bonne voie.";
"whats_new_quick_update_title" = "Mise à jour rapide du portefeuille";
"whats_new_quick_update_body" = "Mettez à jour toutes vos sources depuis un seul écran en un geste.";
"whats_new_streak_title" = "Série de mises à jour";
"whats_new_streak_body" = "Suivez le nombre de mois consécutifs où vous avez tenu votre portefeuille à jour.";
"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";
"insight_allocation_title" = "Écart d'allocation";
"insight_allocation_over_value" = "%1$@ est à %2$.0f%% — au-dessus de votre cible de %3$.0f%%";
"insight_allocation_under_value" = "%1$@ est à %2$.0f%% — en dessous de votre cible de %3$.0f%%";
"insight_allocation_detail" = "Rééquilibrez pour rester dans le plan";
"insight_concentration_title" = "Risque de concentration";
"insight_concentration_value" = "%1$@ représente %2$.0f%% de votre portefeuille";
"insight_concentration_detail" = "Envisagez de mieux répartir";
"insight_best_performer_title" = "Meilleure performance";
"insight_worst_performer_title" = "À surveiller";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversification";
"insight_diversification_single_value" = "Tout sur une seule source — pensez à en ajouter";
"insight_diversification_good_value" = "Réparti sur %d catégories";
"goal_projection_projected" = "Prévu : %@";
"goal_projection_add_contributions" = "Ajoutez des versements pour l'atteindre";
"goal_chip_ahead" = "En avance";
"goal_chip_on_track" = "Dans les temps";
"goal_chip_behind" = "En retard";
"goal_chip_at_this_pace" = "À ce rythme…";
"notification_milestone_title" = "Jalon de portefeuille ! 🎉";
"notification_milestone_body" = "Votre portefeuille vient de dépasser %@. Félicitations !";
"streak_protection_notification_title" = "Ne brisez pas votre série 🔥";
"streak_protection_notification_body" = "Votre bilan mensuel est encore en attente. 2 minutes suffisent pour garder votre série.";
"paywall_benefit_accounts_title" = "Comptes multiples";
"paywall_benefit_accounts_subtitle" = "Des portefeuilles séparés pour la famille ou le travail";
"paywall_benefit_family_title" = "Partage familial";
"paywall_benefit_family_subtitle" = "Un achat, jusqu'à 5 membres de la famille";
"paywall_plan_annual" = "Annuel";
"paywall_plan_annual_per_year" = "%@ / an";
"paywall_plan_lifetime" = "À vie";
"paywall_plan_lifetime_badge" = "MEILLEURE OFFRE";
"paywall_trial_days" = "jours";
"paywall_trial_weeks" = "semaines";
"paywall_trial_months" = "mois";
"paywall_trial_years" = "ans";
"paywall_trial_format" = "%d %@ gratuits";
"chart_history_locked" = "%d mois d'historique en plus — débloquez avec Premium";
"chart_group_overview" = "Aperçu";
"chart_group_analyze" = "Analyse";
"chart_group_risk" = "Risque";
"chart_group_forecast" = "Prévision";
"kpi_total_value" = "Valeur totale";
"kpi_period_return" = "Rendement période";
"kpi_cagr" = "TCAC";
"kpi_volatility" = "Volatilité";
"kpi_max_drawdown" = "Baisse max.";
"chart_zoom_in" = "Zoom avant";
"chart_zoom_out" = "Zoom arrière";
"chart_zoom_reset" = "Réinitialiser le zoom";
"quick_update_paste_suggestion" = "Coller %@ dans %@";
"quick_update_next_badge" = "SUIVANTE";
// App Intents (1.4.2)
"intent_logged_dialog" = "%1$@ enregistré pour %2$@.";
"intent_amount_invalid" = "Le montant doit être supérieur à zéro. Combien ?";
"Log Portfolio Value" = "Enregistrer la valeur du portefeuille";
"Record a value for one of your sources without opening the app." = "Enregistrez une valeur pour lune de vos sources sans ouvrir lapp.";
"Amount" = "Montant";
"How much?" = "Combien ?";
"Log Value" = "Enregistrer une valeur";
"Update Portfolio" = "Mettre à jour le portefeuille";
"Check Portfolio" = "Consulter le portefeuille";
// Chart share (1.4.2)
"chart_share_button" = "Partager le graphique";
"chart_share_text" = "Mon graphique %@ — créé avec Portfolio Journal";
"chart_share_tagline" = "Suivi dinvestissements privé et serein pour iPhone et iPad.";
"chart_share_scan" = "Scannez pour télécharger";
"chart_locked_title" = "%@ est un graphique Premium";
"chart_locked_cta" = "Débloquer Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ terminé";
"checkin_done_next" = "Prochain : %@";
"checkin_next_due" = "Prochain point : %@";
"checkin_days_left" = "%d jours restants";
"checkin_due_today" = "Aujourd'hui";
"checkin_overdue" = "En retard";
"dash_chart_yoy" = "Année vs Année";
"home_chart_locked_sub" = "Débloquez avec Premium pour voir ce graphique.";
"checkin_sources_progress" = "%1$d sur %2$d sources mises à jour";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mois dernier";
"guided_paste_amount" = "Coller %@";
"guided_next" = "Suivant";
"guided_same_as_last" = "Comme le mois dernier";
"guided_skip" = "Passer";
"guided_reflection_title" = "Comment se sent ce mois-ci ?";
"guided_reflection_subtitle" = "Une note rapide pour votre futur vous.";
"guided_note_placeholder" = "Une ligne honnête sur ce mois…";
"guided_finish" = "Terminer le check-in";
"guided_done_title" = "Check-in de %@ terminé";
"guided_streak" = "%d mois d'affilée";
"guided_share_month" = "Partager ce mois";
"guided_done_cta" = "Terminé";
"journal_no_note" = "Pas encore de note.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Version %@";
"whats_new_150_redesign_title" = "Un design plus calme et épuré";
"whats_new_150_redesign_body" = "Accueil repensé avec votre valeur totale en vedette, le check-in au centre et moins de bruit partout.";
"whats_new_150_guided_title" = "Check-in mensuel guidé";
"whats_new_150_guided_body" = "Une source par étape, des deltas en direct, une réflexion rapide — votre rituel de 5 minutes, réinventé.";
"whats_new_150_rings_title" = "Anneaux d'objectifs & timeline";
"whats_new_150_rings_body" = "Des anneaux de progression style Fitness pour vos objectifs et une timeline de votre parcours d'investisseur.";
"whats_new_142_ocr_title" = "Mise à jour par capture d'écran";
"whats_new_142_ocr_body" = "Partagez une capture de votre banque — le solde est reconnu sur l'appareil, rien ne quitte votre iPhone.";
"whats_new_142_siri_title" = "Siri dans votre langue";
"whats_new_142_siri_body" = "« Enregistre le solde de Indexa » — notez des valeurs à la voix ou depuis Raccourcis, sans ouvrir l'app.";
"whats_new_142_charts_title" = "Graphiques repensés";
"whats_new_142_charts_body" = "Barre latérale native sur iPad, KPIs adaptés à chaque graphique et partage en belles images.";
"whats_new_141_extension_title" = "Mettre à jour sans changer d'app";
"whats_new_141_extension_body" = "Partagez une valeur depuis votre app bancaire directement vers Portfolio Journal avec la nouvelle extension.";
"whats_new_141_ipad_title" = "Meilleur sur iPad";
"whats_new_141_ipad_body" = "Graphiques repensés pour le grand écran, avec zoom et étiquettes de valeur.";
"whats_new_141_icloud_title" = "Plus synchronisé";
"whats_new_141_icloud_body" = "Les contributions mensuelles et les objectifs d'allocation se synchronisent désormais via iCloud.";
"whats_new_title_format" = "Nouveautés de la %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Tout mettre à jour en quelques secondes";
"tip_quick_update_body" = "Quick Update liste toutes vos sources sur un écran — collez les valeurs du presse-papiers et c'est fait.";
"tip_ocr_title" = "Ou partagez une capture d'écran";
"tip_ocr_body" = "Depuis votre app bancaire : capture → Partager → Portfolio Journal. Le solde est lu sur l'appareil.";
"tip_siri_title" = "La prochaine fois, essayez la voix";
"tip_siri_body" = "Dites à Siri « Enregistre le solde de mon compte dans Portfolio Journal » — sans ouvrir l'app.";
"tip_chart_share_title" = "Partagez ce graphique";
"tip_chart_share_body" = "Génère une belle image avec vos données, prête pour X ou WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud a refusé certains enregistrements. Cela signifie souvent que le schéma iCloud est encore en cours de configuration — se résout généralement en quelques heures ou après la prochaine mise à jour.";
"icloud_hint_quota" = "Votre stockage iCloud est plein. Libérez de l'espace et réessayez.";
"icloud_hint_network" = "Impossible de joindre iCloud. Vérifiez votre connexion et réessayez.";
"icloud_hint_partial_generic" = "Certains enregistrements n'ont pas pu être synchronisés cette fois. L'app réessaiera automatiquement.";
"icloud_status_error" = "Problème de synchronisation iCloud";
"icloud_status_syncing" = "Synchronisation avec iCloud";
"icloud_show_details" = "Afficher les détails techniques";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Meilleure année";
"yoy_worst_year" = "Pire année";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Coller %@";
"quick_update_scan" = "Scanner";
"quick_update_done_kbd" = "OK";
"quick_update_scanning" = "Lecture de limage…";
"quick_update_pick_amount" = "Quel montant ?";
"quick_update_no_amount_found" = "Aucun montant reconnu dans limage.";
"icloud_copy_diagnostics" = "Copier le diagnostic iCloud";
"icloud_diagnostics_copied" = "Diagnostic copié dans le presse-papiers";
"Smooth Gaps in Charts" = "Lisser les écarts dans les graphiques";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Données manquantes pour %d mois";
"gaps_missing_count" = "%d manquants";
"gaps_range_between" = "Entre %@ et %@";
"Fill in missing data" = "Compléter les données manquantes";
"Missing Data" = "Données manquantes";
"Fill Gap" = "Combler le vide";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Le graphique estime ces mois. Ajoutez des valeurs réelles pour garder un historique précis.";
"Dismiss" = "Ignorer";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mois";
"Monthly Summary" = "Résumé mensuel";
"Portfolio Value" = "Valeur du portefeuille";
"Tracked with" = "Suivi avec";
"Share monthly summary" = "Partager le résumé mensuel";
"since last check-in" = "depuis le dernier point";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Rien de nouveau à importer. Vos données sont déjà à jour.";
"import_preview_sources" = "Créer %d sources";
"import_preview_snapshots_new" = "Créer %d relevés";
"import_preview_snapshots_update" = "Mettre à jour %d relevés";
"import_preview_categories" = "Créer %d catégories";
"import_preview_goals" = "Créer %d objectifs";
"import_preview_journal_new" = "Créer %d entrées de journal";
"import_preview_journal_update" = "Mettre à jour %d entrées de journal";
"Review Import" = "Vérifier limport";
"Reviewing import…" = "Vérification de limport…";
"Import" = "Importer";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimé";
"gaps_autofill_cta" = "Remplir %d mois";
"Add manually" = "Ajouter manuellement";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Le remplissage auto estime les mois manquants entre deux relevés (marqués comme estimés). Ou saisis les vraies valeurs toi-même.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Sauvegarde auto";
"Off" = "Désactivé";
"Daily" = "Quotidien";
"Weekly" = "Hebdomadaire";
"Monthly" = "Mensuel";
"Last auto backup" = "Dernière sauvegarde auto";
/* 1.6.0 hide balances */
"Hide balances" = "Masquer les soldes";
"Show balances" = "Afficher les soldes";
"Balance hidden" = "Solde masqué";
"Reveal your balances" = "Afficher vos soldes";
"Privacy" = "Confidentialité";
"Hide Balances" = "Masquer les soldes";
"Require Face ID to Reveal" = "Exiger Face ID pour afficher";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Masque tous les montants derrière un flou. Idéal pour utiliser l'app en public ou faire des captures. Exigez Face ID pour les afficher, en option.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Tu n'as encore enregistré aucune valeur de portefeuille.";
"intent_networth_dialog" = "Ton patrimoine est de %@.";
"intent_streak" = "Tu es sur une série de %d mois.";
"intent_streak_with_date" = "Tu es sur une série de %d mois. Ton dernier relevé date du %@.";
"intent_streak_none" = "Tu n'as pas encore de série active. Enregistre une valeur pour en commencer une.";
// Charts workable (1.6.0)
"chart_range_button" = "Période personnalisée";
"chart_range_from" = "De";
"chart_range_to" = "À";
"chart_filtered_prefix" = "Filtré : %@";
"chart_overlay_contributions" = "vs Versements";
"chart_insight_best_month" = "Meilleur mois de la période : %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Partage tes progrès";
"share_percent_only" = "Pourcentages uniquement";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Partager";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rendement total";
"share_metric_yoy" = "Cette année";
"share_metric_since_inception" = "Depuis le début";
"share_cta_headline" = "Suis le tien — gratuit";
"share_cta_sub" = "Scanne pour Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Au %@";
"share_theme_dark" = "Sombre";
"share_theme_light" = "Clair";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valeur du jour";
"add_source_value_footer" = "Enregistrée comme votre premier check-in : votre progression commence aujourd'hui.";
"quick_update_add_first_source" = "Ajouter votre première source";
@@ -0,0 +1,679 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Annulla";
"save" = "Salva";
"delete" = "Elimina";
"edit" = "Modifica";
"add" = "Aggiungi";
"done" = "Fatto";
"close" = "Chiudi";
"continue" = "Continua";
"skip" = "Salta";
"error" = "Errore";
"success" = "Successo";
"loading" = "Caricamento...";
"loading_data" = "Caricamento dei tuoi dati...";
"tab_dashboard" = "Home";
"tab_sources" = "Fonti";
"tab_charts" = "Grafici";
"tab_settings" = "Impostazioni";
"dashboard_title" = "Home";
"total_portfolio_value" = "Valore totale del portafoglio";
"today" = "oggi";
"returns" = "Rendimenti";
"by_category" = "Per categoria";
"pending_updates" = "Aggiornamenti in sospeso";
"see_all" = "Vedi tutto";
"sources_title" = "Fonti";
"add_source" = "Aggiungi fonte";
"source_name" = "Nome fonte";
"select_category" = "Seleziona categoria";
"initial_value" = "Valore iniziale";
"initial_value_optional" = "Valore iniziale (opzionale)";
"reminder_frequency" = "Frequenza promemoria";
"source_limit_warning" = "Limite di fonti raggiunto. Passa a Premium per fonti illimitate.";
"no_sources" = "Nessuna fonte di investimento";
"no_sources_message" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
"add_snapshot" = "Aggiungi snapshot";
"edit_snapshot" = "Modifica snapshot";
"snapshot_date" = "Data";
"snapshot_value" = "Valore";
"snapshot_contribution" = "Contributo";
"contribution_optional" = "Contributo (opzionale)";
"notes" = "Note";
"notes_optional" = "Note (opzionali)";
"previous_value" = "Precedente: %@";
"change_from_previous" = "Variazione rispetto al precedente";
"charts_title" = "Grafici";
"evolution" = "Evoluzione";
"allocation" = "Allocazione";
"performance" = "Performance";
"drawdown" = "Drawdown";
"volatility" = "Volatilità";
"prediction" = "Previsione";
"portfolio_evolution" = "Evoluzione del portafoglio";
"asset_allocation" = "Allocazione degli asset";
"performance_by_category" = "Performance per categoria";
"drawdown_analysis" = "Analisi del drawdown";
"prediction_12_month" = "Previsione a 12 mesi";
"not_enough_data" = "Dati insufficienti";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown massimo";
"sharpe_ratio" = "Indice di Sharpe";
"win_rate" = "Tasso di successo";
"avg_monthly" = "Media mensile";
"best_month" = "Mese migliore";
"worst_month" = "Mese peggiore";
"premium" = "Premium";
"upgrade_to_premium" = "Passa a Premium";
"unlock_full_potential" = "Sblocca tutto il potenziale";
"one_time_purchase" = "Acquisto una tantum";
"includes_family_sharing" = "Include la condivisione in famiglia";
"upgrade_now" = "Aggiorna ora";
"restore_purchases" = "Ripristina acquisti";
"premium_active" = "Premium attivo";
"premium_feature" = "Funzione Premium";
"unlock" = "Sblocca";
"feature_unlimited_sources" = "Fonti illimitate";
"feature_unlimited_sources_desc" = "Monitora tutti gli investimenti che vuoi";
"feature_full_history" = "Cronologia completa";
"feature_full_history_desc" = "Accedi a tutta la tua cronologia di investimento";
"feature_advanced_charts" = "Grafici avanzati";
"feature_advanced_charts_desc" = "5 tipi di grafici analitici dettagliati";
"feature_predictions" = "Previsioni";
"feature_predictions_desc" = "Previsioni a 12 mesi basate su IA";
"feature_export" = "Esporta dati";
"feature_export_desc" = "Esporta in CSV e JSON";
"feature_no_ads" = "Niente pubblicità";
"feature_no_ads_desc" = "Esperienza senza pubblicità per sempre";
"paywall_benefit_history_title" = "La tua cronologia completa";
"paywall_benefit_history_subtitle" = "Ogni snapshot, contributo e guadagno dal primo giorno";
"paywall_benefit_charts_title" = "Grafici che rivelano schemi";
"paywall_benefit_charts_subtitle" = "Allocazione, drawdown, performance: tutto in un unico posto";
"paywall_benefit_forecasts_title" = "Previsioni a 12 mesi";
"paywall_benefit_forecasts_subtitle" = "Guarda dove probabilmente andrà il tuo portafoglio";
"paywall_benefit_noads_title" = "Niente pubblicità, mai";
"paywall_benefit_noads_subtitle" = "Esperienza pulita e focalizzata, senza distrazioni";
"settings_title" = "Impostazioni";
"subscription" = "Abbonamento";
"notifications" = "Notifiche";
"default_reminder_time" = "Ora promemoria predefinita";
"data" = "Dati";
"export_data" = "Esporta dati";
"total_sources" = "Fonti totali";
"total_snapshots" = "Snapshot totali";
"storage_used" = "Spazio utilizzato";
"about" = "Info";
"version" = "Versione";
"privacy_policy" = "Informativa sulla privacy";
"terms_of_service" = "Termini di servizio";
"support" = "Supporto";
"rate_app" = "Valuta l'app";
"danger_zone" = "Area pericolosa";
"reset_all_data" = "Reimposta tutti i dati";
"reset_confirmation" = "Questo eliminerà definitivamente tutti i dati di investimento. L'azione non può essere annullata.";
"frequency_monthly" = "Mensile";
"frequency_quarterly" = "Trimestrale";
"frequency_semiannual" = "Semestrale";
"frequency_annual" = "Annuale";
"frequency_custom" = "Personalizzata";
"frequency_never" = "Mai";
"every_n_months" = "Ogni %d mese/i";
"category_stocks" = "Azioni";
"category_bonds" = "Obbligazioni";
"category_real_estate" = "Immobiliare";
"category_crypto" = "Cripto";
"category_cash" = "Liquidità";
"category_etfs" = "ETF";
"category_retirement" = "Pensione";
"category_other" = "Altro";
"uncategorized" = "Senza categoria";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tutto";
"export_format" = "Seleziona formato";
"export_csv" = "CSV";
"export_csv_desc" = "Compatibile con Excel e Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Struttura dati completa per backup";
"onboarding_track_title" = "Monitora i tuoi investimenti";
"onboarding_track_desc" = "Controlla tutte le tue fonti di investimento in un unico posto. Azioni, obbligazioni, immobiliare, cripto e altro.";
"onboarding_visualize_title" = "Visualizza la tua crescita";
"onboarding_visualize_desc" = "Bellissimi grafici mostrano l'evoluzione, l'allocazione e la performance del portafoglio nel tempo.";
"onboarding_reminders_title" = "Non perdere mai un aggiornamento";
"onboarding_reminders_desc" = "Imposta promemoria per monitorare regolarmente i tuoi investimenti. Mensili, trimestrali o personalizzati.";
"onboarding_sync_title" = "Sincronizza ovunque";
"onboarding_sync_desc" = "I tuoi dati si sincronizzano automaticamente tramite iCloud su tutti i tuoi dispositivi Apple.";
"get_started" = "Inizia";
"onboarding_clarity_title" = "Sai esattamente a che punto sei";
"onboarding_clarity_desc" = "Vedi il tuo patrimonio totale, i rendimenti reali e l'allocazione, sempre aggiornati.";
"onboarding_habit_title" = "Bastano 5 minuti al mese";
"onboarding_habit_desc" = "Registra i tuoi valori una volta al mese. Portfolio Journal fa i calcoli e mostra i tuoi progressi.";
"onboarding_calm_title" = "Ignora il rumore. Segui il trend.";
"onboarding_calm_desc" = "Le oscillazioni giornaliere non raccontano la storia vera. La tua crescita in mesi e anni sì.";
"onboarding_goals_title" = "Raggiungi i tuoi obiettivi finanziari";
"onboarding_goals_desc" = "Imposta obiettivi, segui le tappe e vedi esattamente quanto hai fatto.";
"error_generic" = "Si è verificato un errore. Riprova.";
"error_no_purchases" = "Nessun acquisto da ripristinare";
"error_purchase_failed" = "Acquisto non riuscito: %@";
"error_export_failed" = "Esportazione non riuscita. Riprova.";
"placeholder_source_name" = "es. Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Aggiungi note...";
"mood_energized_title" = "Carico";
"mood_confident_title" = "Fiducioso";
"mood_balanced_title" = "Equilibrato";
"mood_cautious_title" = "Cauto";
"mood_stressed_title" = "Stressato";
"mood_energized_detail" = "Mi sento imbattibile";
"mood_confident_detail" = "In carreggiata e lucido";
"mood_balanced_detail" = "Calmo e paziente";
"mood_cautious_detail" = "Osservo i movimenti";
"mood_stressed_detail" = "Ho bisogno di resettare";
"achievement_streak_3_title" = "Serie di 3 mesi";
"achievement_streak_3_detail" = "Hai mantenuto i check-in puntuali per tre mesi di fila.";
"achievement_streak_6_title" = "Serie di mezzo anno";
"achievement_streak_6_detail" = "Sei check-in consecutivi puntuali.";
"achievement_streak_12_title" = "Un anno di slancio";
"achievement_streak_12_detail" = "Un anno intero senza perdere la scadenza.";
"achievement_perfect_on_time_title" = "Mai in ritardo";
"achievement_perfect_on_time_detail" = "Ogni check-in è arrivato prima della scadenza.";
"achievement_clutch_finish_title" = "Finale al limite";
"achievement_clutch_finish_detail" = "Inviato con poche ore di margine ma comunque in tempo.";
"achievement_early_bird_title" = "Mattiniero";
"achievement_early_bird_detail" = "In media completi tutto con molto margine.";
"achievements_title" = "Obiettivi";
"achievements_view_all" = "Vedi tutti gli obiettivi";
"achievements_nav_title" = "Obiettivi";
"achievements_progress_title" = "Progressi";
"achievements_unlocked_title" = "Sbloccati";
"achievements_unlocked_empty" = "Completa i check-in per sbloccare obiettivi.";
"achievements_locked_title" = "Bloccati";
"achievements_locked_empty" = "Tutti gli obiettivi sbloccati. Ottimo lavoro.";
"rating_accessibility" = "Valutazione %d su 5";
"achievements_unlocked_count" = "%d su %d sbloccati";
"last_check_in" = "Ultimo check-in: %@";
"next_check_in" = "Prossimo check-in: %@";
"on_time_rate" = "%@ puntuale";
"on_time_count" = "%d/%d in tempo";
"tightest_finish" = "Chiusura più tirata: %@ prima della scadenza.";
"date_today" = "Oggi";
"date_yesterday" = "Ieri";
"date_never" = "Mai";
"calendar_event_title" = "%@: Check-in mensile";
"calendar_event_notes" = "Apri %@ e completa il tuo check-in mensile.";
"checkin_enjoying_dialog_title" = "Quanto ti piace Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Congratulazioni per il tuo nuovo obiettivo. Il tuo feedback ci aiuta a migliorare.";
"not_now" = "Non ora";
"rating_1_star" = "1 stella";
"rating_n_stars" = "%d stelle";
"app_store_review_title" = "Vuoi lasciare una recensione sull'App Store?";
"app_store_review_message" = "Grazie per le 5 stelle. Aiuta davvero altri investitori a scoprire l'app.";
"write_review" = "Scrivi una recensione";
"save_1_snapshot" = "Salva 1 snapshot";
"save_n_snapshots" = "Salva %d snapshot";
"checkin_update_month" = "Aggiorna %@";
"checkin_start_new" = "Inizia";
"Home" = "Home";
"Sources" = "Fonti";
"Charts" = "Grafici";
"Settings" = "Impostazioni";
"Journal" = "Diario";
"Search monthly notes" = "Cerca note mensili";
"Monthly Check-ins" = "Check-in mensili";
"No monthly notes yet." = "Ancora nessuna nota mensile.";
"No matching notes." = "Nessuna nota corrispondente.";
"Jump to month" = "Vai al mese";
"Today" = "Oggi";
"Mood not set" = "Stato d'animo non impostato";
"No rating" = "Nessuna valutazione";
"No note yet." = "Ancora nessuna nota.";
"Monthly Note" = "Nota mensile";
"Open Full Note" = "Apri nota completa";
"Duplicate Previous" = "Duplica precedente";
"Save" = "Salva";
"Monthly Check-in" = "Check-in mensile";
"This Month" = "Questo mese";
"No check-in yet this month" = "Nessun check-in questo mese";
"Start your first check-in anytime." = "Inizia il tuo primo check-in quando vuoi.";
"Mark Check-in Complete" = "Segna check-in come completato";
"Editing stays open. New check-ins unlock after 70% of the month." = "La modifica resta aperta. I nuovi check-in si sbloccano dopo il 70 % del mese.";
"Momentum & Streaks" = "Slancio e serie";
"Log a check-in to start a streak" = "Registra un check-in per iniziare una serie";
"Streak" = "Serie";
"On-time in a row" = "In tempo di fila";
"Best" = "Migliore";
"Personal best" = "Record personale";
"Avg early" = "Anticipo medio";
"vs deadline" = "vs scadenza";
"On-time score" = "Punteggio puntualità";
"Achievements" = "Obiettivi";
"View all achievements" = "Vedi tutti gli obiettivi";
"Monthly Pulse" = "Polso mensile";
"Optional" = "Opzionale";
"Rate this month" = "Valuta questo mese";
"How did it feel?" = "Come ti sei sentito?";
"Monthly Summary" = "Riepilogo mensile";
"Starting" = "Inizio";
"Ending" = "Fine";
"Contributions" = "Contributi";
"Net Performance" = "Performance netta";
"Update Sources" = "Aggiorna fonti";
"Add sources to start your monthly check-in." = "Aggiungi fonti per iniziare il tuo check-in mensile.";
"Updated this cycle" = "Aggiornato in questo ciclo";
"Needs update" = "Da aggiornare";
"Snapshot Notes" = "Note degli snapshot";
"No snapshot notes for this month." = "Nessuna nota snapshot per questo mese.";
"Source" = "Fonte";
"Your full portfolio,\nfully clear" = "Il tuo intero portafoglio,\ntutto chiaro";
"One payment. Every feature. Forever." = "Un pagamento. Ogni funzione. Per sempre.";
"Get Full Access" = "Ottieni accesso completo";
"Restore Purchases" = "Ripristina acquisti";
"Payment charged to your Apple ID account." = "Il pagamento verrà addebitato sul tuo account Apple ID.";
"Terms" = "Termini";
"Privacy" = "Privacy";
"· one-time · Family Sharing" = "· una tantum · In famiglia";
"Full access, one payment" = "Accesso completo, un solo pagamento";
"Unlimited sources, advanced charts & more" = "Fonti illimitate, grafici avanzati e altro";
"See full access" = "Vedi accesso completo";
"Batch Update" = "Aggiornamento in blocco";
"Current value" = "Valore attuale";
"Contribution this period (optional)" = "Contributo di questo periodo (opzionale)";
"Include Contribution" = "Includi contributo";
"New capital added" = "Nuovo capitale aggiunto";
"Contribution (Optional)" = "Contributo (opzionale)";
"Track new capital added to separate it from investment growth." = "Tieni traccia del nuovo capitale aggiunto per separarlo dalla crescita dell'investimento.";
"Monthly Highlights" = "Punti salienti del mese";
"Best Performer" = "Miglior performance";
"Worst Performer" = "Peggior performance";
"Best Contributor" = "Maggior contributore";
"Update Check-in" = "Aggiorna check-in";
"Completed %@" = "Completato %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Verifica…";
"Clear Filters" = "Cancella filtri";
"Force Upload to iCloud" = "Forza caricamento su iCloud";
"No export yet" = "Nessun export ancora";
"Not synced yet" = "Non ancora sincronizzato";
"Refresh" = "Aggiorna";
"Search sources" = "Cerca fonti";
"Syncing with iCloud..." = "Sincronizzazione con iCloud...";
"Uploading..." = "Caricamento...";
"Verify iCloud Setup" = "Verifica configurazione iCloud";
"add_source_name_footer" = "Una fonte è qualsiasi investimento che vuoi monitorare: azioni, ETF, conti di risparmio, immobili, crypto e altro.";
"add_source_name_placeholder" = "es. ETF MSCI World, Conto deposito, Appartamento...";
"categories_empty" = "Nessuna categoria ancora.";
"category_has_sources_warning" = "Non è possibile eliminare una categoria che ha fonti. Rimuovi o riassegna prima tutte le fonti.";
"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";
"contributions_vs_returns_title" = "Investito vs. Rendimento";
"csv_enter_value" = "Inserisci valore";
"csv_field_category" = "Categoria";
"csv_field_contribution" = "Contributo";
"csv_field_date" = "Data";
"csv_field_notes" = "Note";
"csv_field_source" = "Nome fonte (obbligatorio)";
"csv_field_value" = "Valore (obbligatorio)";
"csv_mapping_subtitle" = "Associa le colonne CSV ai campi di Portfolio Journal";
"csv_no_column" = "— Non mappato —";
"csv_no_date_hint" = "Tutte le righe saranno importate come snapshot di oggi.";
"csv_optional_section" = "Campi opzionali";
"csv_preview_section" = "Anteprima CSV";
"csv_required_section" = "Campi obbligatori";
"csv_use_today" = "Usa la data di oggi";
"goal_achieved_notification_body" = "Hai raggiunto il tuo obiettivo: %@";
"goal_achieved_notification_title" = "Obiettivo raggiunto! 🎉";
"goal_archive" = "Archivia";
"goal_delete_confirm" = "Elimina";
"goal_delete_message" = "Questo obiettivo sarà eliminato definitivamente. Questa azione non può essere annullata.";
"goal_delete_title" = "Elimina obiettivo";
"goal_unarchive" = "Ripristina";
"goals_all_active_achieved" = "Tutti gli obiettivi attivi sono stati raggiunti.";
"goals_empty_archived" = "Nessun obiettivo archiviato.";
"goals_filter_active" = "Attivi";
"goals_filter_all" = "Tutti";
"goals_filter_archived" = "Archiviati";
"icloud_check_description" = "Se hai dati su un altro dispositivo, abilita iCloud per ripristinarli qui.";
"icloud_check_title" = "Usi già Portfolio Journal?";
"icloud_enabled_description" = "Chiudi l'app e riaprila. I tuoi dati si caricheranno automaticamente da iCloud.";
"icloud_enabled_title" = "iCloud abilitato";
"monthly_checkin_notification_body" = "Registra i valori di questo mese e monitora la crescita del tuo portafoglio.";
"monthly_checkin_notification_title" = "È ora del tuo aggiornamento mensile";
"onboarding_add_first_source" = "Aggiungi il mio primo investimento";
"onboarding_import_data" = "Importa dati esistenti";
"onboarding_quickstart_subtitle" = "Aggiungi la tua prima fonte di investimento per iniziare a monitorare il portafoglio.";
"onboarding_quickstart_title" = "Quasi pronto";
"reengagement_body" = "Pochi minuti bastano per tenere sotto controllo i tuoi investimenti.";
"reengagement_goal_title" = "Sei sulla buona strada 📈";
"reengagement_goal_body" = "A questo ritmo raggiungerai \"%@\" entro %@. Guarda quanto manca.";
"reengagement_yoy_title" = "Guarda che crescita 👀";
"reengagement_yoy_body" = "Sei in rialzo del %@ nell'ultimo anno. Controlla l'andamento.";
"reengagement_month_title" = "Bel mese 📈";
"reengagement_month_body" = "Il tuo portafoglio è cresciuto di %@ questo mese. Dai un'occhiata.";
"reengagement_milestone_title" = "Ci sei quasi 🎯";
"reengagement_milestone_body" = "Sei quasi a %@. Quanto manca oggi?";
"reengagement_streak_title" = "Non perdere la serie 🔥";
"reengagement_streak_body" = "Hai registrato %d mesi di fila. Non fermarti ora.";
"reengagement_neutral_title" = "Novità? 👀";
"reengagement_neutral_body" = "C'è stato movimento nel tuo portafoglio. Guarda cosa è cambiato.";
"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";
"sources_filter_all" = "Tutte";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Il tuo riepilogo mensile del portafoglio";
"monthly_summary_notification_body" = "Controlla come hanno performato i tuoi investimenti questo mese.";
"streak_badge" = "Serie di %d mesi";
"goals_empty_add_cta" = "Aggiungi il mio primo obiettivo";
"journal_empty_title" = "Nessun registro ancora";
"journal_empty_body" = "Gli aggiornamenti degli snapshot creano automaticamente voci mensili. Aggiungi la tua prima fonte per iniziare.";
"quick_update_title" = "Aggiornamento rapido";
"quick_update_section_header" = "Inserisci i valori correnti";
"quick_update_section_footer" = "Verranno aggiornate solo le fonti con un valore inserito.";
"quick_update_placeholder" = "Nuovo valore";
"quick_update_save" = "Salva tutto";
"quick_update_no_sources" = "Nessuna fonte";
"quick_update_no_sources_body" = "Aggiungi prima fonti di investimento per usare l'aggiornamento rapido.";
"update_available_title" = "Aggiornamento disponibile";
"update_available_body" = "La versione %@ è disponibile sull'App Store.";
"whats_new_title" = "Novità in 1.4";
"whats_new_subtitle" = "Miglioramenti per aiutarti a restare in carreggiata.";
"whats_new_quick_update_title" = "Aggiornamento rapido del portafoglio";
"whats_new_quick_update_body" = "Aggiorna tutte le tue fonti da un'unica schermata con un tocco.";
"whats_new_streak_title" = "Serie di aggiornamenti";
"whats_new_streak_body" = "Tieni traccia di quanti mesi consecutivi hai tenuto aggiornato il tuo portafoglio.";
"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";
"insight_allocation_title" = "Scostamento allocazione";
"insight_allocation_over_value" = "%1$@ è al %2$.0f%% — sopra il tuo obiettivo del %3$.0f%%";
"insight_allocation_under_value" = "%1$@ è al %2$.0f%% — sotto il tuo obiettivo del %3$.0f%%";
"insight_allocation_detail" = "Riequilibra per restare nel piano";
"insight_concentration_title" = "Rischio di concentrazione";
"insight_concentration_value" = "%1$@ è il %2$.0f%% del tuo portafoglio";
"insight_concentration_detail" = "Valuta di diversificare";
"insight_best_performer_title" = "Miglior rendimento";
"insight_worst_performer_title" = "Richiede attenzione";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificazione";
"insight_diversification_single_value" = "Tutto in un'unica fonte — valuta di aggiungerne un'altra";
"insight_diversification_good_value" = "Distribuito su %d categorie";
"goal_projection_projected" = "Previsione: %@";
"goal_projection_add_contributions" = "Aggiungi versamenti per raggiungerlo";
"goal_chip_ahead" = "In anticipo";
"goal_chip_on_track" = "In linea";
"goal_chip_behind" = "In ritardo";
"goal_chip_at_this_pace" = "A questo ritmo…";
"notification_milestone_title" = "Traguardo raggiunto! 🎉";
"notification_milestone_body" = "Il tuo portafoglio ha appena superato %@. Complimenti!";
"streak_protection_notification_title" = "Non interrompere la tua serie 🔥";
"streak_protection_notification_body" = "Il check-in di questo mese è ancora in sospeso. Bastano 2 minuti per mantenere viva la serie.";
"paywall_benefit_accounts_title" = "Più account";
"paywall_benefit_accounts_subtitle" = "Portafogli separati per famiglia o lavoro";
"paywall_benefit_family_title" = "In famiglia";
"paywall_benefit_family_subtitle" = "Un acquisto, fino a 5 membri della famiglia";
"paywall_plan_annual" = "Annuale";
"paywall_plan_annual_per_year" = "%@ / anno";
"paywall_plan_lifetime" = "Per sempre";
"paywall_plan_lifetime_badge" = "MIGLIOR VALORE";
"paywall_trial_days" = "giorni";
"paywall_trial_weeks" = "settimane";
"paywall_trial_months" = "mesi";
"paywall_trial_years" = "anni";
"paywall_trial_format" = "%d %@ gratis";
"chart_history_locked" = "%d mesi di storico in più — sblocca con Premium";
"chart_group_overview" = "Panoramica";
"chart_group_analyze" = "Analisi";
"chart_group_risk" = "Rischio";
"chart_group_forecast" = "Previsione";
"kpi_total_value" = "Valore totale";
"kpi_period_return" = "Rendimento periodo";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "Volatilità";
"kpi_max_drawdown" = "Drawdown max";
"chart_zoom_in" = "Ingrandisci";
"chart_zoom_out" = "Riduci";
"chart_zoom_reset" = "Reimposta zoom";
"quick_update_paste_suggestion" = "Incolla %@ in %@";
"quick_update_next_badge" = "PROSSIMA";
// App Intents (1.4.2)
"intent_logged_dialog" = "%1$@ salvato per %2$@.";
"intent_amount_invalid" = "Limporto deve essere maggiore di zero. Quanto?";
"Log Portfolio Value" = "Registra valore del portafoglio";
"Record a value for one of your sources without opening the app." = "Registra un valore per una delle tue fonti senza aprire lapp.";
"Amount" = "Importo";
"How much?" = "Quanto?";
"Log Value" = "Registra valore";
"Update Portfolio" = "Aggiorna portafoglio";
"Check Portfolio" = "Controlla portafoglio";
// Chart share (1.4.2)
"chart_share_button" = "Condividi grafico";
"chart_share_text" = "Il mio grafico %@ — creato con Portfolio Journal";
"chart_share_tagline" = "Monitoraggio degli investimenti privato e sereno per iPhone e iPad.";
"chart_share_scan" = "Scansiona per scaricare";
"chart_locked_title" = "%@ è un grafico Premium";
"chart_locked_cta" = "Sblocca Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in di %@ completato";
"checkin_done_next" = "Prossimo: %@";
"checkin_next_due" = "Prossimo check-in: %@";
"checkin_days_left" = "%d giorni rimasti";
"checkin_due_today" = "Oggi";
"checkin_overdue" = "In ritardo";
"dash_chart_yoy" = "Anno su anno";
"home_chart_locked_sub" = "Sblocca con Premium per vedere questo grafico.";
"checkin_sources_progress" = "%1$d di %2$d fonti aggiornate";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mese scorso";
"guided_paste_amount" = "Incolla %@";
"guided_next" = "Avanti";
"guided_same_as_last" = "Come il mese scorso";
"guided_skip" = "Salta";
"guided_reflection_title" = "Come senti questo mese?";
"guided_reflection_subtitle" = "Una nota veloce per il te del futuro.";
"guided_note_placeholder" = "Una riga sincera su questo mese…";
"guided_finish" = "Concludi il check-in";
"guided_done_title" = "Check-in di %@ completato";
"guided_streak" = "%d mesi di fila";
"guided_share_month" = "Condividi questo mese";
"guided_done_cta" = "Fatto";
"journal_no_note" = "Ancora nessuna nota.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versione %@";
"whats_new_150_redesign_title" = "Un look più calmo e pulito";
"whats_new_150_redesign_body" = "Home ridisegnata con il tuo valore totale in primo piano, il check-in al centro e meno rumore ovunque.";
"whats_new_150_guided_title" = "Check-in mensile guidato";
"whats_new_150_guided_body" = "Una fonte per passo, delta in tempo reale, una riflessione veloce — il tuo rituale di 5 minuti, ripensato.";
"whats_new_150_rings_title" = "Anelli obiettivi & timeline";
"whats_new_150_rings_body" = "Anelli di progresso stile Fitness per gli obiettivi e una timeline della tua storia da investitore.";
"whats_new_142_ocr_title" = "Aggiorna da uno screenshot";
"whats_new_142_ocr_body" = "Condividi uno screenshot della tua banca — il saldo viene riconosciuto sul dispositivo, nulla lascia il tuo iPhone.";
"whats_new_142_siri_title" = "Siri nella tua lingua";
"whats_new_142_siri_body" = "\"Registra il saldo di Indexa\" — registra valori con la voce o dai Comandi rapidi, senza aprire l'app.";
"whats_new_142_charts_title" = "Grafici rinnovati";
"whats_new_142_charts_body" = "Barra laterale nativa su iPad, KPI su misura per ogni grafico e condivisione come belle immagini.";
"whats_new_141_extension_title" = "Aggiorna senza cambiare app";
"whats_new_141_extension_body" = "Condividi un valore dall'app della tua banca direttamente in Portfolio Journal con la nuova estensione.";
"whats_new_141_ipad_title" = "Meglio su iPad";
"whats_new_141_ipad_body" = "Grafici ridisegnati per lo schermo grande, con zoom ed etichette dei valori.";
"whats_new_141_icloud_title" = "Più sincronizzato";
"whats_new_141_icloud_body" = "I contributi mensili e gli obiettivi di allocazione ora si sincronizzano via iCloud.";
"whats_new_title_format" = "Novità della %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Aggiorna tutto in pochi secondi";
"tip_quick_update_body" = "Quick Update elenca tutte le tue fonti in una schermata — incolla i valori dagli appunti e via.";
"tip_ocr_title" = "Oppure condividi uno screenshot";
"tip_ocr_body" = "Dall'app della tua banca: screenshot → Condividi → Portfolio Journal. Il saldo viene letto sul dispositivo.";
"tip_siri_title" = "La prossima volta prova con la voce";
"tip_siri_body" = "Di' a Siri \"Registra il saldo del mio conto in Portfolio Journal\" — senza aprire l'app.";
"tip_chart_share_title" = "Condividi questo grafico";
"tip_chart_share_body" = "Genera una bella immagine con i tuoi dati, pronta per X o WhatsApp.";
"allocation_total" = "Totale";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "iCloud ha rifiutato alcuni record. Di solito significa che lo schema iCloud è ancora in configurazione — spesso si risolve in poche ore o dopo il prossimo aggiornamento.";
"icloud_hint_quota" = "Il tuo spazio iCloud è pieno. Libera spazio e riprova.";
"icloud_hint_network" = "Impossibile raggiungere iCloud. Controlla la connessione e riprova.";
"icloud_hint_partial_generic" = "Alcuni record non si sono sincronizzati stavolta. L'app riproverà automaticamente.";
"icloud_status_error" = "Problema di sincronizzazione iCloud";
"icloud_status_syncing" = "Sincronizzazione con iCloud";
"icloud_show_details" = "Mostra dettagli tecnici";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Anno migliore";
"yoy_worst_year" = "Anno peggiore";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Incolla %@";
"quick_update_scan" = "Scansiona";
"quick_update_done_kbd" = "Fine";
"quick_update_scanning" = "Lettura immagine…";
"quick_update_pick_amount" = "Quale importo?";
"quick_update_no_amount_found" = "Nessun importo riconosciuto nellimmagine.";
"icloud_copy_diagnostics" = "Copia diagnostica iCloud";
"icloud_diagnostics_copied" = "Diagnostica copiata negli appunti";
"Smooth Gaps in Charts" = "Uniforma i vuoti nei grafici";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Mancano i dati di %d mesi";
"gaps_missing_count" = "%d mancanti";
"gaps_range_between" = "Tra %@ e %@";
"Fill in missing data" = "Completa i dati mancanti";
"Missing Data" = "Dati mancanti";
"Fill Gap" = "Colma il vuoto";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "Il grafico stima questi mesi. Aggiungi valori reali per mantenere accurata la cronologia.";
"Dismiss" = "Ignora";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d mesi";
"Monthly Summary" = "Riepilogo mensile";
"Portfolio Value" = "Valore del portafoglio";
"Tracked with" = "Registrato con";
"Share monthly summary" = "Condividi il riepilogo mensile";
"since last check-in" = "dallultimo check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Niente di nuovo da importare. I tuoi dati sono già aggiornati.";
"import_preview_sources" = "Crea %d fonti";
"import_preview_snapshots_new" = "Crea %d rilevazioni";
"import_preview_snapshots_update" = "Aggiorna %d rilevazioni";
"import_preview_categories" = "Crea %d categorie";
"import_preview_goals" = "Crea %d obiettivi";
"import_preview_journal_new" = "Crea %d voci del diario";
"import_preview_journal_update" = "Aggiorna %d voci del diario";
"Review Import" = "Rivedi importazione";
"Reviewing import…" = "Revisione importazione…";
"Import" = "Importa";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Stimato";
"gaps_autofill_cta" = "Riempi %d mesi";
"Add manually" = "Aggiungi manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "Il riempimento automatico stima i mesi mancanti tra due snapshot (contrassegnati come stimati). Oppure inserisci tu i valori reali.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Backup automatico";
"Off" = "Disattivato";
"Daily" = "Giornaliero";
"Weekly" = "Settimanale";
"Monthly" = "Mensile";
"Last auto backup" = "Ultimo backup automatico";
/* 1.6.0 hide balances */
"Hide balances" = "Nascondi saldi";
"Show balances" = "Mostra saldi";
"Balance hidden" = "Saldo nascosto";
"Reveal your balances" = "Mostra i tuoi saldi";
"Privacy" = "Privacy";
"Hide Balances" = "Nascondi saldi";
"Require Face ID to Reveal" = "Richiedi Face ID per mostrare";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Nasconde tutti gli importi dietro una sfocatura. Ideale per usare l'app in pubblico o fare screenshot. Facoltativamente richiedi Face ID per mostrarli.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Non hai ancora registrato alcun valore di portafoglio.";
"intent_networth_dialog" = "Il tuo patrimonio è %@.";
"intent_streak" = "Sei a una serie di %d mesi.";
"intent_streak_with_date" = "Sei a una serie di %d mesi. Il tuo ultimo check-in è stato il %@.";
"intent_streak_none" = "Non hai ancora una serie attiva. Registra un valore per iniziarne una.";
// Charts workable (1.6.0)
"chart_range_button" = "Intervallo personalizzato";
"chart_range_from" = "Da";
"chart_range_to" = "A";
"chart_filtered_prefix" = "Filtrato: %@";
"chart_overlay_contributions" = "vs Versamenti";
"chart_insight_best_month" = "Miglior mese del periodo: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Condividi i tuoi progressi";
"share_percent_only" = "Solo percentuali";
"share_format_post" = "Post";
"share_format_story" = "Storia";
"share_card_cta" = "Condividi";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Rendimento totale";
"share_metric_yoy" = "Quest'anno";
"share_metric_since_inception" = "Dall'inizio";
"share_cta_headline" = "Monitora il tuo — gratis";
"share_cta_sub" = "Scansiona per Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Al %@";
"share_theme_dark" = "Scuro";
"share_theme_light" = "Chiaro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valore di oggi";
"add_source_value_footer" = "Salvato come primo check-in: i tuoi progressi iniziano oggi.";
"quick_update_add_first_source" = "Aggiungi la tua prima fonte";
@@ -0,0 +1,679 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "キャンセル";
"save" = "保存";
"delete" = "削除";
"edit" = "編集";
"add" = "追加";
"done" = "完了";
"close" = "閉じる";
"continue" = "続ける";
"skip" = "スキップ";
"error" = "エラー";
"success" = "成功";
"loading" = "読み込み中...";
"loading_data" = "データを読み込み中...";
"tab_dashboard" = "ホーム";
"tab_sources" = "ソース";
"tab_charts" = "チャート";
"tab_settings" = "設定";
"dashboard_title" = "ホーム";
"total_portfolio_value" = "ポートフォリオ合計額";
"today" = "今日";
"returns" = "リターン";
"by_category" = "カテゴリ別";
"pending_updates" = "未更新";
"see_all" = "すべて表示";
"sources_title" = "ソース";
"add_source" = "ソースを追加";
"source_name" = "ソース名";
"select_category" = "カテゴリを選択";
"initial_value" = "初期金額";
"initial_value_optional" = "初期金額(任意)";
"reminder_frequency" = "リマインダー頻度";
"source_limit_warning" = "ソース数の上限に達しました。Premium にアップグレードすると無制限になります。";
"no_sources" = "投資ソースがありません";
"no_sources_message" = "最初の投資ソースを追加してポートフォリオの追跡を始めましょう。";
"add_snapshot" = "スナップショットを追加";
"edit_snapshot" = "スナップショットを編集";
"snapshot_date" = "日付";
"snapshot_value" = "金額";
"snapshot_contribution" = "追加資金";
"contribution_optional" = "追加資金(任意)";
"notes" = "メモ";
"notes_optional" = "メモ(任意)";
"previous_value" = "前回: %@";
"change_from_previous" = "前回からの変化";
"charts_title" = "チャート";
"evolution" = "推移";
"allocation" = "配分";
"performance" = "パフォーマンス";
"drawdown" = "ドローダウン";
"volatility" = "ボラティリティ";
"prediction" = "予測";
"portfolio_evolution" = "ポートフォリオ推移";
"asset_allocation" = "資産配分";
"performance_by_category" = "カテゴリ別パフォーマンス";
"drawdown_analysis" = "ドローダウン分析";
"prediction_12_month" = "12か月予測";
"not_enough_data" = "データが不足しています";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "最大ドローダウン";
"sharpe_ratio" = "シャープレシオ";
"win_rate" = "勝率";
"avg_monthly" = "月平均";
"best_month" = "最高の月";
"worst_month" = "最悪の月";
"premium" = "Premium";
"upgrade_to_premium" = "Premium にアップグレード";
"unlock_full_potential" = "すべての機能を解放";
"one_time_purchase" = "買い切り";
"includes_family_sharing" = "ファミリー共有対応";
"upgrade_now" = "今すぐアップグレード";
"restore_purchases" = "購入を復元";
"premium_active" = "Premium 有効";
"premium_feature" = "Premium 機能";
"unlock" = "ロック解除";
"feature_unlimited_sources" = "無制限のソース";
"feature_unlimited_sources_desc" = "好きなだけ投資を追跡できます";
"feature_full_history" = "完全な履歴";
"feature_full_history_desc" = "投資履歴をすべて確認できます";
"feature_advanced_charts" = "高度なチャート";
"feature_advanced_charts_desc" = "詳細分析チャート 5 種類";
"feature_predictions" = "予測";
"feature_predictions_desc" = "AI による12か月予測";
"feature_export" = "データを書き出し";
"feature_export_desc" = "CSV と JSON に書き出し";
"feature_no_ads" = "広告なし";
"feature_no_ads_desc" = "ずっと広告なしで使えます";
"paywall_benefit_history_title" = "完全な履歴";
"paywall_benefit_history_subtitle" = "初日からのすべての snapshot、追加資金、利益を確認";
"paywall_benefit_charts_title" = "傾向が見えるチャート";
"paywall_benefit_charts_subtitle" = "配分、ドローダウン、パフォーマンスを一か所で確認";
"paywall_benefit_forecasts_title" = "12か月予測";
"paywall_benefit_forecasts_subtitle" = "ポートフォリオの行き先を見通せます";
"paywall_benefit_noads_title" = "広告は一切なし";
"paywall_benefit_noads_subtitle" = "気が散らない、集中できる体験";
"settings_title" = "設定";
"subscription" = "サブスクリプション";
"notifications" = "通知";
"default_reminder_time" = "デフォルトの通知時刻";
"data" = "データ";
"export_data" = "データを書き出し";
"total_sources" = "ソース合計";
"total_snapshots" = "スナップショット合計";
"storage_used" = "使用ストレージ";
"about" = "このアプリについて";
"version" = "バージョン";
"privacy_policy" = "プライバシーポリシー";
"terms_of_service" = "利用規約";
"support" = "サポート";
"rate_app" = "アプリを評価";
"danger_zone" = "危険ゾーン";
"reset_all_data" = "すべてのデータをリセット";
"reset_confirmation" = "すべての投資データが完全に削除されます。この操作は元に戻せません。";
"frequency_monthly" = "毎月";
"frequency_quarterly" = "四半期ごと";
"frequency_semiannual" = "半年ごと";
"frequency_annual" = "毎年";
"frequency_custom" = "カスタム";
"frequency_never" = "なし";
"every_n_months" = "%d か月ごと";
"category_stocks" = "株式";
"category_bonds" = "債券";
"category_real_estate" = "不動産";
"category_crypto" = "暗号資産";
"category_cash" = "現金";
"category_etfs" = "ETF";
"category_retirement" = "退職資産";
"category_other" = "その他";
"uncategorized" = "未分類";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1Y";
"time_all" = "すべて";
"export_format" = "形式を選択";
"export_csv" = "CSV";
"export_csv_desc" = "Excel、Google Sheets に対応";
"export_json" = "JSON";
"export_json_desc" = "バックアップ用の完全なデータ構造";
"onboarding_track_title" = "投資をまとめて管理";
"onboarding_track_desc" = "株式、債券、不動産、暗号資産など、すべての投資ソースを一か所で追跡できます。";
"onboarding_visualize_title" = "成長を可視化";
"onboarding_visualize_desc" = "美しいチャートでポートフォリオの推移、配分、パフォーマンスを確認できます。";
"onboarding_reminders_title" = "更新を忘れない";
"onboarding_reminders_desc" = "毎月、四半期、またはカスタムで、定期的に投資を記録するためのリマインダーを設定できます。";
"onboarding_sync_title" = "どこでも同期";
"onboarding_sync_desc" = "データは iCloud で自動同期され、すべての Apple デバイスで利用できます。";
"get_started" = "始める";
"onboarding_clarity_title" = "今の立ち位置がすぐ分かる";
"onboarding_clarity_desc" = "総資産、実質リターン、配分をいつでも最新の状態で確認できます。";
"onboarding_habit_title" = "月に5分で十分";
"onboarding_habit_desc" = "月に一度金額を入力するだけ。計算は Portfolio Journal が行い、進捗を見せてくれます。";
"onboarding_calm_title" = "ノイズを無視して、流れを見る。";
"onboarding_calm_desc" = "日々の値動きは本当の物語ではありません。数か月、数年の成長こそが大切です。";
"onboarding_goals_title" = "お金の目標に近づく";
"onboarding_goals_desc" = "目標を設定し、節目を追跡し、どこまで進んだかを把握できます。";
"error_generic" = "エラーが発生しました。もう一度お試しください。";
"error_no_purchases" = "復元できる購入が見つかりません";
"error_purchase_failed" = "購入に失敗しました: %@";
"error_export_failed" = "書き出しに失敗しました。もう一度お試しください。";
"placeholder_source_name" = "例: Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "メモを追加...";
"mood_energized_title" = "絶好調";
"mood_confident_title" = "自信あり";
"mood_balanced_title" = "安定";
"mood_cautious_title" = "慎重";
"mood_stressed_title" = "ストレス";
"mood_energized_detail" = "無敵な気分";
"mood_confident_detail" = "順調で落ち着いている";
"mood_balanced_detail" = "冷静で辛抱強い";
"mood_cautious_detail" = "動きを見守っている";
"mood_stressed_detail" = "一度リセットしたい";
"achievement_streak_3_title" = "3か月連続";
"achievement_streak_3_detail" = "3か月連続で期限内にチェックインしました。";
"achievement_streak_6_title" = "半年連続";
"achievement_streak_6_detail" = "6回連続で期限内にチェックインしました。";
"achievement_streak_12_title" = "1年の勢い";
"achievement_streak_12_detail" = "1年間、一度も期限を逃しませんでした。";
"achievement_perfect_on_time_title" = "一度も遅れなし";
"achievement_perfect_on_time_detail" = "すべてのチェックインが期限前に完了しました。";
"achievement_clutch_finish_title" = "ギリギリ成功";
"achievement_clutch_finish_detail" = "残り数時間で提出、それでも期限内。";
"achievement_early_bird_title" = "早め派";
"achievement_early_bird_detail" = "平均するとかなり余裕を持って終えています。";
"achievements_title" = "実績";
"achievements_view_all" = "すべての実績を見る";
"achievements_nav_title" = "実績";
"achievements_progress_title" = "進捗";
"achievements_unlocked_title" = "解除済み";
"achievements_unlocked_empty" = "チェックインを完了して実績を解除しましょう。";
"achievements_locked_title" = "未解除";
"achievements_locked_empty" = "すべての実績を解除しました。すばらしいです。";
"rating_accessibility" = "評価 %d / 5";
"achievements_unlocked_count" = "%d / %d を解除";
"last_check_in" = "前回のチェックイン: %@";
"next_check_in" = "次回のチェックイン: %@";
"on_time_rate" = "%@ が期限内";
"on_time_count" = "%d/%d が期限内";
"tightest_finish" = "最もギリギリだった完了: 締切の %@ 前。";
"date_today" = "今日";
"date_yesterday" = "昨日";
"date_never" = "なし";
"calendar_event_title" = "%@: 月次チェックイン";
"calendar_event_notes" = "%@ を開いて月次チェックインを完了してください。";
"checkin_enjoying_dialog_title" = "Portfolio Journal をどのくらい気に入っていますか?";
"checkin_enjoying_dialog_message" = "新しい実績おめでとうございます。ご意見は改善に役立ちます。";
"not_now" = "今はしない";
"rating_1_star" = "1つ星";
"rating_n_stars" = "%dつ星";
"app_store_review_title" = "App Store にレビューを残しますか?";
"app_store_review_message" = "5つ星ありがとうございます。ほかの投資家がアプリを見つけやすくなります。";
"write_review" = "レビューを書く";
"save_1_snapshot" = "1件の snapshot を保存";
"save_n_snapshots" = "%d件の snapshot を保存";
"checkin_update_month" = "%@ を更新";
"checkin_start_new" = "開始";
"Home" = "ホーム";
"Sources" = "ソース";
"Charts" = "チャート";
"Settings" = "設定";
"Journal" = "ジャーナル";
"Search monthly notes" = "月次メモを検索";
"Monthly Check-ins" = "月次チェックイン";
"No monthly notes yet." = "まだ月次メモがありません。";
"No matching notes." = "一致するメモがありません。";
"Jump to month" = "月へ移動";
"Today" = "今日";
"Mood not set" = "気分未設定";
"No rating" = "評価なし";
"No note yet." = "まだメモがありません。";
"Monthly Note" = "月次メモ";
"Open Full Note" = "全文を開く";
"Duplicate Previous" = "前回を複製";
"Save" = "保存";
"Monthly Check-in" = "月次チェックイン";
"This Month" = "今月";
"No check-in yet this month" = "今月はまだチェックインがありません";
"Start your first check-in anytime." = "いつでも最初のチェックインを始められます。";
"Mark Check-in Complete" = "チェックインを完了にする";
"Editing stays open. New check-ins unlock after 70% of the month." = "編集は開いたままです。新しいチェックインは月の70%経過後に解放されます。";
"Momentum & Streaks" = "勢いと連続記録";
"Log a check-in to start a streak" = "チェックインを記録して連続記録を始めましょう";
"Streak" = "連続記録";
"On-time in a row" = "連続期限内";
"Best" = "最高";
"Personal best" = "自己ベスト";
"Avg early" = "平均余裕";
"vs deadline" = "締切比";
"On-time score" = "期限内スコア";
"Achievements" = "実績";
"View all achievements" = "すべての実績を見る";
"Monthly Pulse" = "月次パルス";
"Optional" = "任意";
"Rate this month" = "今月を評価";
"How did it feel?" = "どんな気分でしたか?";
"Monthly Summary" = "月次サマリー";
"Starting" = "開始";
"Ending" = "終了";
"Contributions" = "追加資金";
"Net Performance" = "純パフォーマンス";
"Update Sources" = "ソースを更新";
"Add sources to start your monthly check-in." = "月次チェックインを始めるにはソースを追加してください。";
"Updated this cycle" = "このサイクルで更新済み";
"Needs update" = "更新が必要";
"Snapshot Notes" = "スナップショットのメモ";
"No snapshot notes for this month." = "今月のスナップショットメモはありません。";
"Source" = "ソース";
"Your full portfolio,\nfully clear" = "あなたのポートフォリオ全体を、\nもっと明確に";
"One payment. Every feature. Forever." = "一度の支払い。すべての機能を。ずっと。";
"Get Full Access" = "フルアクセスを取得";
"Restore Purchases" = "購入を復元";
"Payment charged to your Apple ID account." = "料金は Apple ID アカウントに請求されます。";
"Terms" = "利用規約";
"Privacy" = "プライバシー";
"· one-time · Family Sharing" = "· 買い切り · ファミリー共有";
"Full access, one payment" = "フルアクセスを一度の支払いで";
"Unlimited sources, advanced charts & more" = "無制限のソース、高度なチャートなど";
"See full access" = "フルアクセスを見る";
"Batch Update" = "一括更新";
"Current value" = "現在の金額";
"Contribution this period (optional)" = "今回の追加資金(任意)";
"Include Contribution" = "追加資金を含める";
"New capital added" = "新たに追加した資金";
"Contribution (Optional)" = "追加資金(任意)";
"Track new capital added to separate it from investment growth." = "投資の成長と分けるため、新たに追加した資金を記録します。";
"Monthly Highlights" = "今月のハイライト";
"Best Performer" = "最高のパフォーマー";
"Worst Performer" = "最も低調だったもの";
"Best Contributor" = "最大の貢献元";
"Update Check-in" = "チェックインを更新";
"Completed %@" = "%@ に完了";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "確認中…";
"Clear Filters" = "フィルターをクリア";
"Force Upload to iCloud" = "iCloudへ強制アップロード";
"No export yet" = "まだエクスポートなし";
"Not synced yet" = "まだ同期されていません";
"Refresh" = "更新";
"Search sources" = "ソースを検索";
"Syncing with iCloud..." = "iCloudと同期中...";
"Uploading..." = "アップロード中...";
"Verify iCloud Setup" = "iCloud設定を確認";
"add_source_name_footer" = "ソースとは追跡したい投資のことです:株式、ETF、普通預金、不動産、暗号資産など。";
"add_source_name_placeholder" = "例:MSCI World ETF、普通預金、マンション...";
"categories_empty" = "カテゴリーがまだありません。";
"category_has_sources_warning" = "ソースがあるカテゴリーは削除できません。先にすべてのソースを削除または再割り当てしてください。";
"category_name_placeholder" = "例:緊急資金";
"chart_yoy_empty" = "比較を表示するには、異なる年のスナップショットを追加してください。";
"chart_yoy_title" = "年次比較";
"chart_yoy_estimated_note" = "* 推定(年末予測)";
"checking_icloud" = "iCloudを確認中...";
"contributions_vs_returns_invested" = "投資額";
"contributions_vs_returns_returns" = "市場リターン";
"contributions_vs_returns_title" = "投資額 vs. リターン";
"csv_enter_value" = "値を入力";
"csv_field_category" = "カテゴリー";
"csv_field_contribution" = "追加投資";
"csv_field_date" = "日付";
"csv_field_notes" = "メモ";
"csv_field_source" = "ソース名(必須)";
"csv_field_value" = "値(必須)";
"csv_mapping_subtitle" = "CSVの列をPortfolio Journalのフィールドに対応付けてください";
"csv_no_column" = "— 未マッピング —";
"csv_no_date_hint" = "すべての行が今日のスナップショットとしてインポートされます。";
"csv_optional_section" = "任意フィールド";
"csv_preview_section" = "CSVプレビュー";
"csv_required_section" = "必須フィールド";
"csv_use_today" = "今日の日付を使用";
"goal_achieved_notification_body" = "目標を達成しました:%@";
"goal_achieved_notification_title" = "目標達成! 🎉";
"goal_archive" = "アーカイブ";
"goal_delete_confirm" = "削除";
"goal_delete_message" = "この目標は完全に削除されます。この操作は取り消せません。";
"goal_delete_title" = "目標を削除";
"goal_unarchive" = "元に戻す";
"goals_all_active_achieved" = "すべてのアクティブな目標が達成されました。";
"goals_empty_archived" = "アーカイブされた目標はありません。";
"goals_filter_active" = "アクティブ";
"goals_filter_all" = "すべて";
"goals_filter_archived" = "アーカイブ済み";
"icloud_check_description" = "他のデバイスにデータがある場合は、iCloudを有効にしてここで復元してください。";
"icloud_check_title" = "すでにPortfolio Journalをお使いですか?";
"icloud_enabled_description" = "アプリを閉じて再度開いてください。データはiCloudから自動的に読み込まれます。";
"icloud_enabled_title" = "iCloudが有効になりました";
"monthly_checkin_notification_body" = "今月の値を記録して、ポートフォリオの成長を確認しましょう。";
"monthly_checkin_notification_title" = "月次アップデートの時間です";
"onboarding_add_first_source" = "最初の投資を追加";
"onboarding_import_data" = "既存のデータをインポート";
"onboarding_quickstart_subtitle" = "最初の投資ソースを追加して、ポートフォリオの追跡を始めましょう。";
"onboarding_quickstart_title" = "もうすぐ完了";
"reengagement_body" = "数分で投資の状況を把握できます。";
"reengagement_goal_title" = "順調です 📈";
"reengagement_goal_body" = "このペースなら%2$@までに「%1$@」に到達します。あとどれくらいか見てみましょう。";
"reengagement_yoy_title" = "この成長を見て 👀";
"reengagement_yoy_body" = "この1年で%@上昇しています。トレンドをチェックしましょう。";
"reengagement_month_title" = "好調な月 📈";
"reengagement_month_body" = "今月、ポートフォリオが%@増えました。ちょっと見てみましょう。";
"reengagement_milestone_title" = "あと少し 🎯";
"reengagement_milestone_body" = "もうすぐ%@です。今日はどこまで来たでしょう?";
"reengagement_streak_title" = "連続記録を継続 🔥";
"reengagement_streak_body" = "%dか月連続で記録しています。ここでやめないで。";
"reengagement_neutral_title" = "何か変化は? 👀";
"reengagement_neutral_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" = "スナップショットがすでに存在します";
"sources_filter_all" = "すべて";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "毎月のポートフォリオサマリー";
"monthly_summary_notification_body" = "今月の投資パフォーマンスを確認しましょう。";
"streak_badge" = "%dヶ月連続";
"goals_empty_add_cta" = "最初の目標を追加";
"journal_empty_title" = "まだ記録がありません";
"journal_empty_body" = "スナップショットの更新により月次エントリが自動的に作成されます。最初のソースを追加して始めましょう。";
"quick_update_title" = "クイック更新";
"quick_update_section_header" = "現在の値を入力";
"quick_update_section_footer" = "値が入力されたソースのみ更新されます。";
"quick_update_placeholder" = "新しい値";
"quick_update_save" = "すべて保存";
"quick_update_no_sources" = "ソースなし";
"quick_update_no_sources_body" = "クイック更新を使用するには、まず投資ソースを追加してください。";
"update_available_title" = "アップデートあり";
"update_available_body" = "バージョン%@がApp Storeで利用可能です。";
"whats_new_title" = "1.4の新機能";
"whats_new_subtitle" = "軌道を保つための改善点。";
"whats_new_quick_update_title" = "ポートフォリオのクイック更新";
"whats_new_quick_update_body" = "一つの画面からすべてのソースをワンタップで更新できます。";
"whats_new_streak_title" = "更新ストリーク";
"whats_new_streak_body" = "何ヶ月連続でポートフォリオを最新の状態に保てたか追跡します。";
"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" = "目標に向けて順調";
"insight_allocation_title" = "配分のずれ";
"insight_allocation_over_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を上回っています";
"insight_allocation_under_value" = "%1$@は%2$.0f%% — 目標の%3$.0f%%を下回っています";
"insight_allocation_detail" = "計画に合わせてリバランスしましょう";
"insight_concentration_title" = "集中リスク";
"insight_concentration_value" = "%1$@がポートフォリオの%2$.0f%%を占めています";
"insight_concentration_detail" = "分散を検討しましょう";
"insight_best_performer_title" = "最も好調";
"insight_worst_performer_title" = "要注意";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "分散";
"insight_diversification_single_value" = "1つの資産に集中 — 追加を検討しましょう";
"insight_diversification_good_value" = "%d個のカテゴリに分散";
"goal_projection_projected" = "予測: %@";
"goal_projection_add_contributions" = "積立を追加して達成しましょう";
"goal_chip_ahead" = "前倒し";
"goal_chip_on_track" = "順調";
"goal_chip_behind" = "遅れ気味";
"goal_chip_at_this_pace" = "このペースだと…";
"notification_milestone_title" = "ポートフォリオ達成!🎉";
"notification_milestone_body" = "ポートフォリオが%@を突破しました。おめでとうございます!";
"streak_protection_notification_title" = "連続記録を守ろう 🔥";
"streak_protection_notification_body" = "今月のチェックインがまだ完了していません。2分で連続記録を守れます。";
"paywall_benefit_accounts_title" = "複数アカウント";
"paywall_benefit_accounts_subtitle" = "家族用・事業用にポートフォリオを分けて管理";
"paywall_benefit_family_title" = "ファミリー共有";
"paywall_benefit_family_subtitle" = "1回の購入で家族5人まで利用可能";
"paywall_plan_annual" = "年額";
"paywall_plan_annual_per_year" = "%@ / 年";
"paywall_plan_lifetime" = "買い切り";
"paywall_plan_lifetime_badge" = "ベストバリュー";
"paywall_trial_days" = "日間";
"paywall_trial_weeks" = "週間";
"paywall_trial_months" = "か月";
"paywall_trial_years" = "年間";
"paywall_trial_format" = "%d%@無料";
"chart_history_locked" = "さらに%dか月分の履歴 — プレミアムで解除";
"chart_group_overview" = "概要";
"chart_group_analyze" = "分析";
"chart_group_risk" = "リスク";
"chart_group_forecast" = "予測";
"kpi_total_value" = "合計金額";
"kpi_period_return" = "期間リターン";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "ボラティリティ";
"kpi_max_drawdown" = "最大下落率";
"chart_zoom_in" = "拡大";
"chart_zoom_out" = "縮小";
"chart_zoom_reset" = "ズームをリセット";
"quick_update_paste_suggestion" = "%@ を %@ に貼り付け";
"quick_update_next_badge" = "次";
// App Intents (1.4.2)
"intent_logged_dialog" = "%2$@に%1$@を保存しました。";
"intent_amount_invalid" = "金額はゼロより大きい必要があります。いくらですか?";
"Log Portfolio Value" = "ポートフォリオの金額を記録";
"Record a value for one of your sources without opening the app." = "アプリを開かずにソースの金額を記録します。";
"Amount" = "金額";
"How much?" = "いくらですか?";
"Log Value" = "金額を記録";
"Update Portfolio" = "ポートフォリオを更新";
"Check Portfolio" = "ポートフォリオを確認";
// Chart share (1.4.2)
"chart_share_button" = "チャートを共有";
"chart_share_text" = "私の%@チャート — Portfolio Journalで記録";
"chart_share_tagline" = "iPhoneとiPadのためのプライベートで穏やかな資産管理。";
"chart_share_scan" = "スキャンしてダウンロード";
"chart_locked_title" = "%@はプレミアムチャートです";
"chart_locked_cta" = "プレミアムを解除";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "%@のチェックイン完了";
"checkin_done_next" = "次回:%@";
"checkin_next_due" = "次回のチェックイン:%@";
"checkin_days_left" = "あと%d日";
"checkin_due_today" = "本日期限";
"checkin_overdue" = "期限切れ";
"dash_chart_yoy" = "年別比較";
"home_chart_locked_sub" = "Premium で解除してこのチャートを表示できます。";
"checkin_sources_progress" = "%2$d件中%1$d件のソースを更新済み";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "先月";
"guided_paste_amount" = "%@を貼り付け";
"guided_next" = "次へ";
"guided_same_as_last" = "先月と同じ";
"guided_skip" = "スキップ";
"guided_reflection_title" = "今月はどんな気分ですか?";
"guided_reflection_subtitle" = "未来の自分への簡単なメモ。";
"guided_note_placeholder" = "今月について正直な一言…";
"guided_finish" = "チェックインを完了";
"guided_done_title" = "%@のチェックイン完了";
"guided_streak" = "%dか月連続";
"guided_share_month" = "今月をシェア";
"guided_done_cta" = "完了";
"journal_no_note" = "まだメモがありません。";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "バージョン%@";
"whats_new_150_redesign_title" = "より穏やかで洗練されたデザイン";
"whats_new_150_redesign_body" = "ホームを刷新。総資産を主役に、チェックインを中心に、あらゆる場所のノイズを削減しました。";
"whats_new_150_guided_title" = "ガイド付き月次チェックイン";
"whats_new_150_guided_body" = "1画面1ソース、リアルタイム差分、簡単な振り返り — 月5分の習慣を再設計しました。";
"whats_new_150_rings_title" = "目標リングとジャーナルタイムライン";
"whats_new_150_rings_body" = "フィットネス風の進捗リングと、投資の歩みを綴るムードタイムライン。";
"whats_new_142_ocr_title" = "スクリーンショットから更新";
"whats_new_142_ocr_body" = "銀行アプリのスクリーンショットを共有するだけ。残高はデバイス上で認識され、外部には送信されません。";
"whats_new_142_siri_title" = "Siriに対応";
"whats_new_142_siri_body" = "「Indexaの残高を記録」— アプリを開かずに音声やショートカットで記録できます。";
"whats_new_142_charts_title" = "チャートを一新";
"whats_new_142_charts_body" = "iPadのネイティブサイドバー、チャートごとの最適なKPI、美しい画像での共有ボタン。";
"whats_new_141_extension_title" = "アプリを切り替えずに更新";
"whats_new_141_extension_body" = "新しい共有拡張機能で、銀行アプリから直接Portfolio Journalへ値を送れます。";
"whats_new_141_ipad_title" = "iPadでより快適に";
"whats_new_141_ipad_body" = "大画面向けにチャートを再設計。ピンチズームと値ラベルに対応。";
"whats_new_141_icloud_title" = "さらに同期";
"whats_new_141_icloud_body" = "毎月の積立額と配分目標がiCloudでデバイス間同期されるようになりました。";
"whats_new_title_format" = "バージョン%@の新機能";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "数秒ですべて更新";
"tip_quick_update_body" = "クイック更新なら全ソースを1画面で。クリップボードから値を貼り付けるだけ。";
"tip_ocr_title" = "スクリーンショットの共有でもOK";
"tip_ocr_body" = "銀行アプリでスクショ → 共有 → Portfolio Journal。残高はデバイス上で読み取られます。";
"tip_siri_title" = "次は声で試してみて";
"tip_siri_body" = "Siriに「Portfolio Journalで口座の残高を記録」と話しかけるだけ。アプリを開く必要はありません。";
"tip_chart_share_title" = "このチャートを共有";
"tip_chart_share_body" = "あなたのデータで美しい画像を生成。XやWhatsAppにそのまま共有できます。";
"allocation_total" = "合計";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "一部のレコードがiCloudに拒否されました。多くの場合iCloudのスキーマ設定が完了していないためで、数時間の使用後または次回のアップデートで解消されます。";
"icloud_hint_quota" = "iCloudの容量がいっぱいです。空き容量を確保して再試行してください。";
"icloud_hint_network" = "iCloudに接続できませんでした。接続を確認して再試行してください。";
"icloud_hint_partial_generic" = "今回一部のレコードを同期できませんでした。アプリが自動的に再試行します。";
"icloud_status_error" = "iCloud同期の問題";
"icloud_status_syncing" = "iCloudと同期中";
"icloud_show_details" = "技術的な詳細を表示";
"yoy_vs_prev" = "%@比";
"yoy_best_year" = "最高の年";
"yoy_worst_year" = "最悪の年";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "%@を貼り付け";
"quick_update_scan" = "スキャン";
"quick_update_done_kbd" = "完了";
"quick_update_scanning" = "画像を読み取り中…";
"quick_update_pick_amount" = "どの金額?";
"quick_update_no_amount_found" = "画像から金額を認識できませんでした。";
"icloud_copy_diagnostics" = "iCloud診断をコピー";
"icloud_diagnostics_copied" = "診断をクリップボードにコピーしました";
"Smooth Gaps in Charts" = "グラフの空白を補間";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "%dか月分のデータがありません";
"gaps_missing_count" = "%d件不足";
"gaps_range_between" = "%@〜%@の間";
"Fill in missing data" = "不足しているデータを入力";
"Missing Data" = "不足データ";
"Fill Gap" = "空白を埋める";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "グラフはこれらの月を推定しています。正確な履歴を保つため実際の値を追加してください。";
"Dismiss" = "閉じる";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%dか月";
"Monthly Summary" = "月間サマリー";
"Portfolio Value" = "ポートフォリオの価値";
"Tracked with" = "記録に使用";
"Share monthly summary" = "月間サマリーを共有";
"since last check-in" = "前回のチェックイン以降";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "新しくインポートするものはありません。データはすでに最新です。";
"import_preview_sources" = "%d件のソースを作成";
"import_preview_snapshots_new" = "%d件のスナップショットを作成";
"import_preview_snapshots_update" = "%d件のスナップショットを更新";
"import_preview_categories" = "%d件のカテゴリーを作成";
"import_preview_goals" = "%d件の目標を作成";
"import_preview_journal_new" = "%d件のジャーナルを作成";
"import_preview_journal_update" = "%d件のジャーナルを更新";
"Review Import" = "インポートの確認";
"Reviewing import…" = "インポートを確認中…";
"Import" = "インポート";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "推定";
"gaps_autofill_cta" = "%dか月を自動入力";
"Add manually" = "手動で追加";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "自動入力は2つの記録の間の欠けている月を推定します(推定として表示)。または実際の値を自分で入力できます。";
/* 1.6.0 auto-backup */
"Auto Backup" = "自動バックアップ";
"Off" = "オフ";
"Daily" = "毎日";
"Weekly" = "毎週";
"Monthly" = "毎月";
"Last auto backup" = "最後の自動バックアップ";
/* 1.6.0 hide balances */
"Hide balances" = "残高を隠す";
"Show balances" = "残高を表示";
"Balance hidden" = "残高は非表示";
"Reveal your balances" = "残高を表示";
"Privacy" = "プライバシー";
"Hide Balances" = "残高を隠す";
"Require Face ID to Reveal" = "表示にFace IDを要求";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "すべての金額をぼかして隠します。人前での利用やスクリーンショットに最適。表示にFace IDを要求することもできます。";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "まだポートフォリオの金額が記録されていません。";
"intent_networth_dialog" = "あなたの純資産は%@です。";
"intent_streak" = "%dか月連続で記録中です。";
"intent_streak_with_date" = "%dか月連続で記録中です。最後のチェックインは%@でした。";
"intent_streak_none" = "まだ連続記録がありません。値を記録して始めましょう。";
// Charts workable (1.6.0)
"chart_range_button" = "カスタム期間";
"chart_range_from" = "開始";
"chart_range_to" = "終了";
"chart_filtered_prefix" = "フィルター: %@";
"chart_overlay_contributions" = "拠出額と比較";
"chart_insight_best_month" = "期間内のベスト月: %1$@%2$@";
// Share cards 2.0 (1.6.0)
"share_options_title" = "成果をシェア";
"share_percent_only" = "パーセントのみ";
"share_format_post" = "投稿";
"share_format_story" = "ストーリー";
"share_card_cta" = "シェア";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "トータルリターン";
"share_metric_yoy" = "今年";
"share_metric_since_inception" = "開始来";
"share_cta_headline" = "あなたも無料で記録";
"share_cta_sub" = "スキャンでPortfolio Journalを入手";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "%@時点";
"share_theme_dark" = "ダーク";
"share_theme_light" = "ライト";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "今日の評価額";
"add_source_value_footer" = "最初のチェックインとして保存され、今日から進捗の記録が始まります。";
"quick_update_add_first_source" = "最初の投資先を追加";
@@ -0,0 +1,679 @@
"app_name" = "Portfolio Journal";
"ok" = "OK";
"cancel" = "Cancelar";
"save" = "Salvar";
"delete" = "Excluir";
"edit" = "Editar";
"add" = "Adicionar";
"done" = "Concluído";
"close" = "Fechar";
"continue" = "Continuar";
"skip" = "Pular";
"error" = "Erro";
"success" = "Sucesso";
"loading" = "Carregando...";
"loading_data" = "Carregando seus dados...";
"tab_dashboard" = "Início";
"tab_sources" = "Fontes";
"tab_charts" = "Gráficos";
"tab_settings" = "Ajustes";
"dashboard_title" = "Início";
"total_portfolio_value" = "Valor total da carteira";
"today" = "hoje";
"returns" = "Retornos";
"by_category" = "Por categoria";
"pending_updates" = "Atualizações pendentes";
"see_all" = "Ver tudo";
"sources_title" = "Fontes";
"add_source" = "Adicionar fonte";
"source_name" = "Nome da fonte";
"select_category" = "Selecionar categoria";
"initial_value" = "Valor inicial";
"initial_value_optional" = "Valor inicial (opcional)";
"reminder_frequency" = "Frequência do lembrete";
"source_limit_warning" = "Limite de fontes atingido. Faça upgrade para Premium para fontes ilimitadas.";
"no_sources" = "Nenhuma fonte de investimento";
"no_sources_message" = "Adicione sua primeira fonte de investimento para começar a acompanhar sua carteira.";
"add_snapshot" = "Adicionar snapshot";
"edit_snapshot" = "Editar snapshot";
"snapshot_date" = "Data";
"snapshot_value" = "Valor";
"snapshot_contribution" = "Contribuição";
"contribution_optional" = "Contribuição (opcional)";
"notes" = "Notas";
"notes_optional" = "Notas (opcionais)";
"previous_value" = "Anterior: %@";
"change_from_previous" = "Mudança em relação ao anterior";
"charts_title" = "Gráficos";
"evolution" = "Evolução";
"allocation" = "Alocação";
"performance" = "Desempenho";
"drawdown" = "Drawdown";
"volatility" = "Volatilidade";
"prediction" = "Previsão";
"portfolio_evolution" = "Evolução da carteira";
"asset_allocation" = "Alocação de ativos";
"performance_by_category" = "Desempenho por categoria";
"drawdown_analysis" = "Análise de drawdown";
"prediction_12_month" = "Previsão de 12 meses";
"not_enough_data" = "Dados insuficientes";
"cagr" = "CAGR";
"twr" = "TWR";
"max_drawdown" = "Drawdown máximo";
"sharpe_ratio" = "Índice de Sharpe";
"win_rate" = "Taxa de acerto";
"avg_monthly" = "Média mensal";
"best_month" = "Melhor mês";
"worst_month" = "Pior mês";
"premium" = "Premium";
"upgrade_to_premium" = "Fazer upgrade para Premium";
"unlock_full_potential" = "Desbloqueie todo o potencial";
"one_time_purchase" = "Compra única";
"includes_family_sharing" = "Inclui Compartilhamento Familiar";
"upgrade_now" = "Fazer upgrade agora";
"restore_purchases" = "Restaurar compras";
"premium_active" = "Premium ativo";
"premium_feature" = "Recurso Premium";
"unlock" = "Desbloquear";
"feature_unlimited_sources" = "Fontes ilimitadas";
"feature_unlimited_sources_desc" = "Acompanhe quantos investimentos quiser";
"feature_full_history" = "Histórico completo";
"feature_full_history_desc" = "Acesse todo o seu histórico de investimentos";
"feature_advanced_charts" = "Gráficos avançados";
"feature_advanced_charts_desc" = "5 tipos de gráficos analíticos detalhados";
"feature_predictions" = "Previsões";
"feature_predictions_desc" = "Previsões de 12 meses com IA";
"feature_export" = "Exportar dados";
"feature_export_desc" = "Exporte para CSV e JSON";
"feature_no_ads" = "Sem anúncios";
"feature_no_ads_desc" = "Experiência sem anúncios para sempre";
"paywall_benefit_history_title" = "Seu histórico completo";
"paywall_benefit_history_subtitle" = "Cada snapshot, contribuição e ganho desde o primeiro dia";
"paywall_benefit_charts_title" = "Gráficos que revelam padrões";
"paywall_benefit_charts_subtitle" = "Alocação, drawdown, desempenho: tudo em um só lugar";
"paywall_benefit_forecasts_title" = "Previsões de 12 meses";
"paywall_benefit_forecasts_subtitle" = "Veja para onde sua carteira provavelmente está indo";
"paywall_benefit_noads_title" = "Sem anúncios, nunca";
"paywall_benefit_noads_subtitle" = "Experiência limpa e focada, sem distrações";
"settings_title" = "Ajustes";
"subscription" = "Assinatura";
"notifications" = "Notificações";
"default_reminder_time" = "Horário padrão do lembrete";
"data" = "Dados";
"export_data" = "Exportar dados";
"total_sources" = "Total de fontes";
"total_snapshots" = "Total de snapshots";
"storage_used" = "Armazenamento usado";
"about" = "Sobre";
"version" = "Versão";
"privacy_policy" = "Política de privacidade";
"terms_of_service" = "Termos de serviço";
"support" = "Suporte";
"rate_app" = "Avaliar app";
"danger_zone" = "Zona de perigo";
"reset_all_data" = "Redefinir todos os dados";
"reset_confirmation" = "Isso excluirá permanentemente todos os seus dados de investimento. Esta ação não pode ser desfeita.";
"frequency_monthly" = "Mensal";
"frequency_quarterly" = "Trimestral";
"frequency_semiannual" = "Semestral";
"frequency_annual" = "Anual";
"frequency_custom" = "Personalizada";
"frequency_never" = "Nunca";
"every_n_months" = "A cada %d mês(es)";
"category_stocks" = "Ações";
"category_bonds" = "Títulos";
"category_real_estate" = "Imóveis";
"category_crypto" = "Cripto";
"category_cash" = "Caixa";
"category_etfs" = "ETFs";
"category_retirement" = "Aposentadoria";
"category_other" = "Outro";
"uncategorized" = "Sem categoria";
"time_1m" = "1M";
"time_3m" = "3M";
"time_6m" = "6M";
"time_1y" = "1A";
"time_all" = "Tudo";
"export_format" = "Selecionar formato";
"export_csv" = "CSV";
"export_csv_desc" = "Compatível com Excel e Google Sheets";
"export_json" = "JSON";
"export_json_desc" = "Estrutura completa para backup";
"onboarding_track_title" = "Acompanhe seus investimentos";
"onboarding_track_desc" = "Monitore todas as suas fontes de investimento em um só lugar. Ações, títulos, imóveis, cripto e mais.";
"onboarding_visualize_title" = "Visualize seu crescimento";
"onboarding_visualize_desc" = "Belos gráficos mostram a evolução, alocação e desempenho da sua carteira ao longo do tempo.";
"onboarding_reminders_title" = "Nunca perca uma atualização";
"onboarding_reminders_desc" = "Defina lembretes para acompanhar seus investimentos regularmente. Mensal, trimestral ou personalizado.";
"onboarding_sync_title" = "Sincronize em todos os lugares";
"onboarding_sync_desc" = "Seus dados sincronizam automaticamente via iCloud em todos os seus dispositivos Apple.";
"get_started" = "Começar";
"onboarding_clarity_title" = "Saiba exatamente onde você está";
"onboarding_clarity_desc" = "Veja seu patrimônio total, retornos reais e alocação, sempre atualizados.";
"onboarding_habit_title" = "5 minutos por mês são suficientes";
"onboarding_habit_desc" = "Registre seus valores uma vez por mês. O Portfolio Journal faz as contas e mostra seu progresso.";
"onboarding_calm_title" = "Ignore o ruído. Acompanhe a tendência.";
"onboarding_calm_desc" = "Oscilações diárias não contam a história real. Seu crescimento ao longo de meses e anos conta.";
"onboarding_goals_title" = "Alcance seus objetivos financeiros";
"onboarding_goals_desc" = "Defina metas, acompanhe marcos e veja exatamente o quanto você avançou.";
"error_generic" = "Ocorreu um erro. Tente novamente.";
"error_no_purchases" = "Nenhuma compra encontrada para restaurar";
"error_purchase_failed" = "Compra falhou: %@";
"error_export_failed" = "A exportação falhou. Tente novamente.";
"placeholder_source_name" = "ex.: Vanguard 401k";
"placeholder_value" = "0.00";
"placeholder_notes" = "Adicionar notas...";
"mood_energized_title" = "A mil";
"mood_confident_title" = "Confiante";
"mood_balanced_title" = "Estável";
"mood_cautious_title" = "Cauteloso";
"mood_stressed_title" = "Estressado";
"mood_energized_detail" = "Me sentindo imbatível";
"mood_confident_detail" = "No caminho e tranquilo";
"mood_balanced_detail" = "Calmo e paciente";
"mood_cautious_detail" = "Observando os movimentos";
"mood_stressed_detail" = "Preciso respirar";
"achievement_streak_3_title" = "Sequência de 3 meses";
"achievement_streak_3_detail" = "Você manteve seus check-ins em dia por três meses seguidos.";
"achievement_streak_6_title" = "Boa fase de meio ano";
"achievement_streak_6_detail" = "Seis check-ins consecutivos no prazo.";
"achievement_streak_12_title" = "Um ano de ritmo";
"achievement_streak_12_detail" = "Um ano inteiro sem perder o prazo.";
"achievement_perfect_on_time_title" = "Nunca atrasado";
"achievement_perfect_on_time_detail" = "Todos os check-ins foram enviados antes do prazo.";
"achievement_clutch_finish_title" = "No limite";
"achievement_clutch_finish_detail" = "Enviado com poucas horas de sobra, mas ainda no prazo.";
"achievement_early_bird_title" = "Adiantado";
"achievement_early_bird_detail" = "Em média você termina com bastante tempo sobrando.";
"achievements_title" = "Conquistas";
"achievements_view_all" = "Ver todas as conquistas";
"achievements_nav_title" = "Conquistas";
"achievements_progress_title" = "Progresso";
"achievements_unlocked_title" = "Desbloqueadas";
"achievements_unlocked_empty" = "Complete check-ins para desbloquear conquistas.";
"achievements_locked_title" = "Bloqueadas";
"achievements_locked_empty" = "Todas as conquistas desbloqueadas. Bom trabalho.";
"rating_accessibility" = "Avaliação %d de 5";
"achievements_unlocked_count" = "%d de %d desbloqueadas";
"last_check_in" = "Último check-in: %@";
"next_check_in" = "Próximo check-in: %@";
"on_time_rate" = "%@ no prazo";
"on_time_count" = "%d/%d no prazo";
"tightest_finish" = "Fechamento mais apertado: %@ antes do prazo.";
"date_today" = "Hoje";
"date_yesterday" = "Ontem";
"date_never" = "Nunca";
"calendar_event_title" = "%@: Check-in mensal";
"calendar_event_notes" = "Abra %@ e conclua seu check-in mensal.";
"checkin_enjoying_dialog_title" = "Quanto você está gostando do Portfolio Journal?";
"checkin_enjoying_dialog_message" = "Parabéns pela sua nova conquista. Seu feedback nos ajuda a melhorar.";
"not_now" = "Agora não";
"rating_1_star" = "1 estrela";
"rating_n_stars" = "%d estrelas";
"app_store_review_title" = "Gostaria de deixar uma avaliação na App Store?";
"app_store_review_message" = "Obrigado pelas 5 estrelas. Isso realmente ajuda outros investidores a descobrir o app.";
"write_review" = "Escrever avaliação";
"save_1_snapshot" = "Salvar 1 snapshot";
"save_n_snapshots" = "Salvar %d snapshots";
"checkin_update_month" = "Atualizar %@";
"checkin_start_new" = "Iniciar";
"Home" = "Início";
"Sources" = "Fontes";
"Charts" = "Gráficos";
"Settings" = "Ajustes";
"Journal" = "Diário";
"Search monthly notes" = "Buscar notas mensais";
"Monthly Check-ins" = "Check-ins mensais";
"No monthly notes yet." = "Ainda não há notas mensais.";
"No matching notes." = "Nenhuma nota correspondente.";
"Jump to month" = "Ir para o mês";
"Today" = "Hoje";
"Mood not set" = "Humor não definido";
"No rating" = "Sem avaliação";
"No note yet." = "Ainda sem nota.";
"Monthly Note" = "Nota mensal";
"Open Full Note" = "Abrir nota completa";
"Duplicate Previous" = "Duplicar anterior";
"Save" = "Salvar";
"Monthly Check-in" = "Check-in mensal";
"This Month" = "Este mês";
"No check-in yet this month" = "Ainda não há check-in este mês";
"Start your first check-in anytime." = "Comece seu primeiro check-in quando quiser.";
"Mark Check-in Complete" = "Marcar check-in como concluído";
"Editing stays open. New check-ins unlock after 70% of the month." = "A edição permanece aberta. Novos check-ins são liberados após 70 % do mês.";
"Momentum & Streaks" = "Ritmo e sequências";
"Log a check-in to start a streak" = "Registre um check-in para começar uma sequência";
"Streak" = "Sequência";
"On-time in a row" = "No prazo em sequência";
"Best" = "Melhor";
"Personal best" = "Recorde pessoal";
"Avg early" = "Antecedência média";
"vs deadline" = "vs prazo";
"On-time score" = "Pontuação de prazo";
"Achievements" = "Conquistas";
"View all achievements" = "Ver todas as conquistas";
"Monthly Pulse" = "Pulso mensal";
"Optional" = "Opcional";
"Rate this month" = "Avalie este mês";
"How did it feel?" = "Como foi?";
"Monthly Summary" = "Resumo mensal";
"Starting" = "Início";
"Ending" = "Final";
"Contributions" = "Contribuições";
"Net Performance" = "Desempenho líquido";
"Update Sources" = "Atualizar fontes";
"Add sources to start your monthly check-in." = "Adicione fontes para começar seu check-in mensal.";
"Updated this cycle" = "Atualizado neste ciclo";
"Needs update" = "Precisa de atualização";
"Snapshot Notes" = "Notas de snapshot";
"No snapshot notes for this month." = "Não há notas de snapshot neste mês.";
"Source" = "Fonte";
"Your full portfolio,\nfully clear" = "Sua carteira completa,\ntotalmente clara";
"One payment. Every feature. Forever." = "Um pagamento. Todos os recursos. Para sempre.";
"Get Full Access" = "Obter acesso completo";
"Restore Purchases" = "Restaurar compras";
"Payment charged to your Apple ID account." = "O pagamento será cobrado na sua conta Apple ID.";
"Terms" = "Termos";
"Privacy" = "Privacidade";
"· one-time · Family Sharing" = "· pagamento único · Compartilhamento Familiar";
"Full access, one payment" = "Acesso total, um pagamento";
"Unlimited sources, advanced charts & more" = "Fontes ilimitadas, gráficos avançados e mais";
"See full access" = "Ver acesso completo";
"Batch Update" = "Atualização em lote";
"Current value" = "Valor atual";
"Contribution this period (optional)" = "Contribuição deste período (opcional)";
"Include Contribution" = "Incluir contribuição";
"New capital added" = "Novo capital adicionado";
"Contribution (Optional)" = "Contribuição (opcional)";
"Track new capital added to separate it from investment growth." = "Registre o novo capital adicionado para separar do crescimento do investimento.";
"Monthly Highlights" = "Destaques do mês";
"Best Performer" = "Melhor desempenho";
"Worst Performer" = "Pior desempenho";
"Best Contributor" = "Maior contribuinte";
"Update Check-in" = "Atualizar check-in";
"Completed %@" = "Concluído %@";
// MARK: - Missing keys added (1.3.x)
"Checking…" = "Verificando…";
"Clear Filters" = "Limpar filtros";
"Force Upload to iCloud" = "Forçar envio ao iCloud";
"No export yet" = "Ainda não exportado";
"Not synced yet" = "Ainda não sincronizado";
"Refresh" = "Atualizar";
"Search sources" = "Buscar fontes";
"Syncing with iCloud..." = "Sincronizando com iCloud...";
"Uploading..." = "Enviando...";
"Verify iCloud Setup" = "Verificar configuração do iCloud";
"add_source_name_footer" = "Uma fonte é qualquer investimento que você deseja acompanhar: ações, ETFs, poupança, imóveis, criptomoedas e mais.";
"add_source_name_placeholder" = "ex.: ETF MSCI World, Poupança, Apartamento...";
"categories_empty" = "Nenhuma categoria ainda.";
"category_has_sources_warning" = "Não é possível excluir uma categoria que possui fontes. Remova ou reatribua todas as fontes primeiro.";
"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";
"contributions_vs_returns_title" = "Investido vs. Retorno";
"csv_enter_value" = "Inserir valor";
"csv_field_category" = "Categoria";
"csv_field_contribution" = "Contribuição";
"csv_field_date" = "Data";
"csv_field_notes" = "Notas";
"csv_field_source" = "Nome da fonte (obrigatório)";
"csv_field_value" = "Valor (obrigatório)";
"csv_mapping_subtitle" = "Associe as colunas do CSV aos campos do Portfolio Journal";
"csv_no_column" = "— Não mapeado —";
"csv_no_date_hint" = "Todas as linhas serão importadas como um snapshot de hoje.";
"csv_optional_section" = "Campos opcionais";
"csv_preview_section" = "Prévia do CSV";
"csv_required_section" = "Campos obrigatórios";
"csv_use_today" = "Usar data de hoje";
"goal_achieved_notification_body" = "Você atingiu sua meta: %@";
"goal_achieved_notification_title" = "Meta atingida! 🎉";
"goal_archive" = "Arquivar";
"goal_delete_confirm" = "Excluir";
"goal_delete_message" = "Esta meta será excluída permanentemente. Esta ação não pode ser desfeita.";
"goal_delete_title" = "Excluir meta";
"goal_unarchive" = "Restaurar";
"goals_all_active_achieved" = "Todas as metas ativas foram alcançadas.";
"goals_empty_archived" = "Nenhuma meta arquivada.";
"goals_filter_active" = "Ativas";
"goals_filter_all" = "Todas";
"goals_filter_archived" = "Arquivadas";
"icloud_check_description" = "Se você tem dados em outro dispositivo, ative o iCloud para restaurá-los aqui.";
"icloud_check_title" = "Já usa o Portfolio Journal?";
"icloud_enabled_description" = "Feche o app e abra novamente. Seus dados serão carregados automaticamente do iCloud.";
"icloud_enabled_title" = "iCloud ativado";
"monthly_checkin_notification_body" = "Registre os valores deste mês e acompanhe o crescimento do seu portfólio.";
"monthly_checkin_notification_title" = "Hora da sua atualização mensal";
"onboarding_add_first_source" = "Adicionar meu primeiro investimento";
"onboarding_import_data" = "Importar dados existentes";
"onboarding_quickstart_subtitle" = "Adicione sua primeira fonte de investimento para começar a acompanhar seu portfólio.";
"onboarding_quickstart_title" = "Quase lá";
"reengagement_body" = "Alguns minutos são suficientes para manter o controle dos seus investimentos.";
"reengagement_goal_title" = "Você está no caminho certo 📈";
"reengagement_goal_body" = "Nesse ritmo você vai alcançar \"%@\" até %@. Veja o quanto falta.";
"reengagement_yoy_title" = "Olha esse crescimento 👀";
"reengagement_yoy_body" = "Você está %@ acima no último ano. Confira a tendência.";
"reengagement_month_title" = "Bom mês 📈";
"reengagement_month_body" = "Sua carteira cresceu %@ este mês. Dê uma olhada.";
"reengagement_milestone_title" = "Quase lá 🎯";
"reengagement_milestone_body" = "Você está quase em %@. Quão perto está hoje?";
"reengagement_streak_title" = "Mantenha a sequência 🔥";
"reengagement_streak_body" = "Você registrou %d meses seguidos. Não pare agora.";
"reengagement_neutral_title" = "Alguma novidade? 👀";
"reengagement_neutral_body" = "Houve movimento na sua carteira. Veja o que mudou.";
"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";
"sources_filter_all" = "Todas";
// MARK: - 1.4.0 Features
"monthly_summary_notification_title" = "Seu resumo mensal do portfólio";
"monthly_summary_notification_body" = "Veja como seus investimentos se saíram este mês.";
"streak_badge" = "Sequência de %d meses";
"goals_empty_add_cta" = "Adicionar minha primeira meta";
"journal_empty_title" = "Nenhum registro ainda";
"journal_empty_body" = "As atualizações de snapshots criam entradas mensais automaticamente. Adicione sua primeira fonte para começar.";
"quick_update_title" = "Atualização rápida";
"quick_update_section_header" = "Insira os valores atuais";
"quick_update_section_footer" = "Apenas as fontes com um valor inserido serão atualizadas.";
"quick_update_placeholder" = "Novo valor";
"quick_update_save" = "Salvar tudo";
"quick_update_no_sources" = "Sem fontes";
"quick_update_no_sources_body" = "Adicione fontes de investimento primeiro para usar a atualização rápida.";
"update_available_title" = "Atualização disponível";
"update_available_body" = "A versão %@ está disponível na App Store.";
"whats_new_title" = "Novidades no 1.4";
"whats_new_subtitle" = "Melhorias para te ajudar a manter o rumo.";
"whats_new_quick_update_title" = "Atualização rápida do portfólio";
"whats_new_quick_update_body" = "Atualize todas as suas fontes em uma única tela com um toque.";
"whats_new_streak_title" = "Sequência de atualizações";
"whats_new_streak_body" = "Acompanhe quantos meses consecutivos você manteve seu portfólio atualizado.";
"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";
"insight_allocation_title" = "Desvio de alocação";
"insight_allocation_over_value" = "%1$@ está em %2$.0f%% — acima da sua meta de %3$.0f%%";
"insight_allocation_under_value" = "%1$@ está em %2$.0f%% — abaixo da sua meta de %3$.0f%%";
"insight_allocation_detail" = "Rebalanceie para seguir o plano";
"insight_concentration_title" = "Risco de concentração";
"insight_concentration_value" = "%1$@ representa %2$.0f%% da sua carteira";
"insight_concentration_detail" = "Considere diversificar";
"insight_best_performer_title" = "Melhor desempenho";
"insight_worst_performer_title" = "Requer atenção";
"insight_performer_value" = "%1$@ · %2$+.1f%%";
"insight_diversification_title" = "Diversificação";
"insight_diversification_single_value" = "Tudo em uma fonte — considere adicionar outra";
"insight_diversification_good_value" = "Distribuído em %d categorias";
"goal_projection_projected" = "Previsão: %@";
"goal_projection_add_contributions" = "Adicione aportes para alcançar";
"goal_chip_ahead" = "Adiantado";
"goal_chip_on_track" = "No caminho";
"goal_chip_behind" = "Atrasado";
"goal_chip_at_this_pace" = "Neste ritmo…";
"notification_milestone_title" = "Meta de portfólio! 🎉";
"notification_milestone_body" = "Seu portfólio acabou de ultrapassar %@. Parabéns!";
"streak_protection_notification_title" = "Não quebre sua sequência 🔥";
"streak_protection_notification_body" = "O check-in deste mês ainda está pendente. Leva 2 minutos para manter sua sequência viva.";
"paywall_benefit_accounts_title" = "Várias contas";
"paywall_benefit_accounts_subtitle" = "Carteiras separadas para família ou negócios";
"paywall_benefit_family_title" = "Compartilhamento Familiar";
"paywall_benefit_family_subtitle" = "Uma compra, até 5 membros da família";
"paywall_plan_annual" = "Anual";
"paywall_plan_annual_per_year" = "%@ / ano";
"paywall_plan_lifetime" = "Vitalício";
"paywall_plan_lifetime_badge" = "MELHOR VALOR";
"paywall_trial_days" = "dias";
"paywall_trial_weeks" = "semanas";
"paywall_trial_months" = "meses";
"paywall_trial_years" = "anos";
"paywall_trial_format" = "%d %@ grátis";
"chart_history_locked" = "%d meses a mais de histórico — desbloqueie com o Premium";
"chart_group_overview" = "Visão geral";
"chart_group_analyze" = "Análise";
"chart_group_risk" = "Risco";
"chart_group_forecast" = "Previsão";
"kpi_total_value" = "Valor total";
"kpi_period_return" = "Retorno período";
"kpi_cagr" = "CAGR";
"kpi_volatility" = "Volatilidade";
"kpi_max_drawdown" = "Queda máx.";
"chart_zoom_in" = "Ampliar";
"chart_zoom_out" = "Reduzir";
"chart_zoom_reset" = "Redefinir zoom";
"quick_update_paste_suggestion" = "Colar %@ em %@";
"quick_update_next_badge" = "PRÓXIMA";
// App Intents (1.4.2)
"intent_logged_dialog" = "%1$@ salvo para %2$@.";
"intent_amount_invalid" = "O valor deve ser maior que zero. Quanto?";
"Log Portfolio Value" = "Registrar valor da carteira";
"Record a value for one of your sources without opening the app." = "Registre um valor para uma de suas fontes sem abrir o app.";
"Amount" = "Valor";
"How much?" = "Quanto?";
"Log Value" = "Registrar valor";
"Update Portfolio" = "Atualizar carteira";
"Check Portfolio" = "Verificar carteira";
// Chart share (1.4.2)
"chart_share_button" = "Compartilhar gráfico";
"chart_share_text" = "Meu gráfico de %@ — feito com Portfolio Journal";
"chart_share_tagline" = "Acompanhamento de investimentos privado e tranquilo para iPhone e iPad.";
"chart_share_scan" = "Escaneie para baixar";
"chart_locked_title" = "%@ é um gráfico Premium";
"chart_locked_cta" = "Desbloquear Premium";
// Calm 2.0 Home (1.5.0)
"checkin_done_title" = "Check-in de %@ concluído";
"checkin_done_next" = "Próximo: %@";
"checkin_next_due" = "Próximo check-in: %@";
"checkin_days_left" = "faltam %d dias";
"checkin_due_today" = "Hoje";
"checkin_overdue" = "Atrasado";
"dash_chart_yoy" = "Ano vs Ano";
"home_chart_locked_sub" = "Desbloqueie com Premium para ver este gráfico.";
"checkin_sources_progress" = "%1$d de %2$d fontes atualizadas";
// Guided check-in (1.5.0 Fase 2)
"guided_previous_value" = "Mês passado";
"guided_paste_amount" = "Colar %@";
"guided_next" = "Avançar";
"guided_same_as_last" = "Igual ao mês passado";
"guided_skip" = "Pular";
"guided_reflection_title" = "Como está sendo este mês?";
"guided_reflection_subtitle" = "Uma nota rápida para o seu eu do futuro.";
"guided_note_placeholder" = "Uma linha sincera sobre este mês…";
"guided_finish" = "Concluir check-in";
"guided_done_title" = "Check-in de %@ concluído";
"guided_streak" = "%d meses seguidos";
"guided_share_month" = "Compartilhar este mês";
"guided_done_cta" = "Concluído";
"journal_no_note" = "Ainda sem nota.";
// What's New catalog (1.4.11.5.0)
"whats_new_version_header" = "Versão %@";
"whats_new_150_redesign_title" = "Um visual mais calmo e limpo";
"whats_new_150_redesign_body" = "Início redesenhado com seu valor total em destaque, o check-in no centro e menos ruído em tudo.";
"whats_new_150_guided_title" = "Check-in mensal guiado";
"whats_new_150_guided_body" = "Uma fonte por etapa, deltas ao vivo e uma reflexão rápida — seu ritual de 5 minutos, reinventado.";
"whats_new_150_rings_title" = "Anéis de metas & timeline";
"whats_new_150_rings_body" = "Anéis de progresso estilo Fitness para metas e uma timeline da sua história como investidor.";
"whats_new_142_ocr_title" = "Atualize com uma captura de tela";
"whats_new_142_ocr_body" = "Compartilhe uma captura do seu banco — o saldo é reconhecido no aparelho, nada sai do seu iPhone.";
"whats_new_142_siri_title" = "Siri no seu idioma";
"whats_new_142_siri_body" = "\"Registre o saldo da Indexa\" — anote valores por voz ou pelos Atalhos, sem abrir o app.";
"whats_new_142_charts_title" = "Gráficos renovados";
"whats_new_142_charts_body" = "Barra lateral nativa no iPad, KPIs sob medida por gráfico e compartilhamento com belas imagens.";
"whats_new_141_extension_title" = "Atualize sem trocar de app";
"whats_new_141_extension_body" = "Compartilhe um valor do app do seu banco direto no Portfolio Journal com a nova extensão.";
"whats_new_141_ipad_title" = "Melhor no iPad";
"whats_new_141_ipad_body" = "Gráficos redesenhados para a tela grande, com zoom e rótulos de valor.";
"whats_new_141_icloud_title" = "Mais sincronizado";
"whats_new_141_icloud_body" = "Aportes mensais e metas de alocação agora sincronizam via iCloud.";
"whats_new_title_format" = "Novidades da %@";
// TipKit (1.5.0 Fase 4)
"tip_quick_update_title" = "Atualize tudo em segundos";
"tip_quick_update_body" = "O Quick Update lista todas as suas fontes em uma tela — cole valores da área de transferência e pronto.";
"tip_ocr_title" = "Ou compartilhe uma captura de tela";
"tip_ocr_body" = "Do app do seu banco: captura → Compartilhar → Portfolio Journal. O saldo é lido no aparelho.";
"tip_siri_title" = "Na próxima, experimente por voz";
"tip_siri_body" = "Diga à Siri \"Registre o saldo da minha conta no Portfolio Journal\" — sem abrir o app.";
"tip_chart_share_title" = "Compartilhe este gráfico";
"tip_chart_share_body" = "Gera uma bela imagem com seus dados, pronta para o X ou WhatsApp.";
"allocation_total" = "Total";
// iCloud error hints (1.5.0)
"icloud_hint_schema" = "O iCloud rejeitou alguns registros. Geralmente significa que o esquema do iCloud ainda está sendo configurado — costuma resolver em algumas horas ou após a próxima atualização do app.";
"icloud_hint_quota" = "Seu armazenamento do iCloud está cheio. Libere espaço e tente novamente.";
"icloud_hint_network" = "Não foi possível acessar o iCloud. Verifique sua conexão e tente novamente.";
"icloud_hint_partial_generic" = "Alguns registros não sincronizaram desta vez. O app tentará novamente automaticamente.";
"icloud_status_error" = "Problema de sincronização com o iCloud";
"icloud_status_syncing" = "Sincronizando com o iCloud";
"icloud_show_details" = "Mostrar detalhes técnicos";
"yoy_vs_prev" = "vs %@";
"yoy_best_year" = "Melhor ano";
"yoy_worst_year" = "Pior ano";
// Quick Update paste/scan (1.5.0)
"quick_update_paste_value" = "Colar %@";
"quick_update_scan" = "Escanear";
"quick_update_done_kbd" = "Concluir";
"quick_update_scanning" = "Lendo imagem…";
"quick_update_pick_amount" = "Qual valor?";
"quick_update_no_amount_found" = "Nenhum valor reconhecido na imagem.";
"icloud_copy_diagnostics" = "Copiar diagnóstico do iCloud";
"icloud_diagnostics_copied" = "Diagnóstico copiado para a área de transferência";
"Smooth Gaps in Charts" = "Suavizar lacunas nos gráficos";
// MARK: - Data Gaps (1.5.0)
"gaps_banner_title" = "Faltam dados de %d meses";
"gaps_missing_count" = "%d faltando";
"gaps_range_between" = "Entre %@ e %@";
"Fill in missing data" = "Preencher dados faltantes";
"Missing Data" = "Dados faltantes";
"Fill Gap" = "Preencher lacuna";
"The chart estimates these months. Add real snapshots to keep your history accurate." = "O gráfico estima esses meses. Adicione valores reais para manter seu histórico preciso.";
"Dismiss" = "Dispensar";
// MARK: - Shareable Monthly Summary (1.5.0)
"share_summary_streak_badge" = "%d m";
"Monthly Summary" = "Resumo mensal";
"Portfolio Value" = "Valor da carteira";
"Tracked with" = "Registrado com";
"Share monthly summary" = "Compartilhar resumo mensal";
"since last check-in" = "desde o último check-in";
// MARK: - Import Preview (1.5.0)
"import_preview_nothing" = "Nada novo para importar. Seus dados já estão atualizados.";
"import_preview_sources" = "Criar %d fontes";
"import_preview_snapshots_new" = "Criar %d registros";
"import_preview_snapshots_update" = "Atualizar %d registros";
"import_preview_categories" = "Criar %d categorias";
"import_preview_goals" = "Criar %d metas";
"import_preview_journal_new" = "Criar %d entradas do diário";
"import_preview_journal_update" = "Atualizar %d entradas do diário";
"Review Import" = "Revisar importação";
"Reviewing import…" = "Revisando importação…";
"Import" = "Importar";
/* 1.6.0 gap auto-fill */
"gaps_estimated_note" = "Estimado";
"gaps_autofill_cta" = "Preencher %d meses";
"Add manually" = "Adicionar manualmente";
"Auto-fill estimates the missing months between two snapshots (marked as estimated). Or add the real values yourself." = "O preenchimento automático estima os meses faltantes entre dois registros (marcados como estimados). Ou insira os valores reais você mesmo.";
/* 1.6.0 auto-backup */
"Auto Backup" = "Backup automático";
"Off" = "Desativado";
"Daily" = "Diário";
"Weekly" = "Semanal";
"Monthly" = "Mensal";
"Last auto backup" = "Último backup automático";
/* 1.6.0 hide balances */
"Hide balances" = "Ocultar saldos";
"Show balances" = "Mostrar saldos";
"Balance hidden" = "Saldo oculto";
"Reveal your balances" = "Mostrar seus saldos";
"Privacy" = "Privacidade";
"Hide Balances" = "Ocultar saldos";
"Require Face ID to Reveal" = "Exigir Face ID para mostrar";
"Hide all monetary amounts behind a blur. Great for using the app in public or taking screenshots. Optionally require Face ID to reveal them." = "Oculta todos os valores atrás de um desfoque. Ótimo para usar o app em público ou tirar prints. Opcionalmente, exija Face ID para revelá-los.";
/* 1.6.0 Siri intents */
"intent_networth_empty" = "Você ainda não registrou nenhum valor de carteira.";
"intent_networth_dialog" = "Seu patrimônio é %@.";
"intent_streak" = "Você está em uma sequência de %d meses.";
"intent_streak_with_date" = "Você está em uma sequência de %d meses. Seu último check-in foi em %@.";
"intent_streak_none" = "Você ainda não tem uma sequência ativa. Registre um valor para começar uma.";
// Charts workable (1.6.0)
"chart_range_button" = "Período personalizado";
"chart_range_from" = "De";
"chart_range_to" = "Até";
"chart_filtered_prefix" = "Filtrado: %@";
"chart_overlay_contributions" = "vs Aportes";
"chart_insight_best_month" = "Melhor mês do período: %1$@ (%2$@)";
// Share cards 2.0 (1.6.0)
"share_options_title" = "Compartilhe seu progresso";
"share_percent_only" = "Apenas porcentagens";
"share_format_post" = "Post";
"share_format_story" = "Story";
"share_card_cta" = "Compartilhar";
// Share cards 2.1 — brand header + download CTA (1.6.0)
"share_hero_eyebrow" = "Retorno total";
"share_metric_yoy" = "Este ano";
"share_metric_since_inception" = "Desde o início";
"share_cta_headline" = "Acompanhe o seu — grátis";
"share_cta_sub" = "Escaneie para obter o Portfolio Journal";
// Share cards 2.2 — as-of date + theme (1.6.0)
"share_as_of" = "Em %@";
"share_theme_dark" = "Escuro";
"share_theme_light" = "Claro";
// Activation: first source → first snapshot (1.6.1)
"add_source_value_header" = "Valor de hoje";
"add_source_value_footer" = "Salvo como seu primeiro check-in — seu progresso começa hoje.";
"quick_update_add_first_source" = "Adicionar sua primeira fonte";
+97 -58
View File
@@ -2,6 +2,7 @@ import Foundation
import SwiftUI
import Combine
import GoogleMobileAds
import UserMessagingPlatform
import AppTrackingTransparency
import AdSupport
@@ -12,6 +13,7 @@ class AdMobService: ObservableObject {
@Published var isConsentObtained = false
@Published var canShowAds = false
@Published var isLoading = false
@Published var shouldRequestNonPersonalizedAds = false
// MARK: - Ad Unit IDs
@@ -25,76 +27,107 @@ class AdMobService: ObservableObject {
// MARK: - Initialization
init() {
checkConsentStatus()
setupResetObserver()
Task {
await configureConsentAndRequestAdsIfNeeded()
}
}
// MARK: - Consent Management
func checkConsentStatus() {
// Check if we already have consent
let consentStatus = UserDefaults.standard.bool(forKey: "adConsentObtained")
isConsentObtained = consentStatus
canShowAds = consentStatus
}
func requestConsent() async {
if #available(iOS 14.5, *) {
let status = await ATTrackingManager.requestTrackingAuthorization()
await configureConsentAndRequestAdsIfNeeded()
}
switch status {
case .authorized:
isConsentObtained = true
canShowAds = true
case .denied, .restricted:
// Can still show non-personalized ads
isConsentObtained = true
canShowAds = true
case .notDetermined:
// Will be asked again later
break
@unknown default:
break
func presentPrivacyOptions() async {
guard let root = topViewController() else { return }
await withCheckedContinuation { continuation in
ConsentForm.presentPrivacyOptionsForm(from: root) { _ in
continuation.resume()
}
} else {
// iOS 14.4 and earlier - consent assumed
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
updateConsentState()
}
// MARK: - GDPR Consent (UMP SDK)
func requestGDPRConsent() async {
// Implement UMP SDK consent flow if targeting EU users
// This is a simplified version - full implementation requires UMP SDK
let isEUUser = isUserInEU()
if isEUUser {
// Show GDPR consent dialog
// For now, assume consent if user continues
isConsentObtained = true
canShowAds = true
} else {
isConsentObtained = true
canShowAds = true
}
UserDefaults.standard.set(isConsentObtained, forKey: "adConsentObtained")
func resetConsent() {
ConsentInformation.shared.reset()
UserDefaults.standard.removeObject(forKey: AppConstants.StorageKeys.adConsentObtained)
isConsentObtained = false
canShowAds = false
shouldRequestNonPersonalizedAds = false
}
private func isUserInEU() -> Bool {
let euCountries = [
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE", "GB", "IS", "LI",
"NO", "CH"
]
private func setupResetObserver() {
NotificationCenter.default.addObserver(
forName: .didResetData,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.resetConsent()
await self.configureConsentAndRequestAdsIfNeeded()
}
}
}
let countryCode = Locale.current.region?.identifier ?? ""
return euCountries.contains(countryCode)
private func configureConsentAndRequestAdsIfNeeded() async {
isLoading = true
let parameters = RequestParameters()
await withCheckedContinuation { continuation in
ConsentInformation.shared.requestConsentInfoUpdate(with: parameters) { _ in
continuation.resume()
}
}
if let root = topViewController() {
await withCheckedContinuation { continuation in
ConsentForm.loadAndPresentIfRequired(from: root) { _ in
continuation.resume()
}
}
}
updateConsentState()
if canShowAds {
await requestTrackingIfNeeded()
updateConsentState()
}
isLoading = false
}
private func updateConsentState() {
let consentInfo = ConsentInformation.shared
canShowAds = consentInfo.canRequestAds
isConsentObtained = consentInfo.consentStatus == .obtained || consentInfo.consentStatus == .notRequired
let trackingDenied: Bool
if #available(iOS 14.5, *) {
trackingDenied = ATTrackingManager.trackingAuthorizationStatus != .authorized
} else {
trackingDenied = false
}
shouldRequestNonPersonalizedAds = !isConsentObtained || trackingDenied
UserDefaults.standard.set(isConsentObtained, forKey: AppConstants.StorageKeys.adConsentObtained)
}
private func requestTrackingIfNeeded() async {
if #available(iOS 14.5, *) {
_ = await ATTrackingManager.requestTrackingAuthorization()
}
}
private func topViewController() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else {
return nil
}
var current = root
while let presented = current.presentedViewController {
current = presented
}
return current
}
// MARK: - Analytics
@@ -159,7 +192,13 @@ struct BannerAdView: UIViewRepresentable {
}
bannerView.delegate = context.coordinator
bannerView.load(Request())
let request = Request()
if adMobService.shouldRequestNonPersonalizedAds {
let extras = Extras()
extras.additionalParameters = ["npa": "1"]
request.register(extras)
}
bannerView.load(request)
return bannerView
}
@@ -0,0 +1,338 @@
import AppIntents
import Foundation
import CoreData
// MARK: - App Intents (Siri / Shortcuts / Spotlight)
//
// Surfaces the core monthly habit outside the app: "Update my portfolio" opens
// Quick Update directly, "Check my portfolio" opens the dashboard. Registering an
// AppShortcutsProvider also lists these actions in Spotlight and the Shortcuts app
// with zero user setup a discovery/retention surface the app didn't have.
struct QuickUpdateIntent: AppIntent {
static let title: LocalizedStringResource = "Update Portfolio"
static let description = IntentDescription("Open Quick Update to record your latest portfolio values.")
static let openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
// Same signal the widget deep link uses; ContentView routes it to the sheet.
NotificationCenter.default.post(name: .openQuickUpdate, object: nil)
return .result()
}
}
struct OpenDashboardIntent: AppIntent {
static let title: LocalizedStringResource = "Check Portfolio"
static let description = IntentDescription("Open the dashboard to see your net worth and charts.")
static let openAppWhenRun = true
@MainActor
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return .result()
}
}
// MARK: - Parametric logging (headless)
/// An investment source exposed to Siri/Shortcuts, backed by the App Group
/// mirror the Share Extension already uses no Core Data access needed at
/// resolution time, so it works instantly even before the app launches.
struct SourceEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Source")
static let defaultQuery = SourceEntityQuery()
let id: UUID
let name: String
let currencyCode: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
init(_ info: SharedSourceInfo) {
id = info.id
name = info.name
currencyCode = info.currencyCode
}
}
struct SourceEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [SourceEntity] {
SharedQuickUpdateStore.readMirror()
.filter { identifiers.contains($0.id) }
.map(SourceEntity.init)
}
func suggestedEntities() async throws -> [SourceEntity] {
// Pending-first, mirroring the Share Extension's ordering.
SharedQuickUpdateStore.readMirror()
.sorted { a, b in
if a.updatedThisMonth != b.updatedThisMonth { return !a.updatedThisMonth }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
}
.map(SourceEntity.init)
}
}
/// "Log 15000 for Indexa" records a snapshot without opening any UI. Runs in
/// the app process in the background: the value goes through the same pending
/// queue as the Share Extension and is ingested into Core Data immediately.
struct LogSnapshotIntent: AppIntent {
static let title: LocalizedStringResource = "Log Portfolio Value"
static let description = IntentDescription("Record a value for one of your sources without opening the app.")
static let openAppWhenRun = false
@Parameter(title: "Source")
var source: SourceEntity
@Parameter(title: "Amount", requestValueDialog: "How much?")
var amount: Double
static var parameterSummary: some ParameterSummary {
Summary("Log \(\.$amount) for \(\.$source)")
}
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
guard amount > 0 else {
throw $amount.needsValueError(IntentDialog(stringLiteral: String(localized: "intent_amount_invalid")))
}
SharedQuickUpdateStore.appendPending(PendingQuickUpdate(
sourceId: source.id,
amount: amount,
capturedAt: Date()
))
// We're inside the app process: materialize the snapshot right away.
SharedQuickUpdateSync.ingestPending()
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = source.currencyCode
let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
let text = String(format: String(localized: "intent_logged_dialog"), formatted, source.name)
return .result(dialog: IntentDialog(stringLiteral: text))
}
}
// MARK: - Net worth / streak (read-only, from the shared App Group store)
/// Reads aggregate portfolio facts from the App Group Core Data store the same
/// way the widget does: a read-only container over the shared SQLite file. This
/// lets the intents answer instantly without touching the app's CloudKit stack.
enum SharedPortfolioReader {
private static let storeFileName = "PortfolioJournal.sqlite"
struct Snapshot {
let totalValue: Decimal
let currencyCode: String
let streak: Int
let lastCheckIn: Date?
}
private static func sharedStoreURL() -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier)?
.appendingPathComponent(storeFileName)
}
private static func makeContainer() -> NSPersistentContainer? {
guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL),
let storeURL = sharedStoreURL(),
FileManager.default.fileExists(atPath: storeURL.path) else {
return nil
}
let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model)
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in loadError = error }
return loadError == nil ? container : nil
}
static func read() -> Snapshot {
guard let container = makeContainer() else {
return Snapshot(totalValue: 0, currencyCode: "EUR", streak: 0, lastCheckIn: nil)
}
let context = container.viewContext
let calendar = Calendar.current
// Currency
var currency = "EUR"
let settingsRequest = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
settingsRequest.fetchLimit = 1
if let settings = try? context.fetch(settingsRequest).first,
let code = settings.value(forKey: "currency") as? String, !code.isEmpty {
currency = code
}
// Latest snapshot value per source total net worth.
// Also collect the set of (year, month) that have snapshot data for streak.
let snapRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
snapRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
let snapshots = (try? context.fetch(snapRequest)) ?? []
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal)] = [:]
var monthComponents = Set<DateComponents>()
for snap in snapshots {
guard let source = snap.value(forKey: "source") as? NSManagedObject else { continue }
let date = (snap.value(forKey: "date") as? Date) ?? .distantPast
let value = (snap.value(forKey: "value") as? NSDecimalNumber)?.decimalValue ?? .zero
let key = source.objectID
if let existing = latestBySource[key] {
if date >= existing.date { latestBySource[key] = (date, value) }
} else {
latestBySource[key] = (date, value)
}
let c = calendar.dateComponents([.year, .month], from: date)
monthComponents.insert(DateComponents(year: c.year, month: c.month))
}
let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value }
// Streak: consecutive months with data counting back from most recent.
let sortedMonthsDesc = monthComponents
.compactMap { calendar.date(from: $0) }
.sorted(by: >)
var streak = 0
if let mostRecent = sortedMonthsDesc.first {
streak = 1
var current = mostRecent
for i in 1..<max(sortedMonthsDesc.count, 1) {
guard i < sortedMonthsDesc.count,
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonthsDesc[i]
let pc = calendar.dateComponents([.year, .month], from: prev)
let ec = calendar.dateComponents([.year, .month], from: expected)
if pc.year == ec.year && pc.month == ec.month {
streak += 1
current = prev
} else { break }
}
}
// Last check-in from the most recent completed JournalEntry.
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
journalRequest.fetchLimit = 1
let lastCheckIn = (try? context.fetch(journalRequest))?.first?
.value(forKey: "completionTime") as? Date
return Snapshot(totalValue: totalValue, currencyCode: currency, streak: streak, lastCheckIn: lastCheckIn)
}
}
/// "What's my net worth in Portfolio Journal" reads the total portfolio value
/// from the shared store and speaks it back. Runs headless (no app launch).
struct NetWorthIntent: AppIntent {
static let title: LocalizedStringResource = "Check Net Worth"
// ITMS-90626: intent descriptions must not contain the word "Siri".
static let description = IntentDescription("Get your total portfolio value.")
static let openAppWhenRun = false
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = SharedPortfolioReader.read()
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = snapshot.currencyCode
let formatted = formatter.string(from: NSDecimalNumber(decimal: snapshot.totalValue))
?? "\(snapshot.totalValue)"
let text: String
if snapshot.totalValue == 0 {
text = String(localized: "intent_networth_empty")
} else {
text = String(format: String(localized: "intent_networth_dialog"), formatted)
}
return .result(dialog: IntentDialog(stringLiteral: text))
}
}
/// "What's my streak in Portfolio Journal" reports the current check-in streak
/// and when you last logged. Nice-to-have engagement surface. Runs headless.
struct StreakIntent: AppIntent {
static let title: LocalizedStringResource = "Check Streak"
// ITMS-90626: intent descriptions must not contain the word "Siri".
static let description = IntentDescription("Get your current check-in streak.")
static let openAppWhenRun = false
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
let snapshot = SharedPortfolioReader.read()
let text: String
if snapshot.streak >= 1 {
if let last = snapshot.lastCheckIn {
let df = DateFormatter()
df.dateStyle = .medium
df.timeStyle = .none
text = String(format: String(localized: "intent_streak_with_date"), snapshot.streak, df.string(from: last))
} else {
text = String(format: String(localized: "intent_streak"), snapshot.streak)
}
} else {
text = String(localized: "intent_streak_none")
}
return .result(dialog: IntentDialog(stringLiteral: text))
}
}
struct PortfolioJournalShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: QuickUpdateIntent(),
phrases: [
"Update my portfolio in \(.applicationName)",
"Add a snapshot in \(.applicationName)"
],
shortTitle: "Update Portfolio",
systemImageName: "plus.circle.fill"
)
AppShortcut(
intent: OpenDashboardIntent(),
phrases: [
"Check my portfolio in \(.applicationName)",
"Show my net worth in \(.applicationName)"
],
shortTitle: "Check Portfolio",
systemImageName: "chart.line.uptrend.xyaxis"
)
AppShortcut(
intent: LogSnapshotIntent(),
phrases: [
"Log a value in \(.applicationName)",
"Log my \(\.$source) balance in \(.applicationName)",
"Update \(\.$source) in \(.applicationName)"
],
shortTitle: "Log Value",
systemImageName: "square.and.pencil"
)
AppShortcut(
intent: NetWorthIntent(),
phrases: [
"What's my net worth in \(.applicationName)",
"How much is my portfolio worth in \(.applicationName)",
"Get my net worth from \(.applicationName)"
],
shortTitle: "Net Worth",
systemImageName: "eurosign.circle.fill"
)
AppShortcut(
intent: StreakIntent(),
phrases: [
"What's my streak in \(.applicationName)",
"Check my check-in streak in \(.applicationName)"
],
shortTitle: "Streak",
systemImageName: "flame.fill"
)
}
}
+37
View File
@@ -0,0 +1,37 @@
import SwiftUI
import TipKit
// MARK: - Feature discovery (TipKit, Calm 2.0 Fase 4)
//
// The app's most differentiating features (quick update, screenshot OCR, Siri,
// chart sharing) were invisible unless you stumbled on them. One contextual tip
// each, shown at most once a day (see Tips.configure in PortfolioJournalApp),
// auto-invalidated after display or when the action is performed.
struct QuickUpdateTip: Tip {
var title: Text { Text(String(localized: "tip_quick_update_title")) }
var message: Text? { Text(String(localized: "tip_quick_update_body")) }
var image: Image? { Image(systemName: "bolt.circle.fill") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
struct ScreenshotOCRTip: Tip {
var title: Text { Text(String(localized: "tip_ocr_title")) }
var message: Text? { Text(String(localized: "tip_ocr_body")) }
var image: Image? { Image(systemName: "text.viewfinder") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
struct SiriTip: Tip {
var title: Text { Text(String(localized: "tip_siri_title")) }
var message: Text? { Text(String(localized: "tip_siri_body")) }
var image: Image? { Image(systemName: "mic.fill") }
var options: [any TipOption] { [MaxDisplayCount(1)] }
}
struct ChartShareTip: Tip {
var title: Text { Text(String(localized: "tip_chart_share_title")) }
var message: Text? { Text(String(localized: "tip_chart_share_body")) }
var image: Image? { Image(systemName: "square.and.arrow.up") }
var options: [any TipOption] { [MaxDisplayCount(2)] }
}
@@ -0,0 +1,36 @@
import Foundation
import Combine
@MainActor
class AppUpdateService: ObservableObject {
static let shared = AppUpdateService()
@Published var updateAvailable = false
@Published var latestVersion: String?
private init() {}
func checkForUpdate() {
guard let bundleId = Bundle.main.bundleIdentifier else { return }
let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
guard let url = URL(string: urlString) else { return }
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let first = results.first,
let appStoreVersion = first["version"] as? String,
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
else { return }
if appStoreVersion.compare(currentVersion, options: .numeric) == .orderedDescending {
self.updateAvailable = true
self.latestVersion = appStoreVersion
}
} catch {
// silently fail
}
}
}
}
@@ -0,0 +1,205 @@
import Foundation
import CoreData
enum BackupLocation: String {
case local = "On device"
case iCloud = "iCloud"
}
struct BackupRecord: Identifiable {
let id: String
let url: URL
let date: Date
let size: Int64
let location: BackupLocation
}
class BackupService {
static let shared = BackupService()
private let fileManager: FileManager
private let dateProvider: () -> Date
private let localBaseDirectoryProvider: () -> URL?
private let iCloudBaseDirectoryProvider: () -> URL?
private let exportProvider: () -> String
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private init() {
fileManager = .default
dateProvider = Date.init
localBaseDirectoryProvider = { [fileManager] in
fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
}
iCloudBaseDirectoryProvider = { [fileManager] in
fileManager.url(forUbiquityContainerIdentifier: nil)
}
exportProvider = {
let context = CoreDataStack.shared.viewContext
let sources = BackupService.fetchAllSources(in: context)
let categories = BackupService.fetchAllCategories(in: context)
return ExportService.shared.exportToJSON(sources: sources, categories: categories)
}
}
init(
fileManager: FileManager = .default,
dateProvider: @escaping () -> Date,
localBaseDirectoryProvider: @escaping () -> URL?,
iCloudBaseDirectoryProvider: @escaping () -> URL?,
exportProvider: @escaping () -> String
) {
self.fileManager = fileManager
self.dateProvider = dateProvider
self.localBaseDirectoryProvider = localBaseDirectoryProvider
self.iCloudBaseDirectoryProvider = iCloudBaseDirectoryProvider
self.exportProvider = exportProvider
}
func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] {
let timestamp = dateFormatter.string(from: dateProvider())
let fileName = "backup-\(timestamp).json"
let content = exportProvider()
var records: [BackupRecord] = []
if let localDir = backupDirectory() {
let localURL = localDir.appendingPathComponent(fileName)
write(content: content, to: localURL)
pruneBackups(in: localDir, keep: retentionCount, location: .local)
records.append(contentsOf: listBackups(in: localDir, location: .local))
}
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
let iCloudURL = iCloudDir.appendingPathComponent(fileName)
write(content: content, to: iCloudURL)
pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud)
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
}
return records.sorted { $0.date > $1.date }
}
/// Runs an automatic backup if auto-backup is enabled and the configured
/// interval has elapsed since the last one. Call on app foreground. Reads its
/// config from UserDefaults so it needs no coupling to the Settings VM. The
/// backup content is the lossless JSON v3 export (goals, contributions, journal).
@discardableResult
func runScheduledBackupIfNeeded() -> Bool {
let d = UserDefaults.standard
// `backupsEnabled` is only settable by Premium users, so it also gates this.
guard d.bool(forKey: "backupsEnabled") else { return false }
let frequencyDays = d.integer(forKey: "autoBackupFrequencyDays") // 0 = off
guard frequencyDays > 0 else { return false }
if let last = d.object(forKey: "lastAutoBackupDate") as? Date,
dateProvider().timeIntervalSince(last) < Double(frequencyDays) * 86_400 {
return false
}
let retention = (d.object(forKey: "backupRetentionCount") as? Int) ?? 10
let includeICloud = d.bool(forKey: "cloudSyncEnabled")
_ = createBackup(retentionCount: retention, includeICloud: includeICloud)
d.set(dateProvider(), forKey: "lastAutoBackupDate")
return true
}
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
var records: [BackupRecord] = []
if let localDir = backupDirectory() {
records.append(contentsOf: listBackups(in: localDir, location: .local))
}
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
}
return records.sorted { $0.date > $1.date }
}
private func backupDirectory() -> URL? {
guard let base = localBaseDirectoryProvider() else {
return nil
}
let dir = base.appendingPathComponent("Backups", isDirectory: true)
ensureDirectoryExists(dir)
return dir
}
private func iCloudBackupDirectory() -> URL? {
guard let base = iCloudBaseDirectoryProvider() else { return nil }
let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true)
ensureDirectoryExists(dir)
return dir
}
private func ensureDirectoryExists(_ url: URL) {
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
}
}
private func write(content: String, to url: URL) {
do {
try content.write(to: url, atomically: true, encoding: .utf8)
} catch {
print("Backup write failed: \(error)")
}
}
private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] {
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else {
return []
}
return files.compactMap { url in
guard url.lastPathComponent.hasPrefix("backup-"),
url.pathExtension.lowercased() == "json" else {
return nil
}
let name = url.deletingPathExtension().lastPathComponent
let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date()
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0
return BackupRecord(
id: "\(location.rawValue)-\(name)",
url: url,
date: date,
size: size,
location: location
)
}
}
private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) {
guard keep > 0 else { return }
let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date }
let toDelete = backups.dropFirst(keep)
for backup in toDelete {
try? fileManager.removeItem(at: backup.url)
}
}
private func parseDate(from filename: String) -> Date? {
let parts = filename.split(separator: "-")
guard parts.count >= 3 else { return nil }
let dateString = "\(parts[1])-\(parts[2])"
return dateFormatter.date(from: dateString)
}
private static func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
private static func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
}
@@ -376,7 +376,7 @@ class CalculationService {
sources: [InvestmentSource],
totalPortfolioValue: Decimal
) -> [CategoryMetrics] {
categories.map { category in
let rawMetrics = categories.map { category in
let categorySources = sources.filter { $0.category?.id == category.id }
let allSnapshots = categorySources.flatMap { $0.snapshotsArray }
let metrics = calculateCategoryMetrics(from: allSnapshots)
@@ -396,6 +396,24 @@ class CalculationService {
metrics: metrics
)
}
let filtered = rawMetrics.filter { metric in
metric.totalValue > 0 && sources.contains { $0.category?.id == metric.id }
}
var deduped: [String: CategoryMetrics] = [:]
for metric in filtered {
let key = metric.categoryName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if let existing = deduped[key] {
if metric.totalValue > existing.totalValue {
deduped[key] = metric
}
} else {
deduped[key] = metric
}
}
return Array(deduped.values)
}
private struct SeriesPoint {
@@ -0,0 +1,186 @@
import Foundation
import SwiftUI
import UIKit
// MARK: - Environment flag for image export
//
// ImageRenderer can't rasterize Metal-backed `.drawingGroup()` layers (they come
// out blank). Chart views check this flag and skip drawingGroup during export.
private struct ChartImageExportKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var chartImageExport: Bool {
get { self[ChartImageExportKey.self] }
set { self[ChartImageExportKey.self] = newValue }
}
}
extension View {
/// GPU-rasterize for scroll performance, except while exporting a share
/// image (ImageRenderer can't rasterize Metal layers they come out blank).
@ViewBuilder
func chartDrawingGroup(disabledForExport isExporting: Bool) -> some View {
if isExporting {
self
} else {
drawingGroup()
}
}
}
// MARK: - Chart share service
//
// Renders the current chart into a branded card (app mark, KPIs, QR to the App
// Store) and presents the system share sheet. Reuses the QR/App Store plumbing
// from GoalShareService.
@MainActor
final class ChartShareService {
static let shared = ChartShareService()
private init() {}
static let appStoreShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=chart_share")!
func share<Content: View>(title: String, subtitle: String, stats: [ChartStat], @ViewBuilder chart: () -> Content) {
FirebaseService.shared.logShare(type: "chart")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
let card = ChartShareCardView(
title: title,
subtitle: subtitle,
stats: stats,
qrCodeImage: GoalShareService.generateQRCode(for: Self.appStoreShareURL, size: 200),
chart: chart()
)
let renderer = ImageRenderer(content: card)
renderer.scale = 3
renderer.proposedSize = ProposedViewSize(width: 720, height: nil)
let shareText = String(format: String(localized: "chart_share_text"), title)
+ "\n" + Self.appStoreShareURL.absoluteString
var items: [Any] = []
if let image = renderer.uiImage {
items.append(image)
}
items.append(shareText)
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
activityVC.excludedActivityTypes = [.addToReadingList, .assignToContact, .openInIBooks]
// iPad: activity sheet requires a popover anchor.
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
var presenter = viewController
while let presented = presenter.presentedViewController {
presenter = presented
}
presenter.present(activityVC, animated: true)
}
}
// MARK: - Branded card
struct ChartShareCardView<Content: View>: View {
let title: String
let subtitle: String
let stats: [ChartStat]
let qrCodeImage: UIImage?
let chart: Content
private static var dateLabel: String {
Date().formatted(date: .abbreviated, time: .omitted)
}
var body: some View {
VStack(alignment: .leading, spacing: 18) {
// Header: brand + chart title
HStack(spacing: 12) {
if let brandMark = UIImage(named: "BrandMark") {
Image(uiImage: brandMark)
.resizable()
.scaledToFit()
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 2) {
Text("Portfolio Journal")
.font(.headline)
Text("\(title) · \(Self.dateLabel)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
chart
.environment(\.chartImageExport, true)
if !stats.isEmpty {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
VStack(alignment: .leading, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
Spacer()
}
}
Divider()
// Footer: tagline + QR to the App Store
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: "chart_share_tagline"))
.font(.footnote.weight(.medium))
Text(verbatim: "portfoliojournal.app")
.font(.footnote)
.foregroundColor(.appPrimary)
}
Spacer()
if let qrCodeImage {
VStack(spacing: 4) {
Image(uiImage: qrCodeImage)
.resizable()
.interpolation(.none)
.scaledToFit()
.frame(width: 64, height: 64)
Text(String(localized: "chart_share_scan"))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
.padding(24)
.frame(width: 720)
.background(Color(.systemBackground))
.environment(\.colorScheme, .light)
}
}
+60 -2
View File
@@ -1,5 +1,6 @@
import Foundation
import UIKit
import CoreData
class ExportService {
static let shared = ExportService()
@@ -63,7 +64,7 @@ class ExportService {
) -> String {
var exportData: [String: Any] = [:]
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
exportData["version"] = 2
exportData["version"] = 3
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
@@ -92,14 +93,23 @@ class ExportService {
"icon": category?.icon ?? "chart.pie.fill"
]
if let target = category?.allocationTarget?.doubleValue, target > 0 {
categoryDict["allocationTarget"] = target
}
var sourcesArray: [[String: Any]] = []
for source in categorySources {
var sourceDict: [String: Any] = [
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency
"notificationFrequency": source.notificationFrequency,
"customFrequencyMonths": Int(source.customFrequencyMonths)
]
if let contribution = source.monthlyContribution {
sourceDict["monthlyContribution"] = contribution.doubleValue
}
var snapshotsArray: [[String: Any]] = []
for snapshot in source.snapshotsArray {
var snapshotDict: [String: Any] = [
@@ -134,6 +144,54 @@ class ExportService {
exportData["accounts"] = accountsArray
// Goals (all accounts)
let isoFormatter = ISO8601DateFormatter()
let goalContext = CoreDataStack.shared.viewContext
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
let allGoals = (try? goalContext.fetch(goalRequest)) ?? []
var goalsArray: [[String: Any]] = []
for goal in allGoals {
var goalDict: [String: Any] = [
"name": goal.name,
"isActive": goal.isActive,
"account": goal.account?.name as Any
]
if let amount = goal.targetAmount {
goalDict["targetAmount"] = amount.doubleValue
}
if let date = goal.targetDate {
goalDict["targetDate"] = isoFormatter.string(from: date)
}
goalsArray.append(goalDict)
}
exportData["goals"] = goalsArray
// Journal entries (all)
let journalRequest: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
let allJournalEntries = (try? goalContext.fetch(journalRequest)) ?? []
var journalArray: [[String: Any]] = []
for entry in allJournalEntries {
guard let monthKey = entry.monthKey, !monthKey.isEmpty else { continue }
var entryDict: [String: Any] = ["monthKey": monthKey]
if let note = entry.note, !note.isEmpty {
entryDict["note"] = note
}
if let mood = entry.moodRaw, !mood.isEmpty {
entryDict["mood"] = mood
}
if entry.rating > 0 {
entryDict["rating"] = Int(entry.rating)
}
if let completionTime = entry.completionTime {
entryDict["completionTime"] = isoFormatter.string(from: completionTime)
}
if let createdAt = entry.createdAt {
entryDict["createdAt"] = isoFormatter.string(from: createdAt)
}
journalArray.append(entryDict)
}
exportData["journalEntries"] = journalArray
// Add summary
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
exportData["summary"] = [
@@ -70,7 +70,7 @@ class FirebaseService {
func logPaywallShown(trigger: String) {
guard isConfigured else { return }
Analytics.logEvent("paywall_shown", parameters: [
"trigger": trigger
"paywall_trigger": trigger
])
}
@@ -163,6 +163,31 @@ class FirebaseService {
])
}
/// Per-step onboarding funnel event lets us see exactly where users drop off
/// inside onboarding (only the final `onboarding_completed` existed before).
func logOnboardingStep(step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_step", parameters: [
"step": step
])
}
func logOnboardingSkipped(atStep step: Int) {
guard isConfigured else { return }
Analytics.logEvent("onboarding_skipped", parameters: [
"step": step
])
}
/// Viral loop measurement: fired whenever the user opens a share sheet with
/// app content (portfolio image, check-in, goal). `type` identifies the surface.
func logShare(type: String) {
guard isConfigured else { return }
Analytics.logEvent("content_shared", parameters: [
"share_type": type
])
}
func logWidgetUsed(widgetType: String) {
guard isConfigured else { return }
Analytics.logEvent("widget_used", parameters: [
@@ -1,44 +1,193 @@
import Foundation
import SwiftUI
import UIKit
import CoreImage.CIFilterBuiltins
class GoalShareService {
static let shared = GoalShareService()
/// App Store URL - auto-redirects to user's country
/// NOTE: 6757678318 is the real ADAM id (verified via App Store Connect API).
/// The previous id (6744983373) returned a 404, breaking the share QR loop.
static let appStoreURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318")!
/// Website URL - has Open Graph tags for social media previews
static let websiteURL = URL(string: "https://portfoliojournal.app")!
/// Share page base URL - parameters will be appended
static let sharePageBaseURL = "https://portfoliojournal.app/share"
private init() {}
/// Build share URL with goal parameters for dynamic OG tags
static func buildShareURL(goalName: String, progressPercent: Int) -> URL {
var components = URLComponents(string: sharePageBaseURL)!
components.queryItems = [
URLQueryItem(name: "goal", value: goalName),
URLQueryItem(name: "progress", value: String(progressPercent))
]
return components.url ?? URL(string: sharePageBaseURL)!
}
/// Generate a QR code image for the App Store URL
static func generateQRCode(for url: URL, size: CGFloat = 60) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
// Scale up the QR code
let scale = size / outputImage.extent.size.width
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
return nil
}
return UIImage(cgImage: cgImage)
}
@MainActor
func shareGoal(
name: String,
progress: Double,
currentValue: Decimal,
targetValue: Decimal
targetValue: Decimal,
targetDate: Date? = nil,
estimatedCompletionDate: Date? = nil,
privacyMode: Bool = false
) {
FirebaseService.shared.logShare(type: "goal")
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
// Generate QR code for the card
let qrCodeImage = Self.generateQRCode(for: Self.appStoreURL, size: 200)
let card = GoalShareCardView(
name: name,
progress: progress,
currentValue: currentValue,
targetValue: targetValue
targetValue: targetValue,
targetDate: targetDate,
estimatedCompletionDate: estimatedCompletionDate,
privacyMode: privacyMode,
qrCodeImage: qrCodeImage
)
let progressPercent = Int(progress * 100)
let shareURL = Self.buildShareURL(goalName: name, progressPercent: progressPercent)
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card)
let scale = viewController.view.window?.windowScene?.screen.scale
?? viewController.traitCollection.displayScale
renderer.scale = scale
if let image = renderer.uiImage {
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
viewController.present(activityVC, animated: true)
// Use combined item provider for text + image together
let shareItem = GoalShareItem(
image: image,
goalName: name,
progressPercent: progressPercent,
shareURL: shareURL
)
presentShareSheet(items: [shareItem], from: viewController)
} else {
// Fallback to text only
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
presentShareSheet(items: [text], from: viewController)
}
} else {
let text = "I am \(Int(progress * 100))% towards \(name) on Portfolio Journal!"
let activityVC = UIActivityViewController(activityItems: [text], applicationActivities: nil)
viewController.present(activityVC, animated: true)
// iOS 15 fallback - text only
let text = buildShareText(goalName: name, progressPercent: progressPercent, shareURL: shareURL)
presentShareSheet(items: [text], from: viewController)
}
}
private func buildShareText(goalName: String, progressPercent: Int, shareURL: URL) -> String {
return """
I'm \(progressPercent)% towards my "\(goalName)" goal! 🎯
Track your investment goals with Portfolio Journal.
\(shareURL.absoluteString)
"""
}
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
// Exclude some activities that don't make sense for goal sharing
activityVC.excludedActivityTypes = [
.addToReadingList,
.assignToContact,
.openInIBooks
]
// iPad support - prevent crash by setting popover source
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
viewController.present(activityVC, animated: true)
}
}
/// Combined share item that provides both image and text together
private class GoalShareItem: NSObject, UIActivityItemSource {
let image: UIImage
let goalName: String
let progressPercent: Int
let shareURL: URL
init(image: UIImage, goalName: String, progressPercent: Int, shareURL: URL) {
self.image = image
self.goalName = goalName
self.progressPercent = progressPercent
self.shareURL = shareURL
super.init()
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return image
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
// For most activities, return the image
// The text will be provided via LPLinkMetadata or as a separate item
return image
}
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
return "My Investment Goal Progress - Portfolio Journal"
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.title = "I'm \(progressPercent)% towards my \"\(goalName)\" goal! 🎯"
metadata.originalURL = shareURL
metadata.url = shareURL
metadata.imageProvider = NSItemProvider(object: image)
// Set icon
if let appIcon = UIImage(named: "BrandMark") {
metadata.iconProvider = NSItemProvider(object: appIcon)
}
return metadata
}
}
import LinkPresentation
+89 -4
View File
@@ -16,7 +16,13 @@ class IAPService: ObservableObject {
// MARK: - Constants
/// Lifetime unlock (non-consumable). Existing product keep the id stable.
static let premiumProductID = "com.portfoliojournal.premium"
/// Annual auto-renewable subscription with intro free trial. Acts as the primary
/// offer; lifetime becomes the "best value" anchor. Must be created in App Store
/// Connect with this exact id until then the paywall gracefully shows lifetime only.
static let annualProductID = "com.portfoliojournal.premium.annual"
static let allProductIDs: Set<String> = [premiumProductID, annualProductID]
static let premiumPrice = "€4.69"
// MARK: - Private Properties
@@ -49,6 +55,13 @@ class IAPService: ObservableObject {
}
}
#if DEBUG
func setPremiumForTesting(_ value: Bool, familyShared: Bool = false) {
isPremium = value
isFamilyShared = familyShared
}
#endif
deinit {
updateListenerTask?.cancel()
}
@@ -57,7 +70,7 @@ class IAPService: ObservableObject {
func loadProducts() async {
do {
products = try await Product.products(for: [Self.premiumProductID])
products = try await Product.products(for: Self.allProductIDs)
print("Loaded \(products.count) products")
} catch {
print("Failed to load products: \(error)")
@@ -66,10 +79,16 @@ class IAPService: ObservableObject {
// MARK: - Purchase
/// Purchases the lifetime unlock (backwards-compatible entry point).
func purchase() async throws {
guard let product = products.first else {
guard let product = premiumProduct ?? products.first else {
throw IAPError.productNotFound
}
try await purchase(product)
}
/// Purchases a specific product (annual subscription or lifetime unlock).
func purchase(_ product: Product) async throws {
purchaseState = .purchasing
@@ -133,6 +152,13 @@ class IAPService: ObservableObject {
}
}
// MARK: - TestFlight Detection
/// TestFlight builds use a "sandboxReceipt" instead of the production "receipt".
private var isRunningOnTestFlight: Bool {
Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
}
// MARK: - Update Premium Status
func updatePremiumStatus() async {
@@ -147,10 +173,21 @@ class IAPService: ObservableObject {
}
#endif
// TestFlight builds always get Premium so testers can evaluate all features
if isRunningOnTestFlight {
isPremium = true
isFamilyShared = false
sharedDefaults?.set(true, forKey: "premiumUnlocked")
return
}
var entitledProductID = Self.premiumProductID
for await result in StoreKit.Transaction.currentEntitlements {
if case .verified(let transaction) = result {
if transaction.productID == Self.premiumProductID {
// Lifetime unlock OR an active annual subscription both grant premium.
if Self.allProductIDs.contains(transaction.productID) {
isEntitled = true
entitledProductID = transaction.productID
familyShared = transaction.ownershipType == .familyShared
break
}
@@ -165,7 +202,7 @@ class IAPService: ObservableObject {
let context = CoreDataStack.shared.viewContext
PremiumStatus.updateStatus(
isPremium: isEntitled,
productIdentifier: Self.premiumProductID,
productIdentifier: entitledProductID,
transactionId: nil,
isFamilyShared: familyShared,
in: context
@@ -216,9 +253,34 @@ class IAPService: ObservableObject {
products.first { $0.id == Self.premiumProductID }
}
/// Annual subscription, nil until the product exists in App Store Connect.
var annualProduct: Product? {
products.first { $0.id == Self.annualProductID }
}
var formattedPrice: String {
premiumProduct?.displayPrice ?? Self.premiumPrice
}
var formattedAnnualPrice: String? {
annualProduct?.displayPrice
}
/// Localized description of the annual intro offer ("7 days free"), if configured.
var annualTrialDescription: String? {
guard let intro = annualProduct?.subscription?.introductoryOffer,
intro.paymentMode == .freeTrial else { return nil }
let period = intro.period
let unit: String
switch period.unit {
case .day: unit = String(localized: "paywall_trial_days")
case .week: unit = String(localized: "paywall_trial_weeks")
case .month: unit = String(localized: "paywall_trial_months")
case .year: unit = String(localized: "paywall_trial_years")
@unknown default: unit = ""
}
return String(format: String(localized: "paywall_trial_format"), period.value, unit)
}
}
// MARK: - IAP Error
@@ -253,4 +315,27 @@ extension IAPService {
("xmark.circle", "No Ads", "Ad-free experience forever"),
("person.2", "Family Sharing", "Share with up to 5 family members")
]
/// Condensed benefits shown on the paywall (outcome-focused). Multiple Accounts and
/// Family Sharing are key differentiators vs competitors keep them visible here.
static var paywallBenefits: [(icon: String, title: String, subtitle: String)] {[
("clock.arrow.circlepath",
String(localized: "paywall_benefit_history_title"),
String(localized: "paywall_benefit_history_subtitle")),
("chart.bar.xaxis",
String(localized: "paywall_benefit_charts_title"),
String(localized: "paywall_benefit_charts_subtitle")),
("wand.and.stars",
String(localized: "paywall_benefit_forecasts_title"),
String(localized: "paywall_benefit_forecasts_subtitle")),
("person.2",
String(localized: "paywall_benefit_accounts_title"),
String(localized: "paywall_benefit_accounts_subtitle")),
("person.3",
String(localized: "paywall_benefit_family_title"),
String(localized: "paywall_benefit_family_subtitle")),
("xmark.circle",
String(localized: "paywall_benefit_noads_title"),
String(localized: "paywall_benefit_noads_subtitle"))
]}
}
+648 -12
View File
@@ -31,6 +31,25 @@ class ImportService {
case json
}
/// Read-only summary of what an import WOULD do, computed against existing
/// data without mutating anything. Presented to the user for confirmation
/// before the real import runs.
struct ImportPreview {
var sourcesToCreate: Int = 0
var snapshotsToCreate: Int = 0
var snapshotsToUpdate: Int = 0
var categoriesToCreate: Int = 0
var goalsToCreate: Int = 0
var journalToCreate: Int = 0
var journalToUpdate: Int = 0
var hasAnything: Bool {
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
categoriesToCreate > 0 || goalsToCreate > 0 ||
journalToCreate > 0 || journalToUpdate > 0
}
}
struct ImportedAccount {
let name: String
let currency: String?
@@ -44,11 +63,14 @@ class ImportService {
let name: String
let colorHex: String?
let icon: String?
let allocationTarget: Double?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let monthlyContribution: Decimal?
let customFrequencyMonths: Int?
let snapshots: [ImportedSnapshot]
}
@@ -59,6 +81,23 @@ class ImportService {
let notes: String?
}
struct ImportedGoal {
let name: String
let targetAmount: Decimal?
let targetDate: Date?
let isActive: Bool
let accountName: String?
}
struct ImportedJournalEntry {
let monthKey: String
let note: String?
let mood: String?
let rating: Int?
let completionTime: Date?
let createdAt: Date?
}
func importData(
content: String,
format: ImportFormat,
@@ -79,7 +118,14 @@ class ImportService {
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
let goals = parseGoals(content)
let journal = parseJournal(content)
return applyImport(
parsed,
context: CoreDataStack.shared.viewContext,
goals: goals,
journal: journal
)
}
}
@@ -93,6 +139,8 @@ class ImportService {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
@@ -106,6 +154,8 @@ class ImportService {
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
let totalSnapshots = parsed.reduce(0) { total, account in
@@ -120,7 +170,12 @@ class ImportService {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(parsed, context: context) { completed in
let result = self.applyImport(
parsed,
context: context,
goals: goals,
journal: journal
) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
@@ -147,7 +202,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
static func sampleJSON() -> String {
return """
{
"version": 2,
"version": 3,
"currency": "EUR",
"accounts": [{
"name": "Personal",
@@ -171,6 +226,203 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
"""
}
// MARK: - Dry-Run Preview (read-only)
/// Parses the content and counts what an import WOULD do against existing
/// data, WITHOUT mutating anything. Uses the same parse functions and the
/// same existence checks as `applyImport`, but only reads.
func previewImportAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
let preview = self.computePreview(
accounts: parsed,
goals: goals,
journal: journal,
context: context
)
continuation.resume(returning: preview)
}
}
}
/// Dry-run for the CSV column-mapping flow.
func previewCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(
preview: preview,
mapping: mapping,
defaultAccountName: defaultAccountName
)
let result = self.computePreview(
accounts: accounts,
goals: [],
journal: [],
context: context
)
continuation.resume(returning: result)
}
}
}
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
/// (same source name+account, same-day snapshot, category name, goal
/// name+account, journal monthKey) but never writes. Because it never
/// saves, it may not perfectly disambiguate multiple new sources/categories
/// that collapse to the same name within one import; it errs toward the
/// user-facing counts `applyImport` would produce for typical files.
private func computePreview(
accounts: [ImportedAccount],
goals: [ImportedGoal],
journal: [ImportedJournalEntry],
context: NSManagedObjectContext
) -> ImportPreview {
var preview = ImportPreview()
let existingCategories = fetchCategories(in: context)
let categoryLookup = buildCategoryLookup(from: existingCategories)
let existingAccountsLookup = fetchAccountsLookup(in: context)
// Track names we would create so duplicates within the same import file
// aren't double-counted.
var plannedCategoryKeys = Set<String>()
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
var accountsByName: [String: Account] = existingAccountsLookup
// "Other" is created on demand by applyImport when a source has no
// resolvable category; only count it if we actually need it.
var otherCategoryNeeded = false
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
for importedAccount in accounts {
let account = existingAccountsLookup[importedAccount.name]
accountsByName[importedAccount.name] = account
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
if existingCategory == nil {
if shouldUseOther {
otherCategoryNeeded = otherCategoryNeeded || !otherExists
} else {
let key = normalizedCategoryName(
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
)
if !plannedCategoryKeys.contains(key) {
plannedCategoryKeys.insert(key)
preview.categoriesToCreate += 1
}
}
}
for importedSource in importedCategory.sources {
let accountId = account?.safeId
let existingSource = fetchSource(
named: importedSource.name,
accountId: accountId,
in: context
)
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
plannedSourceKeys.insert(sourceKey)
preview.sourcesToCreate += 1
}
for snapshot in importedSource.snapshots {
// Only an EXISTING source can have a same-day snapshot to update.
if let existing = existingSource,
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
preview.snapshotsToUpdate += 1
} else {
preview.snapshotsToCreate += 1
}
}
}
}
}
if otherCategoryNeeded {
preview.categoriesToCreate += 1
}
// Goals dedup by name within account (case-insensitive), same as applyImport.
var plannedGoalKeys = Set<String>()
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
if !plannedGoalKeys.contains(key) {
plannedGoalKeys.insert(key)
preview.goalsToCreate += 1
}
}
}
// Journal dedup by monthKey, same as applyImport.
var plannedJournalKeys = Set<String>()
for importedEntry in journal {
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
plannedJournalKeys.insert(importedEntry.monthKey)
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
preview.journalToUpdate += 1
} else {
preview.journalToCreate += 1
}
}
return preview
}
/// Read-only fetch of an existing source by name within an account (or with
/// no account). Mirrors applyImport's `name && account?.id` match.
private func fetchSource(
named name: String,
accountId: UUID?,
in context: NSManagedObjectContext
) -> InvestmentSource? {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", name)
let matches = (try? context.fetch(request)) ?? []
return matches.first { $0.account?.id == accountId }
}
// MARK: - Parsing
private func parseCSV(
@@ -230,9 +482,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: nil,
sources: importedSources
)
}
return ImportedAccount(
@@ -274,9 +537,12 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let allocationTarget = categoryDict["allocationTarget"] as? Double
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let monthlyContribution = (sourceDict["monthlyContribution"] as? Double).map { Decimal($0) }
let customFrequencyMonths = sourceDict["customFrequencyMonths"] as? Int
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
@@ -295,13 +561,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
return ImportedSource(
name: sourceName,
monthlyContribution: monthlyContribution,
customFrequencyMonths: customFrequencyMonths,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: allocationTarget,
sources: sources
)
}
@@ -344,13 +616,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
return ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: nil,
sources: sources
)
}
@@ -371,11 +649,70 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return []
}
private func parseGoals(_ content: String) -> [ImportedGoal] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let goalsArray = json["goals"] as? [[String: Any]] else {
return []
}
return goalsArray.compactMap { goalDict -> ImportedGoal? in
guard let name = goalDict["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let targetAmount = (goalDict["targetAmount"] as? Double).map { Decimal($0) }
let targetDate = (goalDict["targetDate"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let isActive = goalDict["isActive"] as? Bool ?? true
let accountName = goalDict["account"] as? String
return ImportedGoal(
name: name,
targetAmount: targetAmount,
targetDate: targetDate,
isActive: isActive,
accountName: accountName
)
}
}
private func parseJournal(_ content: String) -> [ImportedJournalEntry] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let journalArray = json["journalEntries"] as? [[String: Any]] else {
return []
}
return journalArray.compactMap { entryDict -> ImportedJournalEntry? in
guard let monthKey = entryDict["monthKey"] as? String,
!monthKey.isEmpty else {
return nil
}
let note = entryDict["note"] as? String
let mood = entryDict["mood"] as? String
let rating = entryDict["rating"] as? Int
let completionTime = (entryDict["completionTime"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let createdAt = (entryDict["createdAt"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
return ImportedJournalEntry(
monthKey: monthKey,
note: note,
mood: mood,
rating: rating,
completionTime: completionTime,
createdAt: createdAt
)
}
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
goals: [ImportedGoal] = [],
journal: [ImportedJournalEntry] = [],
snapshotProgress: ((Int) -> Void)? = nil
) -> ImportResult {
let accountRepository = AccountRepository(context: context)
@@ -383,6 +720,15 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
let sourceRepository = InvestmentSourceRepository(context: context)
let snapshotRepository = SnapshotRepository(context: context)
// Batch import: suppress the per-row widget refresh (each does a synchronous
// WAL checkpoint on the main context) to avoid hanging the main thread on
// large imports. Refresh once when everything is done.
CoreDataStack.shared.suppressWidgetRefresh = true
defer {
CoreDataStack.shared.suppressWidgetRefresh = false
CoreDataStack.shared.refreshWidgetData()
}
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
@@ -404,6 +750,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
// Build lookup of existing accounts by fetching directly from database
let existingAccountsLookup = fetchAccountsLookup(in: context)
var accountsByName: [String: Account] = existingAccountsLookup
for importedAccount in accounts {
let existingAccount = existingAccountsLookup[importedAccount.name]
let account: Account
@@ -419,6 +767,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
)
accountsCreated += 1
}
accountsByName[importedAccount.name] = account
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
@@ -438,6 +787,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
))
categoryLookup[normalizedCategoryName(category.name)] = category
if let allocationTarget = importedCategory.allocationTarget {
category.allocationTarget = NSNumber(value: allocationTarget)
}
for importedSource in importedCategory.sources {
let accountId = account.safeId
let existingSource = sourceRepository.sources.first(where: {
@@ -454,6 +807,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
sourcesCreated += 1
}
if let monthlyContribution = importedSource.monthlyContribution {
source.monthlyContribution = NSDecimalNumber(decimal: monthlyContribution)
}
if let customFrequencyMonths = importedSource.customFrequencyMonths {
source.customFrequencyMonths = Int16(customFrequencyMonths)
}
for snapshot in importedSource.snapshots {
// Check if a snapshot with the same date already exists for this source
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
@@ -475,7 +835,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes
notes: snapshot.notes,
batch: true
)
snapshotsCreated += 1
}
@@ -494,6 +855,47 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
}
}
// Goals: create directly on the background context. Dedup by name within
// the same account (case-insensitive), mirroring GoalRepository behaviour.
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { continue }
guard let targetAmount = importedGoal.targetAmount else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let goal = Goal(context: context)
goal.name = trimmedName
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = importedGoal.targetDate
goal.account = resolvedAccount
goal.isActive = importedGoal.isActive
}
}
// Journal entries: create directly on the background context. Dedup by
// monthKey; update fields when an entry already exists.
for importedEntry in journal {
let entry = fetchJournalEntry(monthKey: importedEntry.monthKey, in: context)
?? JournalEntry(context: context)
entry.monthKey = importedEntry.monthKey
if let note = importedEntry.note, !note.isEmpty {
entry.note = note
}
if let mood = importedEntry.mood, !mood.isEmpty {
entry.moodRaw = mood
}
if let rating = importedEntry.rating {
entry.rating = Int16(rating)
}
if let completionTime = importedEntry.completionTime {
entry.completionTime = completionTime
}
if let createdAt = importedEntry.createdAt {
entry.createdAt = createdAt
}
}
if context.hasChanges {
do {
try context.save()
@@ -502,12 +904,26 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
}
}
if !completionDatesByMonth.isEmpty {
for (monthKey, completionDate) in completionDatesByMonth {
// Snapshot-derived completion marking is a FALLBACK for imports that carry no
// journal (CSV, or JSON without journalEntries): it marks months that have
// snapshots as "checked in" so the Home card looks right. When the import DOES
// bring journal entries, they already carry their real completionTime and were
// created on the background context above running setCompletionDate here (on
// viewContext, which hasn't merged those yet) would create DUPLICATE
// JournalEntry rows for the same month. So skip it when journal was imported.
if journal.isEmpty, !completionDatesByMonth.isEmpty {
// MonthlyCheckInStore mutates CoreDataStack.shared.viewContext (main queue).
// applyImport runs on a BACKGROUND context, so calling setCompletionDate
// here directly was a Core Data concurrency violation the imported months'
// JournalEntry.completionTime never persisted, leaving the "next check-in"
// stuck on an old entry. Run it on the viewContext's own queue.
CoreDataStack.shared.viewContext.performAndWait {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
for (monthKey, completionDate) in completionDatesByMonth {
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
}
@@ -586,6 +1002,37 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
return try? context.fetch(request).first
}
/// Fetch an existing goal by name (case-insensitive) within the same account.
private func fetchGoal(
named name: String,
account: Account?,
in context: NSManagedObjectContext
) -> Goal? {
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
if let account = account {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == %@", name, account
)
} else {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == nil", name
)
}
request.fetchLimit = 1
return try? context.fetch(request).first
}
/// Fetch an existing journal entry by monthKey.
private func fetchJournalEntry(
monthKey: String,
in context: NSManagedObjectContext
) -> JournalEntry? {
let request: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
request.predicate = NSPredicate(format: "monthKey == %@", monthKey)
request.fetchLimit = 1
return try? context.fetch(request).first
}
private func canonicalCategoryName(for rawName: String) -> String? {
let normalized = normalizedCategoryName(rawName)
for mapping in categoryAliasMappings {
@@ -655,6 +1102,195 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
]
}
// MARK: - CSV Column Mapping
/// Parsed preview data for the mapping UI
struct CSVPreview {
let rows: [[String]]
let columnCount: Int
func headers(hasHeaderRow: Bool) -> [String] {
if hasHeaderRow, let first = rows.first {
return first
}
return (0..<columnCount).map { "Column \($0 + 1)" }
}
func sampleRow(hasHeaderRow: Bool) -> [String]? {
let start = hasHeaderRow ? 1 : 0
return rows.count > start ? rows[start] : nil
}
}
func previewCSV(_ content: String) -> CSVPreview {
let rows = parseCSVRows(content)
let columnCount = rows.first?.count ?? 0
return CSVPreview(rows: rows, columnCount: columnCount)
}
// Maps each app field to a CSV column index or a constant value
struct CSVMappingConfig {
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
static let notMapped = -1
static let constant = -2
static let useToday = -3
var hasHeaderRow: Bool = true
var sourceIndex: Int = notMapped
var sourceConstant: String = ""
var valueIndex: Int = notMapped
var dateIndex: Int = useToday // default: use today
var categoryIndex: Int = constant
var categoryConstant: String = "Other" // default category name when constant
var contributionIndex: Int = notMapped
var notesIndex: Int = notMapped
var isValid: Bool {
let sourceOk: Bool = {
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
return sourceIndex >= 0
}()
let valueOk = valueIndex >= 0
return sourceOk && valueOk
}
}
func importCSVWithMapping(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> ImportResult {
let preview = previewCSV(content)
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
}
func importCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
let totalSnapshots = accounts.reduce(0) { t, a in
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(accounts, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
private func parseCSVWithMapping(
preview: CSVPreview,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> [ImportedAccount] {
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
let fallbackAccount = defaultAccountName ?? "Personal"
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in dataRows {
// Source name
let sourceName: String
if mapping.sourceIndex == CSVMappingConfig.constant {
guard !mapping.sourceConstant.isEmpty else { continue }
sourceName = mapping.sourceConstant
} else if mapping.sourceIndex >= 0 {
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
sourceName = v
} else {
continue
}
// Value
guard mapping.valueIndex >= 0,
let valStr = row.safeValue(at: mapping.valueIndex),
let value = parseDecimal(valStr) else { continue }
// Date
let date: Date
if mapping.dateIndex == CSVMappingConfig.useToday {
date = Calendar.current.startOfDay(for: Date())
} else if mapping.dateIndex >= 0,
let dateStr = row.safeValue(at: mapping.dateIndex),
let parsed = parseDate(dateStr) {
date = parsed
} else {
date = Calendar.current.startOfDay(for: Date())
}
// Category
let categoryName: String
if mapping.categoryIndex == CSVMappingConfig.constant {
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
} else if mapping.categoryIndex >= 0,
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
categoryName = v
} else {
categoryName = "Other"
}
// Contribution
let contribution: Decimal? = mapping.contributionIndex >= 0
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
: nil
// Notes
let notes: String? = mapping.notesIndex >= 0
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
: nil
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(
name: sourceName,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: nil,
sources: importedSources
)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
@@ -0,0 +1,241 @@
import SwiftUI
/// On-device, privacy-friendly rule-based insights engine.
///
/// Produces a prioritized list of `PortfolioInsight`s purely from local data
/// no network, no AI calls. The `DashboardViewModel` gathers the inputs from
/// Core Data and passes them in; this keeps the rules pure and testable.
enum InsightsEngine {
/// One category's allocation snapshot for the allocation-vs-target rule.
struct CategorySlice {
let name: String
let percentageOfPortfolio: Double
/// User's target allocation percentage, if set.
let targetPercentage: Double?
/// Trailing return percentage over the analysed window, if computable.
let returnPercentage: Double?
}
/// One source's weight for the concentration-risk rule.
struct SourceSlice {
let name: String
let percentageOfPortfolio: Double
}
struct Input {
let totalValue: Decimal
let categories: [CategorySlice]
let sources: [SourceSlice]
/// Number of categories that actually hold value.
let fundedCategoryCount: Int
/// Number of sources that actually hold value.
let fundedSourceCount: Int
let yearChangePercentage: Double
let allTimeReturn: Decimal
let updateStreak: Int
let forecast: PortfolioForecast?
}
// Thresholds
private static let concentrationThreshold = 0.40 // one source > 40% of portfolio
private static let allocationDriftThreshold = 5.0 // > 5 percentage points off target
private static let milestoneNearThreshold = 0.90 // within 10% of next milestone
static func makeInsights(_ input: Input, limit: Int = 4) -> [PortfolioInsight] {
guard input.totalValue > 0 else { return [] }
var result: [PortfolioInsight] = []
result.append(contentsOf: milestoneInsight(input))
result.append(contentsOf: allocationDriftInsight(input))
result.append(contentsOf: concentrationInsight(input))
result.append(contentsOf: performerInsights(input))
result.append(contentsOf: diversificationInsight(input))
result.append(contentsOf: streakInsight(input))
result.append(contentsOf: ytdInsight(input))
result.append(contentsOf: marketGainsInsight(input))
result.append(contentsOf: forecastInsight(input))
// Prioritize: attention first, then positive, then neutral; stable within tone.
let sorted = result.enumerated().sorted { lhs, rhs in
if lhs.element.tone.priority != rhs.element.tone.priority {
return lhs.element.tone.priority > rhs.element.tone.priority
}
return lhs.offset < rhs.offset
}.map(\.element)
return Array(sorted.prefix(limit))
}
// MARK: - Rules
private static func milestoneInsight(_ input: Input) -> [PortfolioInsight] {
let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000,
2_500_000, 5_000_000, 10_000_000]
guard let next = milestones.first(where: { $0 > input.totalValue }) else { return [] }
let pct = NSDecimalNumber(decimal: input.totalValue / next).doubleValue
guard pct >= milestoneNearThreshold else { return [] }
let gap = next - input.totalValue
let gapStr = CurrencyFormatter.format(gap, style: .currency, maximumFractionDigits: 0)
let msStr = CurrencyFormatter.format(next, style: .currency, maximumFractionDigits: 0)
return [PortfolioInsight(
id: "milestone",
systemImage: "flag.checkered",
title: String(localized: "insight_milestone_title"),
value: String(format: String(localized: "insight_milestone_value"), gapStr, msStr),
accentColor: .orange,
tone: .positive
)]
}
private static func allocationDriftInsight(_ input: Input) -> [PortfolioInsight] {
// Pick the category most off its target (over or under).
let drifts = input.categories.compactMap { cat -> (CategorySlice, Double)? in
guard let target = cat.targetPercentage, target > 0 else { return nil }
return (cat, cat.percentageOfPortfolio - target)
}
guard let (cat, drift) = drifts.max(by: { abs($0.1) < abs($1.1) }),
abs(drift) >= allocationDriftThreshold,
let target = cat.targetPercentage else { return [] }
let over = drift > 0
let value = String(
format: String(localized: over ? "insight_allocation_over_value" : "insight_allocation_under_value"),
cat.name,
cat.percentageOfPortfolio,
target
)
return [PortfolioInsight(
id: "allocation_\(cat.name)",
systemImage: over ? "arrow.up.forward.circle.fill" : "arrow.down.forward.circle.fill",
title: String(localized: "insight_allocation_title"),
value: value,
accentColor: .appWarning,
detail: String(localized: "insight_allocation_detail"),
tone: .attention
)]
}
private static func concentrationInsight(_ input: Input) -> [PortfolioInsight] {
guard let top = input.sources.max(by: { $0.percentageOfPortfolio < $1.percentageOfPortfolio }),
top.percentageOfPortfolio >= concentrationThreshold * 100,
input.fundedSourceCount >= 2 else { return [] }
return [PortfolioInsight(
id: "concentration",
systemImage: "exclamationmark.triangle.fill",
title: String(localized: "insight_concentration_title"),
value: String(format: String(localized: "insight_concentration_value"), top.name, top.percentageOfPortfolio),
accentColor: .appWarning,
detail: String(localized: "insight_concentration_detail"),
tone: .attention
)]
}
private static func performerInsights(_ input: Input) -> [PortfolioInsight] {
let ranked = input.categories.compactMap { cat -> (CategorySlice, Double)? in
guard let ret = cat.returnPercentage else { return nil }
return (cat, ret)
}
guard ranked.count >= 2 else { return [] }
var out: [PortfolioInsight] = []
if let best = ranked.max(by: { $0.1 < $1.1 }), best.1 > 0 {
out.append(PortfolioInsight(
id: "best_performer",
systemImage: "trophy.fill",
title: String(localized: "insight_best_performer_title"),
value: String(format: String(localized: "insight_performer_value"), best.0.name, best.1),
accentColor: .positiveGreen,
tone: .positive
))
}
if let worst = ranked.min(by: { $0.1 < $1.1 }), worst.1 < 0 {
out.append(PortfolioInsight(
id: "worst_performer",
systemImage: "chart.line.downtrend.xyaxis",
title: String(localized: "insight_worst_performer_title"),
value: String(format: String(localized: "insight_performer_value"), worst.0.name, worst.1),
accentColor: .negativeRed,
tone: .attention
))
}
return out
}
private static func diversificationInsight(_ input: Input) -> [PortfolioInsight] {
// Encourage diversification only when the portfolio is thin.
if input.fundedSourceCount == 1 {
return [PortfolioInsight(
id: "diversification",
systemImage: "square.grid.2x2",
title: String(localized: "insight_diversification_title"),
value: String(localized: "insight_diversification_single_value"),
accentColor: .appPrimary,
tone: .neutral
)]
}
if input.fundedCategoryCount >= 4 {
return [PortfolioInsight(
id: "diversification",
systemImage: "square.grid.2x2.fill",
title: String(localized: "insight_diversification_title"),
value: String(format: String(localized: "insight_diversification_good_value"), input.fundedCategoryCount),
accentColor: .positiveGreen,
tone: .positive
)]
}
return []
}
private static func streakInsight(_ input: Input) -> [PortfolioInsight] {
guard input.updateStreak >= 3 else { return [] }
return [PortfolioInsight(
id: "streak",
systemImage: "flame.fill",
title: String(localized: "insight_streak_title"),
value: String(format: String(localized: "insight_streak_value"), input.updateStreak),
accentColor: .orange,
tone: .positive
)]
}
private static func ytdInsight(_ input: Input) -> [PortfolioInsight] {
let ytd = input.yearChangePercentage
guard abs(ytd) >= 0.1 else { return [] }
let icon = ytd >= 0 ? "arrow.up.right.circle.fill" : "arrow.down.right.circle.fill"
return [PortfolioInsight(
id: "ytd",
systemImage: icon,
title: String(localized: "insight_ytd_title"),
value: String(format: "%+.1f%%", ytd),
accentColor: ytd >= 0 ? .positiveGreen : .negativeRed,
tone: ytd >= 0 ? .positive : .neutral
)]
}
private static func marketGainsInsight(_ input: Input) -> [PortfolioInsight] {
guard input.allTimeReturn > 0 else { return [] }
let gainStr = CurrencyFormatter.format(input.allTimeReturn, style: .currency, maximumFractionDigits: 0)
return [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,
tone: .positive
)]
}
private static func forecastInsight(_ input: Input) -> [PortfolioInsight] {
guard let forecast = input.forecast, forecast.forecastValue > input.totalValue else { return [] }
return [PortfolioInsight(
id: "forecast",
systemImage: "wand.and.stars",
title: String(localized: "insight_forecast_title"),
value: "\(forecast.formattedForecastValue) · \(forecast.formattedForecastDate)",
accentColor: .purple,
tone: .neutral
)]
}
}
@@ -1,5 +1,6 @@
import Foundation
import Combine
import CoreData
import UserNotifications
import UIKit
@@ -31,6 +32,9 @@ class NotificationService: ObservableObject {
await MainActor.run {
self.isAuthorized = granted
}
if granted {
scheduleMonthlyPerformanceSummary()
}
return granted
} catch {
print("Notification authorization error: \(error)")
@@ -155,6 +159,18 @@ extension NotificationService {
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
// Handle batch update deep link from monthly check-in
if let action = userInfo["action"] as? String, action == "batchUpdate" {
NotificationCenter.default.post(name: .openBatchUpdate, object: nil)
return
}
// Handle openDashboard deep link from monthly summary notification
if let action = userInfo["action"] as? String, action == "openDashboard" {
NotificationCenter.default.post(name: .openDashboard, object: nil)
return
}
guard let sourceIdString = userInfo["sourceId"] as? String,
let sourceId = UUID(uuidString: sourceIdString) else {
return
@@ -174,6 +190,409 @@ extension NotificationService {
extension Notification.Name {
static let openSourceDetail = Notification.Name("openSourceDetail")
static let didResetData = Notification.Name("didResetData")
static let openBatchUpdate = Notification.Name("openBatchUpdate")
static let openDashboard = Notification.Name("openDashboard")
static let openQuickUpdate = Notification.Name("openQuickUpdate")
static let openAddFirstSource = Notification.Name("openAddFirstSource")
}
// MARK: - Re-engagement Notifications
extension NotificationService {
/// Schedules a re-engagement notification 7 days from now.
/// Call this every time the app becomes active to reset the timer.
///
/// Instead of a fixed generic message, the body is built from the user's real
/// KPIs (goal ETA, YoY, monthly gain, milestone proximity, update streak) to
/// spark curiosity. Among the hooks that currently apply we rotate one per call
/// so the copy stays fresh instead of repeating the same line every week.
func scheduleReEngagementNotification() {
guard isAuthorized else { return }
center.removePendingNotificationRequests(withIdentifiers: ["re_engagement"])
let content = UNMutableNotificationContent()
let hooks = buildReEngagementHooks()
if let hook = hooks.isEmpty ? nil : hooks[nextReEngagementRotationIndex(count: hooks.count)] {
content.title = hook.title
content.body = hook.body
} else {
// No data yet (brand-new / empty portfolio) generic fallback.
content.title = String(localized: "reengagement_title")
content.body = String(localized: "reengagement_body")
}
content.sound = .default
content.userInfo = ["action": "openDashboard"]
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 7 * 24 * 3600, repeats: false)
let request = UNNotificationRequest(identifier: "re_engagement", content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Re-engagement notification error: \(error)")
}
}
}
/// Advances and returns the rotation index over the eligible hooks so a different
/// one surfaces each time we (re)schedule.
private func nextReEngagementRotationIndex(count: Int) -> Int {
guard count > 0 else { return 0 }
let key = "reengagementRotationIndex"
let stored = UserDefaults.standard.integer(forKey: key)
UserDefaults.standard.set(stored &+ 1, forKey: key)
return ((stored % count) + count) % count
}
/// 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.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")
content.body = String(localized: "monthly_checkin_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Monthly check-in notification error: \(error)")
}
}
return
}
}
/// Streak protection: reminds the user on the 25th of the CURRENT month if this
/// month's check-in is still pending loss aversion beats the day-1 nudge alone.
/// Safe to call on every app activation (no-op if already done or date passed).
func scheduleStreakProtectionReminder() {
guard isAuthorized else { return }
let identifier = "streak_protection"
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 }
// Already checked in this month nothing to protect
guard MonthlyCheckInStore.completionDate(for: thisMonthStart.adding(days: 1)) == nil else { return }
var components = calendar.dateComponents([.year, .month], from: thisMonthStart)
components.day = 25
components.hour = 18
components.minute = 0
guard let fireDate = calendar.date(from: components), fireDate > now else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "streak_protection_notification_title")
content.body = String(localized: "streak_protection_notification_body")
content.sound = .default
content.userInfo = ["action": "batchUpdate"]
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Streak protection notification error: \(error)")
}
}
}
/// Schedules a monthly portfolio summary notification on the 5th of each month at 9am.
func scheduleMonthlyPerformanceSummary() {
guard isAuthorized else { return }
let identifier = "monthly_summary"
center.getPendingNotificationRequests { [weak self] requests in
guard let self, !requests.contains(where: { $0.identifier == identifier }) else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "monthly_summary_notification_title")
content.body = String(localized: "monthly_summary_notification_body")
content.sound = .default
content.userInfo = ["action": "openDashboard"]
var components = DateComponents()
components.day = 5
components.hour = 9
components.minute = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
self.center.add(request) { error in
if let error = error {
print("Monthly summary notification error: \(error)")
}
}
}
}
/// Fires a one-time local notification celebrating goal achievement.
func scheduleGoalAchievedNotification(goalName: String) {
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "goal_achieved_notification_title")
content.body = String(format: String(localized: "goal_achieved_notification_body"), goalName)
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let id = "goal_achieved_\(UUID().uuidString)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request) { error in
if let error = error {
print("Goal achieved notification error: \(error)")
}
}
}
/// 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: - Re-engagement KPI copy
private extension NotificationService {
struct ReEngagementHook {
let title: String
let body: String
}
static let milestones: [Decimal] = [1000, 2500, 5000, 10000, 25000, 50000,
100000, 250000, 500000, 1_000_000]
static let monthYearFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f
}()
static let percentFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.minimumFractionDigits = 0
f.maximumFractionDigits = 1
return f
}()
/// Builds the list of KPI-driven hooks that currently apply. A neutral
/// curiosity hook is always appended (when the user has data) so the message
/// never has to fall back to the bland generic copy and never states a loss.
func buildReEngagementHooks() -> [ReEngagementHook] {
let ctx = CoreDataStack.shared.viewContext
let sourceReq: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
sourceReq.predicate = NSPredicate(format: "isActive == YES")
let sources = (try? ctx.fetch(sourceReq)) ?? []
guard !sources.isEmpty else { return [] }
let summary = CalculationService.shared.calculatePortfolioSummary(from: sources, snapshots: [])
let evolution = monthlyEvolution(from: sources)
var hooks: [ReEngagementHook] = []
// 1. Goal ETA "at this pace you'll reach '<goal>' by <month year>"
if let goalHook = goalEtaHook(ctx: ctx, sources: sources) {
hooks.append(goalHook)
}
// 2. Year-over-year growth
if summary.yearChangePercentage >= 5,
let pct = Self.percentFormatter.string(from: NSNumber(value: summary.yearChangePercentage)) {
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_yoy_title"),
body: String(format: String(localized: "reengagement_yoy_body"), "\(pct)%")))
}
// 3. Positive monthly change
if summary.monthChange > 0 {
let amount = CurrencyFormatter.format(summary.monthChange, style: .currency, maximumFractionDigits: 0)
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_month_title"),
body: String(format: String(localized: "reengagement_month_body"), amount)))
}
// 4. Close to the next round milestone (within 10% below it)
if let msHook = milestoneProximityHook(totalValue: summary.totalValue) {
hooks.append(msHook)
}
// 5. Update streak of 3+ consecutive months
if let streak = monthlyStreak(evolution: evolution), streak >= 3 {
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_streak_title"),
body: String(format: String(localized: "reengagement_streak_body"), streak)))
}
// 6. Neutral curiosity always available, never states a loss.
hooks.append(ReEngagementHook(
title: String(localized: "reengagement_neutral_title"),
body: String(localized: "reengagement_neutral_body")))
return hooks
}
/// Portfolio total at the end of each month that has any snapshot, ascending.
func monthlyEvolution(from sources: [InvestmentSource]) -> [(date: Date, value: Decimal)] {
let cal = Calendar.current
var monthStarts = Set<Date>()
for source in sources {
for snap in source.sortedSnapshotsByDateAscending {
if let m = cal.date(from: cal.dateComponents([.year, .month], from: snap.date)) {
monthStarts.insert(m)
}
}
}
return monthStarts.sorted().map { monthStart in
let monthEnd = cal.date(byAdding: DateComponents(month: 1, day: -1), to: monthStart) ?? monthStart
var total = Decimal.zero
for source in sources {
if let snap = source.sortedSnapshotsByDateAscending.last(where: { $0.date <= monthEnd }) {
total += snap.decimalValue
}
}
return (monthStart, total)
}
}
/// Projects the first active goal reachable on the current 6-month trend and
/// returns a hook naming its ETA. Mirrors GoalsViewModel.estimateCompletionDate.
func goalEtaHook(ctx: NSManagedObjectContext, sources: [InvestmentSource]) -> ReEngagementHook? {
let req: NSFetchRequest<Goal> = Goal.fetchRequest()
req.predicate = NSPredicate(format: "isActive == YES")
let goals = (try? ctx.fetch(req)) ?? []
guard !goals.isEmpty else { return nil }
let cal = Calendar.current
for goal in goals {
let goalSources: [InvestmentSource]
if let accId = goal.account?.id {
goalSources = sources.filter { $0.account?.id == accId }
} else {
goalSources = sources
}
let evolution = monthlyEvolution(from: goalSources)
guard evolution.count >= 3,
let first = evolution.suffix(6).first,
let last = evolution.suffix(6).last else { continue }
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else { continue }
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else { continue }
let remaining = goal.targetDecimal - last.value
guard remaining > 0 else { continue } // already reached skip
let monthsToGo = Int(ceil(NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue))
guard monthsToGo > 0, monthsToGo <= 600,
let eta = cal.date(byAdding: .month, value: monthsToGo, to: last.date) else { continue }
let etaStr = Self.monthYearFormatter.string(from: eta)
return ReEngagementHook(
title: String(localized: "reengagement_goal_title"),
body: String(format: String(localized: "reengagement_goal_body"), goal.name, etaStr))
}
return nil
}
/// Hook when the portfolio is within 10% below the next round milestone.
func milestoneProximityHook(totalValue: Decimal) -> ReEngagementHook? {
let current = NSDecimalNumber(decimal: totalValue).doubleValue
guard current > 0 else { return nil }
for milestone in Self.milestones {
let target = NSDecimalNumber(decimal: milestone).doubleValue
if current < target {
guard current >= target * 0.9 else { return nil } // next one up is too far
let msStr = CurrencyFormatter.format(milestone, style: .currency, maximumFractionDigits: 0)
return ReEngagementHook(
title: String(localized: "reengagement_milestone_title"),
body: String(format: String(localized: "reengagement_milestone_body"), msStr))
}
}
return nil
}
/// Number of consecutive months (ending at the most recent one with data) that
/// have a snapshot. `evolution` already holds one entry per month.
func monthlyStreak(evolution: [(date: Date, value: Decimal)]) -> Int? {
guard !evolution.isEmpty else { return nil }
let cal = Calendar.current
let months = Set(evolution.map { $0.date })
guard var cursor = months.max() else { return nil }
var count = 0
while months.contains(cursor) {
count += 1
guard let prev = cal.date(byAdding: .month, value: -1, to: cursor) else { break }
cursor = prev
}
return count
}
}
// MARK: - Background Refresh
+156 -337
View File
@@ -3,370 +3,195 @@ 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
var trend = 0.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)
let window = Double(min(3, values.count))
let recentAvg: Double = values.suffix(3).reduce(0, +) / window
let earlyAvg: Double = values.prefix(3).reduce(0, +) / window
trend = (recentAvg - earlyAvg) / Double(values.count)
}
// Calculate standard deviation for confidence interval
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 +199,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,104 @@
import StoreKit
import UIKit
final class ReviewPromptService {
static let shared = ReviewPromptService()
private let lastPromptKey = "reviewPromptLastDate"
private let checkInCountKey = "reviewPromptCheckinCount"
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
// Rating velocity: with a monthly-cadence app, 3 check-ins + 90 days meant 3+ months
// before the first review could even be requested. 2 check-ins + 30 days keeps the
// prompt tied to a good moment (a completed check-in) while building ratings sooner.
private let minCheckInsBetweenPrompts = 2
private let minDaysBetweenPrompts = 30
private let userDefaults: UserDefaults
private let dateProvider: () -> Date
private let reviewRequestHandler: () -> Void
private init() {
userDefaults = .standard
dateProvider = Date.init
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
}
init(
userDefaults: UserDefaults,
dateProvider: @escaping () -> Date,
reviewRequestHandler: @escaping () -> Void
) {
self.userDefaults = userDefaults
self.dateProvider = dateProvider
self.reviewRequestHandler = reviewRequestHandler
}
func recordMonthlyCheckInCompleted() {
let currentCount = userDefaults.integer(forKey: checkInCountKey)
userDefaults.set(currentCount + 1, forKey: checkInCountKey)
requestReviewIfEligible()
}
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> Bool {
guard !newlyUnlockedAchievementKeys.isEmpty else { return false }
guard !hasCompletedStoreReview else { return false }
let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? [])
let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys)
guard !unpromptedKeys.isEmpty else { return false }
let mergedKeys = promptedKeys.union(unpromptedKeys).sorted()
userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey)
return true
}
var hasCompletedStoreReview: Bool {
userDefaults.bool(forKey: hasCompletedStoreReviewKey)
}
func markStoreReviewCompleted() {
userDefaults.set(true, forKey: hasCompletedStoreReviewKey)
}
static func appStoreWriteReviewURL() -> URL {
guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else {
return GoalShareService.appStoreURL
}
var queryItems = components.queryItems ?? []
queryItems.removeAll { $0.name == "action" }
queryItems.append(URLQueryItem(name: "action", value: "write-review"))
components.queryItems = queryItems
return components.url ?? GoalShareService.appStoreURL
}
private func requestReviewIfEligible() {
guard !hasCompletedStoreReview else { return }
let now = dateProvider()
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
if daysSince < Double(minDaysBetweenPrompts) {
return
}
}
let count = userDefaults.integer(forKey: checkInCountKey)
guard count >= minCheckInsBetweenPrompts else { return }
requestReview()
}
private func requestReview() {
reviewRequestHandler()
userDefaults.set(dateProvider(), forKey: lastPromptKey)
userDefaults.set(0, forKey: checkInCountKey)
}
private static func defaultReviewRequestHandler() {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
if #available(iOS 18.0, *) {
AppStore.requestReview(in: scene)
} else {
SKStoreReviewController.requestReview(in: scene)
}
}
}
@@ -19,7 +19,6 @@ class SampleDataService {
let snapshotRepository = SnapshotRepository(context: context)
let goalRepository = GoalRepository(context: context)
let transactionRepository = TransactionRepository(context: context)
let categories = fetchCategories(in: context)
guard let fallbackCategory = categories.first else { return }
@@ -70,16 +69,6 @@ class SampleDataService {
seedMonthlyNotes()
transactionRepository.createTransaction(
source: stocks,
type: .buy,
date: Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date(),
shares: 10,
price: 400,
amount: nil,
notes: "Sample buy"
)
_ = goalRepository.createGoal(
name: "1M Goal",
targetAmount: 1_000_000,
@@ -1,11 +1,278 @@
import Foundation
import UIKit
import SwiftUI
import LinkPresentation
class ShareService {
static let shared = ShareService()
/// Attributed App Store link for shared cards (ct= shows up in App
/// Analytics as a campaign, so referral installs become measurable).
static let snapshotShareURL = URL(string: "https://apps.apple.com/app/portfolio-journal-tracker/id6757678318?ct=snapshot_share")!
private init() {}
/// Extracts the "(±X.XX%)" part of a combined "±1.234 (±X.XX%)" string.
static func percentPart(_ s: String?) -> String? {
guard let s else { return nil }
guard let open = s.lastIndex(of: "("), let close = s.lastIndex(of: ")"), open < close else {
return s
}
return String(s[s.index(after: open)..<close])
}
struct PortfolioCardStrings {
/// The big number (an all-time/return %) the hero of the card.
let heroPercent: String?
/// Supporting absolute value (empty in percent-only mode).
let totalValue: String
let changeText: String
let yearChange: String?
let sinceInceptionChange: String?
}
/// The all-time return is always the hero (branding + shareable). With
/// `percentOnly` every absolute amount is dropped nobody posts their net
/// worth in euros; everybody posts +42%.
static func portfolioCardStrings(
totalValue: String,
changeText: String,
yearChange: String?,
sinceInceptionChange: String?,
percentOnly: Bool
) -> PortfolioCardStrings {
let hero = percentPart(sinceInceptionChange) ?? percentPart(changeText)
if percentOnly {
return PortfolioCardStrings(
heroPercent: hero ?? totalValue,
totalValue: "",
changeText: percentPart(changeText) ?? changeText,
yearChange: percentPart(yearChange),
sinceInceptionChange: percentPart(sinceInceptionChange)
)
}
return PortfolioCardStrings(
heroPercent: hero,
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange
)
}
static func buildMonthlyCheckInShareText(summary: MonthlySummary, appName: String) -> String {
"""
\(summary.periodLabel) Check-in
Starting: \(summary.formattedStartingValue)
Ending: \(summary.formattedEndingValue)
Contributions: \(summary.formattedContributions)
Net performance: \(summary.formattedNetPerformance) (\(summary.formattedNetPerformancePercentage))
Shared from \(appName)
"""
}
static func buildPortfolioValueShareText(
totalValue: String,
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?,
appName: String
) -> String {
var lines = [
"Total Portfolio Value",
totalValue,
"\(changeText) \(changeLabel)"
]
if let yearChange {
lines.append("YoY: \(yearChange)")
}
if let sinceInceptionChange {
lines.append("Since inception: \(sinceInceptionChange)")
}
lines.append("")
lines.append("Shared from \(appName)")
return lines.joined(separator: "\n")
}
@MainActor
func shareMonthlyCheckIn(summary: MonthlySummary, appName: String) {
FirebaseService.shared.logShare(type: "monthly_checkin")
let text = Self.buildMonthlyCheckInShareText(summary: summary, appName: appName)
shareCard(
cardTitle: "\(summary.periodLabel) Check-in",
fallbackText: text
) {
MonthlyCheckInShareCardView(
summary: summary,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
)
}
}
struct MonthlySummaryShareData {
let monthLabel: String
let totalValue: String
let changeAmount: String
let changePercentage: String
let isPositive: Bool
let streak: Int
let mood: MonthlyCheckInMood?
let rating: Int?
}
@MainActor
func shareMonthlySummary(_ data: MonthlySummaryShareData) {
FirebaseService.shared.logShare(type: "monthly_summary")
let appName = Self.appDisplayName
var lines = [
"\(data.monthLabel) — Monthly Summary",
"Portfolio Value: \(data.totalValue)",
"\(data.changeAmount) (\(data.changePercentage)) since last check-in"
]
if data.streak >= 2 {
lines.append("Streak: \(data.streak) months")
}
if let mood = data.mood {
lines.append("Mood: \(mood.title)")
}
lines.append("")
lines.append("Tracked with \(appName)")
let fallbackText = lines.joined(separator: "\n")
shareCard(
cardTitle: "\(data.monthLabel) — Monthly Summary",
fallbackText: fallbackText
) {
MonthlySummaryShareView(
monthLabel: data.monthLabel,
totalValue: data.totalValue,
changeAmount: data.changeAmount,
changePercentage: data.changePercentage,
isPositive: data.isPositive,
streak: data.streak,
mood: data.mood,
rating: data.rating,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: GoalShareService.appStoreURL, size: 200)
)
}
}
@MainActor
func sharePortfolioValue(
totalValue: String,
changeText: String,
changeLabel: String,
yearChange: String?,
sinceInceptionChange: String?,
sparkline: [Double] = [],
isPositive: Bool = true,
asOf: String? = nil,
theme: ShareCardTheme = .dark,
percentOnly: Bool = false,
storyFormat: Bool = false
) {
FirebaseService.shared.logShare(type: percentOnly ? "portfolio_value_percent" : "portfolio_value")
let appName = Self.appDisplayName
let display = Self.portfolioCardStrings(
totalValue: totalValue,
changeText: changeText,
yearChange: yearChange,
sinceInceptionChange: sinceInceptionChange,
percentOnly: percentOnly
)
// Fallback text honors the privacy choice too.
let text = Self.buildPortfolioValueShareText(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName
)
shareCard(
cardTitle: "Portfolio Snapshot",
fallbackText: text,
shareURL: Self.snapshotShareURL
) {
PortfolioValueShareCardView(
totalValue: display.totalValue,
changeText: display.changeText,
changeLabel: changeLabel,
yearChange: display.yearChange,
sinceInceptionChange: display.sinceInceptionChange,
appName: appName,
qrCodeImage: GoalShareService.generateQRCode(for: Self.snapshotShareURL, size: 200),
sparkline: sparkline,
isPositive: isPositive,
heroPercent: display.heroPercent,
asOf: asOf,
theme: theme,
storyFormat: storyFormat
)
}
}
func shareText(_ content: String) {
guard let viewController = ShareService.topViewController() else { return }
let activityVC = UIActivityViewController(
activityItems: [content],
applicationActivities: nil
)
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
}
DispatchQueue.main.async {
viewController.present(activityVC, animated: true)
}
}
@MainActor
private func shareCard<Content: View>(
cardTitle: String,
fallbackText: String,
shareURL: URL = GoalShareService.appStoreURL,
@ViewBuilder card: () -> Content
) {
guard let viewController = ShareService.topViewController() else { return }
if #available(iOS 16.0, *) {
let renderer = ImageRenderer(content: card())
let scale = viewController.view.window?.windowScene?.screen.scale
?? viewController.traitCollection.displayScale
renderer.scale = scale
if let image = renderer.uiImage {
let item = CardShareItem(
image: image,
title: cardTitle,
text: fallbackText,
url: shareURL
)
presentShareSheet(items: [item], from: viewController)
return
}
}
presentShareSheet(items: [fallbackText], from: viewController)
}
func shareTextFile(content: String, fileName: String) {
guard let viewController = ShareService.topViewController() else { return }
@@ -109,4 +376,67 @@ class ShareService {
.replacingOccurrences(of: ";", with: "\\;")
.replacingOccurrences(of: ",", with: "\\,")
}
private func presentShareSheet(items: [Any], from viewController: UIViewController) {
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
viewController.present(activityVC, animated: true)
}
private static var appDisplayName: String {
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return name
}
if let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String {
return name
}
return "Portfolio Journal"
}
}
private class CardShareItem: NSObject, UIActivityItemSource {
let image: UIImage
let title: String
let text: String
let url: URL
init(image: UIImage, title: String, text: String, url: URL) {
self.image = image
self.title = title
self.text = text
self.url = url
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
image
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
image
}
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
title
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.title = title
metadata.originalURL = url
metadata.url = url
metadata.imageProvider = NSItemProvider(object: image)
if let appIcon = UIImage(named: "BrandMark") {
metadata.iconProvider = NSItemProvider(object: appIcon)
}
return metadata
}
}
@@ -1,41 +1,65 @@
import Foundation
import CoreData
/// Per-category allocation target (percentage).
///
/// Backed by the `Category.allocationTarget` Core Data attribute so targets sync across
/// devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is device-local
/// and did NOT sync `migrateIfNeeded(context:)` moves any legacy values over.
enum AllocationTargetStore {
private static let targetsKey = "allocationTargets"
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
private static let legacyKey = "allocationTargets"
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
static func target(for categoryId: UUID) -> Double? {
loadTargets()[categoryId.uuidString]
guard let category = fetchCategory(categoryId),
let target = category.allocationTarget?.doubleValue,
target > 0 else { return nil }
return target
}
static func setTarget(_ value: Double?, for categoryId: UUID) {
var targets = loadTargets()
let key = categoryId.uuidString
guard let category = fetchCategory(categoryId) else { return }
if let value, value > 0 {
targets[key] = value
category.allocationTarget = NSNumber(value: value)
} else {
targets.removeValue(forKey: key)
category.allocationTarget = nil
}
saveTargets(targets)
try? viewContext.save()
}
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
let targets = loadTargets()
return categoryIds.reduce(0) { total, id in
total + (targets[id.uuidString] ?? 0)
categoryIds.reduce(0) { total, id in
total + (target(for: id) ?? 0)
}
}
private static func loadTargets() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: targetsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
return decoded
private static func fetchCategory(_ id: UUID) -> Category? {
let request = Category.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? viewContext.fetch(request).first
}
private static func saveTargets(_ targets: [String: Double]) {
if let data = try? JSONEncoder().encode(targets) {
UserDefaults.standard.set(data, forKey: targetsKey)
// MARK: - Migration
/// One-time migration of legacy UserDefaults targets into Core Data so they sync via
/// iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
static func migrateIfNeeded(context: NSManagedObjectContext) {
guard let data = UserDefaults.standard.data(forKey: legacyKey),
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
!dict.isEmpty else { return }
let categories = (try? context.fetch(Category.fetchRequest())) ?? []
var changed = false
for category in categories where category.allocationTarget == nil {
if let value = dict[category.id.uuidString], value > 0 {
category.allocationTarget = NSNumber(value: value)
changed = true
}
}
if changed { try? context.save() }
UserDefaults.standard.removeObject(forKey: legacyKey)
}
}
@@ -0,0 +1,66 @@
import SwiftUI
import Combine
/// Central store for the "Hide balances" privacy feature.
///
/// When `balancesHidden` is true, every monetary amount in the app renders
/// redacted (a blurred bullet mask) instead of the real value. This lets the
/// user open the app in public or take screenshots without exposing net worth.
///
/// If `requireBiometricToReveal` is enabled, un-hiding balances must first pass
/// Face ID / biometric auth (reusing `AppLockService`). On failure, balances
/// stay hidden.
///
/// State is persisted in `UserDefaults` so it survives relaunches, and mirrored
/// into `@Published` properties so SwiftUI views observing this object update
/// instantly when the user taps the quick toggle.
@MainActor
final class BalancePrivacyManager: ObservableObject {
static let shared = BalancePrivacyManager()
private enum Keys {
static let balancesHidden = "balancesHidden"
static let requireBiometricToReveal = "requireBiometricToReveal"
}
/// When true, all monetary amounts render redacted.
@Published var balancesHidden: Bool {
didSet { UserDefaults.standard.set(balancesHidden, forKey: Keys.balancesHidden) }
}
/// When true, revealing hidden balances requires biometric auth.
@Published var requireBiometricToReveal: Bool {
didSet { UserDefaults.standard.set(requireBiometricToReveal, forKey: Keys.requireBiometricToReveal) }
}
init() {
self.balancesHidden = UserDefaults.standard.bool(forKey: Keys.balancesHidden)
self.requireBiometricToReveal = UserDefaults.standard.bool(forKey: Keys.requireBiometricToReveal)
}
/// Toggle balance visibility. Hiding is always immediate. Revealing, when
/// `requireBiometricToReveal` is on and biometrics are available, runs a
/// Face ID prompt first and only reveals on success.
func toggleHidden() {
if balancesHidden {
reveal()
} else {
balancesHidden = true
}
}
/// Attempt to reveal balances, honoring the biometric requirement.
func reveal() {
guard balancesHidden else { return }
if requireBiometricToReveal && AppLockService.canUseBiometrics() {
AppLockService.authenticate(reason: "Reveal your balances") { [weak self] success in
if success {
self?.balancesHidden = false
}
// On failure, stay hidden do nothing.
}
} else {
balancesHidden = false
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Fills gaps between snapshots for chart display only it never creates or
/// mutates Core Data objects. A source that has a snapshot in February and the
/// next in June leaves March/April/May empty; without filling, per-source and
/// category lines dip or break. This computes a value for every month in the
/// grid so the graphs read continuously.
///
/// - Internal gaps (between two known snapshots) are linearly interpolated when
/// `interpolate` is true, or carried forward (stepped) when false.
/// - After a source's last snapshot the value is carried forward (there is no
/// future point to interpolate towards) matches the portfolio total.
/// - Before a source's first snapshot the value is `nil` (the source did not
/// exist yet, so it must not contribute 0 and drag the total down).
enum ChartGapFill {
/// The user-facing toggle (Settings). Defaults to ON so charts look right out
/// of the box; reading via `object(forKey:)` so an unset key means enabled.
static let defaultsKey = "smoothChartGaps"
static var isEnabled: Bool {
(UserDefaults.standard.object(forKey: defaultsKey) as? Bool) ?? true
}
/// For a single source: `known` maps a month index (position in the ordered
/// month grid) to that month's value. Returns a value per month index, with
/// internal gaps filled and a trailing carry-forward. `nil` means "absent".
static func denseValues(
known: [Int: Decimal],
monthCount: Int,
interpolate: Bool
) -> [Decimal?] {
var result = [Decimal?](repeating: nil, count: monthCount)
let indices = known.keys.sorted()
guard let first = indices.first, let last = indices.last else { return result }
for i in indices { result[i] = known[i] }
if interpolate {
// Linear interpolation across each internal gap.
for k in 0..<(indices.count - 1) {
let a = indices[k]
let b = indices[k + 1]
guard b - a > 1, let va = known[a], let vb = known[b] else { continue }
for i in (a + 1)..<b {
let frac = Decimal(i - a) / Decimal(b - a)
result[i] = va + (vb - va) * frac
}
}
} else {
// Stepped carry-forward within internal gaps.
var lastVal = known[first] ?? .zero
for i in first...last {
if let v = result[i] { lastVal = v } else { result[i] = lastVal }
}
}
// Carry the last known value forward past the final snapshot.
if let lastVal = known[last], last + 1 < monthCount {
for i in (last + 1)..<monthCount { result[i] = lastVal }
}
return result
}
}
@@ -6,6 +6,16 @@ enum CurrencyFormatter {
return AppSettings.getOrCreate(in: context).currency
}
static func locale(for currencyCode: String?) -> Locale {
guard let currencyCode, !currencyCode.isEmpty else { return Locale.current }
if let match = Locale.availableIdentifiers.first(where: {
Locale(identifier: $0).currency?.identifier == currencyCode
}) {
return Locale(identifier: match)
}
return Locale.current
}
static func format(_ decimal: Decimal, style: NumberFormatter.Style = .currency, maximumFractionDigits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
@@ -14,10 +24,116 @@ enum CurrencyFormatter {
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func format(
_ decimal: Decimal,
currencyCode: String?,
style: NumberFormatter.Style = .currency,
maximumFractionDigits: Int = 2,
preferredLocale: Locale? = nil
) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = style
formatter.currencyCode = currencyCode ?? currentCurrencyCode()
formatter.maximumFractionDigits = maximumFractionDigits
formatter.locale = preferredLocale ?? locale(for: currencyCode)
return formatter.string(from: decimal as NSDecimalNumber) ?? "\(decimal)"
}
static func symbol(for code: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = code
return formatter.currencySymbol ?? code
}
/// Parses a user-typed numeric string accepting both `.` and `,` as decimal/grouping separators.
/// The parser is intentionally permissive to avoid turning decimal input into huge integers.
static func parseUserInput(_ string: String, currencySymbol: String = "") -> Decimal? {
let stripped = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: "\u{00A0}", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { $0.isNumber || $0 == "." || $0 == "," || $0 == "-" }
guard !stripped.isEmpty else { return nil }
let isNegative = stripped.hasPrefix("-")
let unsigned = stripped.replacingOccurrences(of: "-", with: "")
guard !unsigned.isEmpty else { return nil }
let normalizedUnsigned = normalizeNumericInput(unsigned)
let normalized = isNegative ? "-\(normalizedUnsigned)" : normalizedUnsigned
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.number(from: normalized)?.decimalValue
}
private static func normalizeNumericInput(_ input: String) -> String {
let hasDot = input.contains(".")
let hasComma = input.contains(",")
if hasDot && hasComma {
// Both separators present: the last one is considered decimal.
let lastDot = input.lastIndex(of: ".")!
let lastComma = input.lastIndex(of: ",")!
if lastComma > lastDot {
return input
.replacingOccurrences(of: ".", with: "")
.replacingOccurrences(of: ",", with: ".")
}
return input.replacingOccurrences(of: ",", with: "")
}
if hasDot {
return normalizeSingleSeparator(input, separator: ".")
}
if hasComma {
return normalizeSingleSeparator(input, separator: ",")
}
return input
}
private static func normalizeSingleSeparator(_ input: String, separator: Character) -> String {
let parts = input.split(separator: separator, omittingEmptySubsequences: false)
guard parts.count > 1 else { return input }
if parts.count == 2 {
// A single separator is treated as decimal (e.g. 533.595).
return "\(parts[0]).\(parts[1])"
}
let integerParts = Array(parts.dropLast())
let fractionPart = String(parts.last ?? "")
// If all groups follow a strict thousands pattern and the last one has 3 digits,
// prefer grouping-only interpretation (e.g. 1.234.567).
if looksLikeGroupedThousands(integerParts), fractionPart.count == 3 {
return input.replacingOccurrences(of: String(separator), with: "")
}
// Otherwise, treat the last separator as decimal and previous ones as grouping.
let integer = integerParts.joined()
return "\(integer).\(fractionPart)"
}
private static func looksLikeGroupedThousands(_ groups: [Substring]) -> Bool {
guard let first = groups.first, !first.isEmpty, first.count <= 3 else { return false }
guard groups.count >= 2 else { return false }
return groups.dropFirst().allSatisfy { $0.count == 3 }
}
/// Formats a decimal for display in an input field (no grouping separator).
static func formatForInput(_ decimal: Decimal, currencyCode: String) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = locale(for: currencyCode)
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
}
}
@@ -4,6 +4,22 @@ struct DashboardSectionConfig: Identifiable, Codable, Hashable {
let id: String
var isVisible: Bool
var isCollapsed: Bool
var columnSpan: Int
init(id: String, isVisible: Bool, isCollapsed: Bool, columnSpan: Int = 1) {
self.id = id
self.isVisible = isVisible
self.isCollapsed = isCollapsed
self.columnSpan = columnSpan
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
isVisible = try container.decode(Bool.self, forKey: .isVisible)
isCollapsed = try container.decode(Bool.self, forKey: .isCollapsed)
columnSpan = try container.decodeIfPresent(Int.self, forKey: .columnSpan) ?? 1
}
}
enum DashboardSection: String, CaseIterable, Identifiable {
@@ -16,6 +32,7 @@ enum DashboardSection: String, CaseIterable, Identifiable {
case goals
case pendingUpdates
case periodReturns
case contributionsVsReturns
var id: String { rawValue }
@@ -39,6 +56,8 @@ enum DashboardSection: String, CaseIterable, Identifiable {
return "Pending Updates"
case .periodReturns:
return "Returns"
case .contributionsVsReturns:
return "Invested vs. Returns"
}
}
}
@@ -84,12 +103,23 @@ enum DashboardLayoutStore {
UserDefaults.standard.removeObject(forKey: storageKey)
}
/// Calm 2.0 defaults: three zones state (total value), action (check-in),
/// context. Redundant cards start hidden: pending updates and streaks now
/// live inside the check-in card, period returns inside the hero. All remain
/// available via Customize.
private static let hiddenByDefault: Set<DashboardSection> = [
.momentumStreaks, .pendingUpdates, .periodReturns, .monthlySummary
]
private static func defaultConfigs() -> [DashboardSectionConfig] {
DashboardSection.allCases.map { section in
DashboardSectionConfig(
id: section.id,
isVisible: true,
isCollapsed: false
isVisible: !hiddenByDefault.contains(section),
isCollapsed: false,
// The check-in row is slim full width on the iPad grid so it
// reads as the "action" zone instead of leaving a gap cell.
columnSpan: section == .monthlyCheckIn ? 2 : 1
)
}
}
@@ -105,6 +105,28 @@ extension Color {
return Color(hex: hex) ?? .blue
}
// MARK: - Source Colors (distinct per-source palette, different order from categories)
static let sourceColors: [String] = [
"#6366F1", // Indigo
"#F97316", // Orange
"#06B6D4", // Cyan
"#EF4444", // Red
"#84CC16", // Lime
"#EC4899", // Pink
"#14B8A6", // Teal
"#F59E0B", // Amber
"#8B5CF6", // Purple
"#3B82F6", // Blue
"#A855F7", // Violet
"#10B981", // Green
]
static func sourceColor(at index: Int) -> Color {
let hex = sourceColors[index % sourceColors.count]
return Color(hex: hex) ?? .blue
}
// MARK: - Chart Colors
static let chartColors: [Color] = categoryColors.compactMap { Color(hex: $0) }
@@ -136,6 +136,18 @@ extension Date {
return formatter.localizedString(for: self, relativeTo: Date())
}
var relativeDayDescription: String {
let calendar = Calendar.current
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: self), to: calendar.startOfDay(for: Date())).day ?? 0
if days == 0 { return String(localized: "date_today") }
if days == 1 { return "1d ago" }
if days < 31 { return "\(days)d ago" }
let months = calendar.dateComponents([.month], from: self, to: Date()).month ?? 0
if months < 12 { return "\(max(1, months))mo ago" }
let years = calendar.dateComponents([.year], from: self, to: Date()).year ?? 0
return "\(max(1, years))y ago"
}
var friendlyDescription: String {
if isToday {
return String(localized: "date_today")
@@ -54,6 +54,27 @@ class FreemiumValidator: ObservableObject {
return snapshots.filter { $0.date >= cutoffDate }
}
/// Number of distinct months of data that exist BEYOND the free history window.
/// Used to make the locked value concrete ("8 more months with Premium") instead
/// of silently truncating users can't want what they can't see.
func hiddenHistoryMonths(in snapshots: [Snapshot]) -> Int {
if iapService.isPremium { return 0 }
let cutoffDate = Calendar.current.date(
byAdding: .month,
value: -FreemiumLimits.maxHistoricalMonths,
to: Date()
) ?? Date()
let calendar = Calendar.current
let hiddenMonths = Set(
snapshots
.filter { $0.date < cutoffDate }
.map { calendar.dateComponents([.year, .month], from: $0.date) }
)
return hiddenMonths.count
}
func isSnapshotAccessible(_ snapshot: Snapshot) -> Bool {
if iapService.isPremium { return true }
@@ -0,0 +1,167 @@
import Foundation
/// Pure, dependency-free projection helper for goals.
///
/// Given the current portfolio value relevant to a goal (its account's sources,
/// or the whole portfolio when the goal has no account), a monthly growth rate
/// derived from evolution history, and the total monthly contributions of the
/// relevant sources, it projects value forward month by month and estimates the
/// month the goal amount is reached plus an on-track status vs the target date.
///
/// Kept free of Core Data / SwiftUI so it is trivially unit-testable and reusable.
enum GoalProjection {
/// On-track status of a goal relative to its target date.
enum Status: Equatable {
/// Reached before the target date.
case aheadOfSchedule
/// Reached at (or within a small window of) the target date.
case onTrack
/// Reached after the target date.
case behind
/// Never reached at the current trajectory (no growth, no contributions).
case unreachable
/// No target date set only a projected date is meaningful.
case noTargetDate
}
struct Result {
/// Projected month the goal amount is reached, or nil if unreachable.
let projectedDate: Date?
/// Number of months from `asOf` until the goal is reached, or nil if unreachable.
let monthsToReach: Int?
/// On-track status vs the target date.
let status: Status
/// Monthly growth rate used (fraction, e.g. 0.008 == +0.8%/mo). Zero if flat/negative.
let monthlyGrowthRate: Double
/// A short projection curve (value per month) for a sparkline, starting at `currentValue`.
/// Empty when the goal is already reached or unreachable.
let sparkline: [Decimal]
var isReachable: Bool { projectedDate != nil }
}
/// Hard cap on how many months we project before declaring a goal unreachable
/// at the current pace (50 years).
private static let maxProjectionMonths = 600
/// Derives a robust monthly growth rate (as a fraction) from evolution history.
///
/// Uses the trailing window (default 6 points) and computes a monthly CAGR from
/// first to last value. Contributions inflate raw value growth, so this rate is a
/// blended "how fast the pot grows" figure rather than pure market return which
/// is the honest thing to extrapolate for an ETA. Returns 0 for flat/negative
/// trends or insufficient data.
static func monthlyGrowthRate(
from evolution: [(date: Date, value: Decimal)],
window: Int = 6
) -> Double {
guard evolution.count >= 3 else { return 0 }
let recent = Array(evolution.suffix(max(2, window)))
guard let first = recent.first, let last = recent.last else { return 0 }
let firstValue = NSDecimalNumber(decimal: first.value).doubleValue
let lastValue = NSDecimalNumber(decimal: last.value).doubleValue
guard firstValue > 0, lastValue > firstValue else { return 0 }
let months = max(1, first.date.monthsBetween(last.date))
// Monthly CAGR: (last/first)^(1/months) - 1
let ratio = lastValue / firstValue
let rate = pow(ratio, 1.0 / Double(months)) - 1.0
guard rate.isFinite, rate > 0 else { return 0 }
// Clamp to a sane band to avoid absurd extrapolations from noisy data.
return min(rate, 0.5)
}
/// Projects when `targetAmount` is reached.
///
/// - Parameters:
/// - currentValue: current portfolio value relevant to the goal.
/// - targetAmount: the goal amount.
/// - monthlyGrowthRate: fraction per month (from `monthlyGrowthRate(from:)`).
/// - monthlyContribution: total monthly contributions of relevant sources.
/// - targetDate: optional deadline to compare against.
/// - asOf: base date to project from (defaults to now).
static func project(
currentValue: Decimal,
targetAmount: Decimal,
monthlyGrowthRate: Double,
monthlyContribution: Decimal,
targetDate: Date?,
asOf: Date = Date()
) -> Result {
// Already reached.
if currentValue >= targetAmount, targetAmount > 0 {
return Result(
projectedDate: asOf,
monthsToReach: 0,
status: targetDate == nil ? .noTargetDate : .onTrack,
monthlyGrowthRate: max(0, monthlyGrowthRate),
sparkline: []
)
}
let rate = max(0, monthlyGrowthRate)
let contribution = NSDecimalNumber(decimal: max(0, monthlyContribution)).doubleValue
var value = NSDecimalNumber(decimal: currentValue).doubleValue
let target = NSDecimalNumber(decimal: targetAmount).doubleValue
// No trajectory at all unreachable.
if rate <= 0 && contribution <= 0 {
return Result(
projectedDate: nil,
monthsToReach: nil,
status: .unreachable,
monthlyGrowthRate: 0,
sparkline: []
)
}
var sparkline: [Decimal] = [Decimal(value)]
var months = 0
while value < target && months < maxProjectionMonths {
value = value * (1 + rate) + contribution
months += 1
// Sample the sparkline sparsely (at most ~12 points).
if months % max(1, months / 12) == 0 || value >= target {
sparkline.append(Decimal(value))
}
}
guard value >= target else {
return Result(
projectedDate: nil,
monthsToReach: nil,
status: .unreachable,
monthlyGrowthRate: rate,
sparkline: []
)
}
let projectedDate = Calendar.current.date(byAdding: .month, value: months, to: asOf)
let status: Status
if let targetDate {
let projectedDay = (projectedDate ?? asOf).startOfDay
let targetDay = targetDate.startOfDay
let deltaDays = targetDay.daysBetween(projectedDay)
if deltaDays <= -15 {
status = .aheadOfSchedule
} else if deltaDays >= 15 {
status = .behind
} else {
status = .onTrack
}
} else {
status = .noTargetDate
}
return Result(
projectedDate: projectedDate,
monthsToReach: months,
status: status,
monthlyGrowthRate: rate,
sparkline: sparkline
)
}
}
@@ -0,0 +1,95 @@
import Foundation
import UIKit
import Vision
// MARK: - On-device amount OCR (app target)
//
// Recognizes the monetary amounts in a shared/picked image entirely on-device
// (Vision), ranked by visual prominence the balance is almost always the
// biggest number on a bank screen. Mirrors the Share Extension's scanner so the
// two entry points behave identically.
struct ScannedAmount: Identifiable, Equatable {
let id = UUID()
let display: String
let value: Decimal
let prominence: CGFloat
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
}
enum ImageAmountScanner {
static let maxCandidates = 6
static func scan(_ image: UIImage, completion: @escaping ([ScannedAmount]) -> Void) {
guard let cgImage = image.cgImage else { completion([]); return }
let request = VNRecognizeTextRequest { request, _ in
let obs = (request.results as? [VNRecognizedTextObservation]) ?? []
completion(candidates(from: obs))
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
DispatchQueue.global(qos: .userInitiated).async {
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
try? handler.perform([request])
}
}
private static let numberPattern: NSRegularExpression = {
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
return try! NSRegularExpression(pattern: pattern)
}()
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ScannedAmount] {
var found: [ScannedAmount] = []
for observation in observations {
guard let recognized = observation.topCandidates(1).first else { continue }
let line = recognized.string
let range = NSRange(line.startIndex..., in: line)
for match in numberPattern.matches(in: line, range: range) {
guard let r = Range(match.range, in: line) else { continue }
let display = String(line[r])
guard !isPercentage(display, in: line, at: r),
!looksLikeYear(display),
let value = CurrencyFormatter.parseUserInput(display), value > 0 else { continue }
found.append(ScannedAmount(
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
value: value,
prominence: observation.boundingBox.height))
}
}
var byValue: [Decimal: ScannedAmount] = [:]
for c in found {
if let e = byValue[c.value], e.prominence >= c.prominence { continue }
byValue[c.value] = c
}
return byValue.values.sorted { $0.prominence > $1.prominence }.prefix(maxCandidates).map { $0 }
}
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
if after.first == "%" { return true }
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
return before.first == "%"
}
private static func looksLikeYear(_ display: String) -> Bool {
guard display.count == 4, display.allSatisfy(\.isNumber), let v = Int(display) else { return false }
return (1990...2099).contains(v)
}
private static func cgOrientation(from o: UIImage.Orientation) -> CGImagePropertyOrientation {
switch o {
case .up: return .up
case .down: return .down
case .left: return .left
case .right: return .right
case .upMirrored: return .upMirrored
case .downMirrored: return .downMirrored
case .leftMirrored: return .leftMirrored
case .rightMirrored: return .rightMirrored
@unknown default: return .up
}
}
}
@@ -1,15 +1,27 @@
import Foundation
import CoreData
// MonthlyCheckInStore primary storage is CoreData (syncs via iCloud).
// UserDefaults is kept for one-time migration from older builds.
enum MonthlyCheckInStore {
// Legacy UserDefaults keys (read-only after migration)
private static let notesKey = "monthlyCheckInNotes"
private static let completionsKey = "monthlyCheckInCompletions"
private static let legacyLastCheckInKey = "lastCheckInDate"
private static let entriesKey = "monthlyCheckInEntries"
private static let migrationDoneKey = "journalMigratedToCoreData"
static let graceDays = 20
// MARK: - CoreData Context
private static var context: NSManagedObjectContext {
CoreDataStack.shared.viewContext
}
// MARK: - Public Accessors
static func note(for date: Date) -> String {
entry(for: date)?.note ?? ""
fetchEntry(for: monthKey(for: date))?.note ?? ""
}
static func setNote(_ note: String, for date: Date) {
@@ -20,21 +32,17 @@ enum MonthlyCheckInStore {
}
static func rating(for date: Date) -> Int? {
entry(for: date)?.rating
fetchEntry(for: monthKey(for: date))?.ratingValue
}
static func setRating(_ rating: Int?, for date: Date) {
updateEntry(for: date) { entry in
if let rating, rating > 0 {
entry.rating = min(max(1, rating), 5)
} else {
entry.rating = nil
}
entry.ratingValue = rating
}
}
static func mood(for date: Date) -> MonthlyCheckInMood? {
entry(for: date)?.mood
fetchEntry(for: monthKey(for: date))?.mood
}
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
@@ -44,53 +52,113 @@ enum MonthlyCheckInStore {
}
static func monthKey(for date: Date) -> String {
Self.monthFormatter.string(from: date)
monthFormatter.string(from: effectiveMonth(for: date))
}
static func allNotes() -> [(date: Date, note: String)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
fetchAllEntries()
.compactMap { entry in
guard let key = entry.monthKey,
let date = monthFormatter.date(from: key) else { return nil }
return (date: date, note: entry.note ?? "")
}
.sorted { $0.date > $1.date }
}
static func entry(for date: Date) -> MonthlyCheckInEntry? {
loadEntries()[monthKey(for: date)]
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
loadEntries()
.compactMap { key, entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil }
return (date: date, entry: entry)
fetchAllEntries()
.compactMap { entry in
guard let key = entry.monthKey,
let date = monthFormatter.date(from: key) else { return nil }
return (date: date, entry: makeCheckInEntry(entry))
}
.sorted { $0.date > $1.date }
}
static func completionDate(for date: Date) -> Date? {
entry(for: date)?.completionDate
fetchEntry(for: monthKey(for: date))?.completionTime
}
static func setCompletionDate(_ completionDate: Date, for month: Date) {
updateEntry(for: month) { entry in
entry.completionTime = completionDate.timeIntervalSince1970
let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays)
let targetKey = monthFormatter.string(from: targetMonth)
let calendar = Calendar.current
if calendar.isDate(month, inSameDayAs: completionDate),
calendar.component(.day, from: completionDate) > graceDays {
let allExisting = fetchAllEntries()
let completedPrevious = allExisting.compactMap { entry -> (month: Date, entry: JournalEntry)? in
guard let key = entry.monthKey,
let entryMonth = monthFormatter.date(from: key)?.startOfMonth,
entry.completionTime != nil,
entryMonth < targetMonth else { return nil }
return (month: entryMonth, entry: entry)
}
if let lastCompleted = completedPrevious.max(by: { $0.month < $1.month }) {
var cursor = lastCompleted.month.adding(months: 1).startOfMonth
while cursor < targetMonth {
let key = monthFormatter.string(from: cursor)
if fetchEntry(for: key) == nil {
let fallbackDate = min(cursor.endOfMonth, completionDate)
let new = JournalEntry(context: context)
new.monthKey = key
new.note = lastCompleted.entry.note
new.ratingValue = lastCompleted.entry.ratingValue
new.mood = lastCompleted.entry.mood
new.completionTime = fallbackDate
}
cursor = cursor.adding(months: 1).startOfMonth
}
}
}
// Ensure target month entry exists with the completion date.
let targetEntry = fetchEntry(for: targetKey) ?? {
let e = JournalEntry(context: context)
e.monthKey = targetKey
return e
}()
targetEntry.completionTime = completionDate
// Backfill any previous entries that have no completion date.
for entry in fetchAllEntries() {
guard let key = entry.monthKey,
let entryMonth = monthFormatter.date(from: key)?.startOfMonth,
entryMonth < targetMonth,
entry.completionTime == nil else { continue }
entry.completionTime = min(entryMonth.endOfMonth, completionDate)
}
saveContext()
}
static func latestCompletionDate() -> Date? {
let latestEntryDate = loadEntries().values
.compactMap { $0.completionDate }
.max()
let request = JournalEntry.fetchRequest()
request.predicate = NSPredicate(format: "completionTime != nil")
request.sortDescriptors = [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)]
request.fetchLimit = 1
return (try? context.fetch(request))?.first?.completionTime
}
if let latestEntryDate {
return latestEntryDate
static func effectiveMonth(
for date: Date,
relativeTo referenceDate: Date = Date(),
graceDays: Int = 20
) -> Date {
let calendar = Calendar.current
if calendar.isDate(date, inSameDayAs: referenceDate) {
let day = calendar.component(.day, from: referenceDate)
if day <= graceDays {
return referenceDate.adding(months: -1).startOfMonth
}
}
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
guard legacy > 0 else { return nil }
return Date(timeIntervalSince1970: legacy)
return date.startOfMonth
}
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
@@ -115,7 +183,6 @@ enum MonthlyCheckInStore {
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
// Current streak counts consecutive on-time months up to the reference month.
var currentStreak = 0
var cursor = referenceDate.startOfMonth
while onTimeMonths.contains(cursor) {
@@ -123,7 +190,6 @@ enum MonthlyCheckInStore {
cursor = cursor.adding(months: -1).startOfMonth
}
// Best streak across history.
let sortedMonths = onTimeMonths.sorted()
var bestStreak = 0
var running = 0
@@ -139,9 +205,7 @@ enum MonthlyCheckInStore {
}
let averageDaysBeforeDeadline = onTimeCount > 0
? deadlineDiffs
.filter { $0 >= 0 }
.average()
? deadlineDiffs.filter { $0 >= 0 }.average()
: nil
let closestCutoffDays = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.min()
@@ -175,6 +239,10 @@ enum MonthlyCheckInStore {
}
static func clearAll() {
for entry in fetchAllEntries() {
context.delete(entry)
}
saveContext()
let defaults = UserDefaults.standard
defaults.removeObject(forKey: notesKey)
defaults.removeObject(forKey: completionsKey)
@@ -182,168 +250,129 @@ enum MonthlyCheckInStore {
defaults.removeObject(forKey: legacyLastCheckInKey)
}
// MARK: - Private Helpers
// MARK: - One-time Migration from UserDefaults
private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) {
static func migrateIfNeeded() {
guard !UserDefaults.standard.bool(forKey: migrationDoneKey) else { return }
let legacyEntries = loadLegacyEntries()
for (key, legacy) in legacyEntries {
guard fetchEntry(for: key) == nil else { continue }
let entry = JournalEntry(context: context)
entry.monthKey = key
entry.note = legacy.note
entry.ratingValue = legacy.rating
entry.mood = legacy.mood
if let t = legacy.completionTime {
entry.completionTime = Date(timeIntervalSince1970: t)
}
entry.createdAt = Date(timeIntervalSince1970: legacy.createdAt)
}
saveContext()
UserDefaults.standard.set(true, forKey: migrationDoneKey)
}
// MARK: - Private CoreData Helpers
private static func fetchEntry(for key: String) -> JournalEntry? {
let request = JournalEntry.fetchRequest()
request.predicate = NSPredicate(format: "monthKey == %@", key)
request.fetchLimit = 1
return try? context.fetch(request).first
}
private static func fetchAllEntries() -> [JournalEntry] {
let request = JournalEntry.fetchRequest()
return (try? context.fetch(request)) ?? []
}
private static func updateEntry(for date: Date, mutate: (JournalEntry) -> Void) {
let key = monthKey(for: date)
var entries = loadEntries()
var entry = entries[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: legacyCompletion(for: key),
createdAt: Date().timeIntervalSince1970
)
let entry = fetchEntry(for: key) ?? {
let e = JournalEntry(context: context)
e.monthKey = key
return e
}()
mutate(&entry)
mutate(entry)
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
entry.note = nil
}
let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil
let isEmpty = entry.note == nil
&& entry.ratingValue == nil
&& entry.mood == nil
&& entry.completionTime == nil
if isEmpty {
entries.removeValue(forKey: key)
} else {
entries[key] = entry
context.delete(entry)
}
saveEntries(entries)
persistLegacyMirrors(entries)
saveContext()
}
private static func loadEntries() -> [String: MonthlyCheckInEntry] {
guard let data = UserDefaults.standard.data(forKey: entriesKey),
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else {
return migrateLegacyData()
private static func saveContext() {
guard context.hasChanges else { return }
try? context.save()
}
private static func makeCheckInEntry(_ entry: JournalEntry) -> MonthlyCheckInEntry {
MonthlyCheckInEntry(
note: entry.note,
rating: entry.ratingValue,
mood: entry.mood,
completionTime: entry.completionTime?.timeIntervalSince1970,
createdAt: entry.createdAt?.timeIntervalSince1970 ?? Date().timeIntervalSince1970
)
}
// MARK: - Legacy UserDefaults Reader (for migration only)
private static func loadLegacyEntries() -> [String: MonthlyCheckInEntry] {
if let data = UserDefaults.standard.data(forKey: entriesKey),
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data),
!decoded.isEmpty {
return decoded
}
// Ensure legacy data is merged if it existed before this release.
return mergeLegacy(into: decoded)
return loadAndMergeLegacyKeys()
}
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
guard let data = try? JSONEncoder().encode(entries) else { return }
UserDefaults.standard.set(data, forKey: entriesKey)
}
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
let notes = loadNotes()
let completions = loadCompletions()
private static func loadAndMergeLegacyKeys() -> [String: MonthlyCheckInEntry] {
let notes = loadLegacyNotes()
let completions = loadLegacyCompletions()
guard !notes.isEmpty || !completions.isEmpty else { return [:] }
var entries: [String: MonthlyCheckInEntry] = [:]
let now = Date().timeIntervalSince1970
for (key, note) in notes {
entries[key] = MonthlyCheckInEntry(
note: note,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: now
note: note, rating: nil, mood: nil,
completionTime: completions[key], createdAt: now
)
}
for (key, completion) in completions where entries[key] == nil {
entries[key] = MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completion,
createdAt: completion
note: nil, rating: nil, mood: nil,
completionTime: completion, createdAt: completion
)
}
saveEntries(entries)
return entries
}
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] {
var merged = entries
let notes = loadNotes()
let completions = loadCompletions()
var shouldSave = false
for (key, note) in notes where merged[key]?.note == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: completions[key],
createdAt: Date().timeIntervalSince1970
)
entry.note = note
merged[key] = entry
shouldSave = true
}
for (key, completion) in completions where merged[key]?.completionTime == nil {
var entry = merged[key] ?? MonthlyCheckInEntry(
note: nil,
rating: nil,
mood: nil,
completionTime: nil,
createdAt: completion
)
entry.completionTime = completion
merged[key] = entry
shouldSave = true
}
if shouldSave {
saveEntries(merged)
}
return merged
}
private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) {
var notes: [String: String] = [:]
var completions: [String: Double] = [:]
for (key, entry) in entries {
if let note = entry.note {
notes[key] = note
}
if let completion = entry.completionTime {
completions[key] = completion
}
}
saveNotes(notes)
saveCompletions(completions)
}
private static func loadNotes() -> [String: String] {
private static func loadLegacyNotes() -> [String: String] {
guard let data = UserDefaults.standard.data(forKey: notesKey),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
}
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
return decoded
}
private static func saveNotes(_ notes: [String: String]) {
guard let data = try? JSONEncoder().encode(notes) else { return }
UserDefaults.standard.set(data, forKey: notesKey)
}
private static func loadCompletions() -> [String: Double] {
private static func loadLegacyCompletions() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: completionsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
return [:]
}
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] }
return decoded
}
private static func saveCompletions(_ completions: [String: Double]) {
guard let data = try? JSONEncoder().encode(completions) else { return }
UserDefaults.standard.set(data, forKey: completionsKey)
}
private static func legacyCompletion(for key: String) -> Double? {
loadCompletions()[key]
}
// MARK: - Private Achievement Helpers
private struct MonthlyCheckInAchievementRule {
let achievement: MonthlyCheckInAchievement
@@ -397,9 +426,7 @@ enum MonthlyCheckInStore {
icon: "hourglass"
),
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
if let closestCutoffDays {
return closestCutoffDays <= 2
}
if let closestCutoffDays { return closestCutoffDays <= 2 }
return false
}
),
@@ -445,12 +472,8 @@ enum MonthlyCheckInStore {
) -> [MonthlyCheckInAchievement] {
achievementRules.compactMap { rule in
rule.isUnlocked(
currentStreak,
bestStreak,
onTimeCount,
totalCheckIns,
closestCutoffDays,
averageDaysBeforeDeadline
currentStreak, bestStreak, onTimeCount, totalCheckIns,
closestCutoffDays, averageDaysBeforeDeadline
) ? rule.achievement : nil
}
}
@@ -0,0 +1,59 @@
import Foundation
import CoreData
/// Per-source recurring monthly contribution amount.
///
/// Backed by the `InvestmentSource.monthlyContribution` Core Data attribute so the value
/// syncs across devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is
/// device-local and did NOT sync `migrateIfNeeded(context:)` moves any legacy value over.
enum MonthlyContributionStore {
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
private static let legacyKey = "monthlyContributions"
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
static func contribution(for sourceId: UUID) -> Decimal? {
guard let source = fetchSource(sourceId),
let amount = source.monthlyContribution?.decimalValue,
amount > 0 else { return nil }
return amount
}
static func setContribution(_ amount: Decimal?, for sourceId: UUID) {
guard let source = fetchSource(sourceId) else { return }
if let amount, amount > 0 {
source.monthlyContribution = NSDecimalNumber(decimal: amount)
} else {
source.monthlyContribution = nil
}
try? viewContext.save()
}
private static func fetchSource(_ id: UUID) -> InvestmentSource? {
let request = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? viewContext.fetch(request).first
}
// MARK: - Migration
/// One-time migration of legacy UserDefaults contributions into Core Data so they sync
/// via iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
static func migrateIfNeeded(context: NSManagedObjectContext) {
guard let data = UserDefaults.standard.data(forKey: legacyKey),
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
!dict.isEmpty else { return }
let sources = (try? context.fetch(InvestmentSource.fetchRequest())) ?? []
var changed = false
for source in sources where source.monthlyContribution == nil {
if let raw = dict[source.id.uuidString], raw > 0 {
source.monthlyContribution = NSDecimalNumber(value: raw)
changed = true
}
}
if changed { try? context.save() }
UserDefaults.standard.removeObject(forKey: legacyKey)
}
}
@@ -0,0 +1,38 @@
import Foundation
import CoreData
/// Decides when to ask for an App Store rating: after a "positive moment"
/// (a saved check-in), only once the habit is established (2 distinct months
/// of snapshots) and at most once per app version. Apple additionally caps
/// the system prompt at 3 displays per year, so over-calling is safe.
///
/// Call sites own the actual prompt via SwiftUI's `\.requestReview` this
/// service only answers "should we ask now?" and records that we did.
@MainActor
enum ReviewRequestService {
private static let lastVersionKey = "reviewRequest.lastVersion"
/// Returns true at most once per app version, and only when the user has
/// snapshots in 2+ distinct months (they've seen the app deliver value).
/// Marks the version as asked when returning true.
static func shouldRequestAfterCheckIn() -> Bool {
guard distinctSnapshotMonths() >= 2 else { return false }
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let defaults = UserDefaults.standard
guard defaults.string(forKey: lastVersionKey) != version else { return false }
defaults.set(version, forKey: lastVersionKey)
return true
}
private static func distinctSnapshotMonths() -> Int {
let request = NSFetchRequest<Snapshot>(entityName: "Snapshot")
guard let snapshots = try? CoreDataStack.shared.viewContext.fetch(request) else { return 0 }
let calendar = Calendar.current
let months = Set(snapshots.map { snapshot in
calendar.dateComponents([.year, .month], from: snapshot.date)
})
return months.count
}
}
@@ -0,0 +1,142 @@
import Foundation
import CoreData
// MARK: - App Group bridge for the Quick Update share extension
//
// The share extension can't open the CloudKit-backed Core Data store, so the app
// maintains a lightweight mirror of active sources in the App Group defaults and
// the extension appends "pending updates" to a queue there. The app ingests the
// queue on every activation, creating real snapshots.
/// Snapshot of an active source, enough for the extension's picker.
struct SharedSourceInfo: Codable, Identifiable {
let id: UUID
let name: String
let latestValue: Double
let currencyCode: String
/// True when a value was already recorded this month (by app or extension).
var updatedThisMonth: Bool
}
/// A value captured from the share extension, waiting to become a Snapshot.
struct PendingQuickUpdate: Codable {
let sourceId: UUID
let amount: Double
let capturedAt: Date
}
enum SharedQuickUpdateStore {
private static let mirrorKey = "sharedSourceMirror"
private static let queueKey = "pendingQuickUpdates"
static var defaults: UserDefaults? {
UserDefaults(suiteName: AppConstants.appGroupIdentifier)
}
// MARK: Mirror (app writes, extension reads)
static func writeMirror(_ sources: [SharedSourceInfo]) {
guard let data = try? JSONEncoder().encode(sources) else { return }
defaults?.set(data, forKey: mirrorKey)
}
static func readMirror() -> [SharedSourceInfo] {
guard let data = defaults?.data(forKey: mirrorKey),
let decoded = try? JSONDecoder().decode([SharedSourceInfo].self, from: data) else { return [] }
return decoded
}
// MARK: Pending queue (extension writes, app drains)
static func appendPending(_ update: PendingQuickUpdate) {
var queue = readPending()
queue.append(update)
if let data = try? JSONEncoder().encode(queue) {
defaults?.set(data, forKey: queueKey)
}
// Optimistically mark the source as updated in the mirror so the extension
// suggests the next pending source on the following invocation.
var mirror = readMirror()
if let idx = mirror.firstIndex(where: { $0.id == update.sourceId }) {
mirror[idx].updatedThisMonth = true
writeMirror(mirror)
}
}
static func readPending() -> [PendingQuickUpdate] {
guard let data = defaults?.data(forKey: queueKey),
let decoded = try? JSONDecoder().decode([PendingQuickUpdate].self, from: data) else { return [] }
return decoded
}
static func clearPending() {
defaults?.removeObject(forKey: queueKey)
}
}
// MARK: - App-side sync & ingestion
enum SharedQuickUpdateSync {
/// Refreshes the source mirror for the extension. Call on app activation and
/// after snapshot saves.
@MainActor
static func refreshMirror() {
let context = CoreDataStack.shared.viewContext
let request = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "isActive == YES")
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
guard let sources = try? context.fetch(request) else { return }
let settings = AppSettings.getOrCreate(in: context)
let calendar = Calendar.current
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()
let mirror = sources.map { source in
SharedSourceInfo(
id: source.id,
name: source.name,
latestValue: NSDecimalNumber(decimal: source.latestValue).doubleValue,
currencyCode: source.account?.currency ?? settings.currency,
updatedThisMonth: (source.latestSnapshot?.date ?? .distantPast) >= monthStart
)
}
SharedQuickUpdateStore.writeMirror(mirror)
}
/// Drains the extension's pending queue into real snapshots. Returns how many
/// snapshots were created. Call on app activation.
@MainActor
@discardableResult
static func ingestPending() -> Int {
let pending = SharedQuickUpdateStore.readPending()
guard !pending.isEmpty else { return 0 }
let context = CoreDataStack.shared.viewContext
let request = InvestmentSource.fetchRequest()
guard let sources = try? context.fetch(request) else { return 0 }
let byId = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
var created = 0
for update in pending {
guard let source = byId[update.sourceId] else { continue }
let snapshot = Snapshot(context: context)
snapshot.id = UUID()
snapshot.value = NSDecimalNumber(value: update.amount)
snapshot.date = update.capturedAt
snapshot.source = source
created += 1
}
if context.hasChanges {
do {
try context.save()
} catch {
print("Failed to ingest pending quick updates: \(error)")
return 0
}
}
SharedQuickUpdateStore.clearPending()
refreshMirror()
return created
}
}
@@ -0,0 +1,87 @@
import Foundation
import CoreData
/// Detects INTERNAL gaps in a source's snapshot history: two consecutive
/// snapshots more than one calendar month apart, i.e. one or more calendar
/// months between them have no data at all.
///
/// This complements `ChartGapFill`, which only smooths the *display* by
/// interpolating/carrying values across those empty months. The chart looks
/// continuous, but real data is missing this surfaces that so the user can
/// fill it in. Read-only: it never mutates Core Data.
enum SnapshotGapDetector {
/// A single internal gap for one source.
struct Gap: Identifiable, Equatable {
let sourceId: UUID
let sourceName: String
/// The month of the snapshot before the gap (start of that month).
let fromMonth: Date
/// The month of the snapshot after the gap (start of that month).
let toMonth: Date
/// Number of calendar months with no data between `fromMonth` and `toMonth`.
let missingMonths: Int
/// The individual missing months (start-of-month dates), oldest first.
let missingMonthDates: [Date]
var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" }
}
/// Computes all internal gaps across the provided sources' snapshots.
/// Only months strictly between two known snapshots count a source's
/// trailing "not updated in a while" is handled elsewhere (pending updates).
static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] {
let calendar = Calendar.current
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var gaps: [Gap] = []
for source in sources {
let sourceId = source.id
guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else {
continue
}
// One value per calendar month (keep the latest in each), sorted ascending.
var monthStarts: [Date] = sourceSnapshots
.map { $0.date.startOfMonth }
monthStarts = Array(Set(monthStarts)).sorted()
guard monthStarts.count >= 2 else { continue }
for i in 0..<(monthStarts.count - 1) {
let from = monthStarts[i]
let to = monthStarts[i + 1]
let monthsApart = from.monthsBetween(to)
guard monthsApart > 1 else { continue }
var missingDates: [Date] = []
var cursor = from.adding(months: 1).startOfMonth
while cursor < to {
missingDates.append(cursor)
cursor = cursor.adding(months: 1).startOfMonth
}
guard !missingDates.isEmpty else { continue }
gaps.append(Gap(
sourceId: sourceId,
sourceName: source.name,
fromMonth: from,
toMonth: to,
missingMonths: missingDates.count,
missingMonthDates: missingDates
))
}
}
// Most recent gaps first (by the month after the gap), then by source name.
return gaps.sorted {
if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth }
return $0.sourceName < $1.sourceName
}
}
/// Total count of missing months across all gaps.
static func totalMissingMonths(_ gaps: [Gap]) -> Int {
gaps.reduce(0) { $0 + $1.missingMonths }
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
import CryptoKit
/// Deterministic UUIDs (RFC 4122 v5) for records that every device/install must
/// create identically the default Account and the default Categories.
///
/// Why: `awakeFromInsert()` assigns a random UUID, so each device seeded its own
/// "Default" account + default categories with *different* UUIDs before the first
/// CloudKit import. CloudKit keys on the UUID (record name), so those identical-in-
/// meaning records piled up as distinct records (8 accounts, 23 categories in the
/// wild). Giving the defaults a STABLE UUID derived from a fixed namespace + name
/// means every device produces the SAME record name CloudKit natively merges
/// them into one. No dedup, no deletes, no cascade the root-cause fix.
enum StableID {
/// Fixed app namespace (generated once, constant forever never change it).
static let namespace = UUID(uuidString: "F3A1C2D4-5E6F-4A7B-8C9D-0E1F2A3B4C5D")!
/// RFC 4122 v5 UUID: SHA-1(namespace bytes name) with version/variant bits set.
static func v5(_ name: String) -> UUID {
var hasher = Insecure.SHA1()
withUnsafeBytes(of: namespace.uuid) { hasher.update(bufferPointer: $0) }
hasher.update(data: Data(name.utf8))
let digest = Array(hasher.finalize()) // 20 bytes; take first 16
var b = Array(digest.prefix(16))
b[6] = (b[6] & 0x0F) | 0x50 // version 5
b[8] = (b[8] & 0x3F) | 0x80 // RFC 4122 variant
return UUID(uuid: (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]))
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import Foundation
import Combine
import CoreData
import SwiftUI
@MainActor
class DashboardViewModel: ObservableObject {
@@ -22,6 +23,14 @@ class DashboardViewModel: ObservableObject {
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var updateStreak: Int = 0
// MARK: - Data Gaps
/// Internal gaps in per-source snapshot history (missing months between two
/// known snapshots). The chart smooths these over; this exposes them so the
/// user knows real data is missing and can fill it. See SnapshotGapDetector.
@Published var dataGaps: [SnapshotGapDetector.Gap] = []
// MARK: - Portfolio Forecast
@@ -195,11 +204,21 @@ class DashboardViewModel: ObservableObject {
snapshots: allSnapshots
)
updateEvolutionData(from: completedSnapshots, categories: categories)
latestPortfolioChange = calculateLatestChange(from: evolutionData)
latestPortfolioChange = calculateLatestCheckInChange(
sources: sources,
snapshots: allSnapshots,
fallback: evolutionData
)
// Calculate portfolio forecast
updatePortfolioForecast()
// Compute update streak
updateStreak = computeStreak(from: evolutionData)
// Detect internal data gaps (missing months between known snapshots)
dataGaps = SnapshotGapDetector.detectGaps(sources: sources, snapshots: allSnapshots)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
@@ -274,51 +293,75 @@ class DashboardViewModel: ObservableObject {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in
let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date)
return DateComponents(year: components.year, month: components.month)
let calendar = Calendar.current
func monthKey(_ date: Date) -> DateComponents {
let c = calendar.dateComponents([.year, .month], from: date)
return DateComponents(year: c.year, month: c.month)
}
// Ordered grid of every month that has data, and each month's index in it.
let sortedMonthKeys = Set(snapshots.map { monthKey($0.date) }).sorted {
(calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast)
}
let monthCount = sortedMonthKeys.count
guard monthCount > 0 else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
var indexByKey: [DateComponents: Int] = [:]
for (i, key) in sortedMonthKeys.enumerated() { indexByKey[key] = i }
// Per source: the latest snapshot value at each month index + its category.
var knownBySource: [UUID: [Int: (value: Decimal, date: Date)]] = [:]
var categoryBySource: [UUID: UUID] = [:]
for snapshot in snapshots {
guard let sourceId = snapshot.source?.id,
let idx = indexByKey[monthKey(snapshot.date)] else { continue }
let existing = knownBySource[sourceId]?[idx]
if existing == nil || snapshot.date > existing!.date {
knownBySource[sourceId, default: [:]][idx] = (snapshot.decimalValue, snapshot.date)
}
if let categoryId = snapshot.source?.category?.id {
categoryBySource[sourceId] = categoryId
}
}
// Fill each source's gaps (linear interpolation when enabled, else stepped
// carry-forward), then sum per month. Interpolating per source means a
// source last seen in Feb and next in Jun contributes a smooth ramp for
// Mar/Apr/May instead of a flat line + jump. Chart display only no data
// is created (see ChartGapFill).
let interpolate = ChartGapFill.isEnabled
var monthTotals = [Decimal](repeating: 0, count: monthCount)
var monthCategory = [[UUID: Decimal]](repeating: [:], count: monthCount)
var categoryTotals: [UUID: Decimal] = [:]
for (sourceId, known) in knownBySource {
let dense = ChartGapFill.denseValues(
known: known.mapValues { $0.value },
monthCount: monthCount,
interpolate: interpolate
)
let categoryId = categoryBySource[sourceId]
for i in 0..<monthCount {
guard let v = dense[i] else { continue }
monthTotals[i] += v
if let categoryId {
monthCategory[i][categoryId, default: 0] += v
categoryTotals[categoryId, default: 0] += v
}
}
}
var evolution: [(date: Date, value: Decimal)] = []
evolution.reserveCapacity(groupedByMonth.count)
evolution.reserveCapacity(monthCount)
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
series.reserveCapacity(groupedByMonth.count)
var categoryTotals: [UUID: Decimal] = [:]
for (key, monthSnapshots) in groupedByMonth {
var latestBySource: [UUID: Snapshot] = [:]
for snapshot in monthSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
if let existing = latestBySource[sourceId] {
if snapshot.date > existing.date {
latestBySource[sourceId] = snapshot
}
} else {
latestBySource[sourceId] = snapshot
}
}
var total: Decimal = 0
var valuesByCategory: [UUID: Decimal] = [:]
for snapshot in latestBySource.values {
let value = snapshot.decimalValue
total += value
if let categoryId = snapshot.source?.category?.id {
valuesByCategory[categoryId, default: 0] += value
categoryTotals[categoryId, default: 0] += value
}
}
let date = Calendar.current.date(from: key) ?? Date()
evolution.append((date: date, value: total))
series.append((date: date, valuesByCategory: valuesByCategory))
series.reserveCapacity(monthCount)
for i in 0..<monthCount {
let date = calendar.date(from: sortedMonthKeys[i]) ?? Date()
evolution.append((date: date, value: monthTotals[i]))
series.append((date: date, valuesByCategory: monthCategory[i]))
}
evolution.sort { $0.date < $1.date }
series.sort { $0.date < $1.date }
return EvolutionSummary(
evolutionData: evolution,
categorySeries: series,
@@ -345,7 +388,10 @@ class DashboardViewModel: ObservableObject {
for (key, monthSnapshots) in groupedByMonth {
guard let monthDate = Calendar.current.date(from: key) else { continue }
guard monthDate > cutoff else { continue }
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
// A month is "complete" if all active sources have snapshot data for it.
// We intentionally do NOT require a MonthlyCheckInStore entry here because
// that store lives in UserDefaults (device-local) and is not synced via iCloud.
// Snapshot data from CoreData is the authoritative cross-device source of truth.
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
if sourceIds.isSubset(of: monthSourceIds) {
completed.insert(key)
@@ -360,7 +406,7 @@ class DashboardViewModel: ObservableObject {
snapshots: [Snapshot]
) -> [Snapshot] {
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else {
return []
return snapshots
}
let completedMonthsAfter = completedMonthKeys(
@@ -371,6 +417,10 @@ class DashboardViewModel: ObservableObject {
return snapshots.filter { snapshot in
let monthDate = snapshot.date.startOfMonth
if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate),
snapshot.date > completionDate {
return false
}
if monthDate <= lastCompleted {
return true
}
@@ -382,7 +432,7 @@ class DashboardViewModel: ObservableObject {
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
guard data.count >= 2 else {
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
return PortfolioChange(absolute: 0, percentage: 0, label: "since last check-in")
}
let last = data[data.count - 1]
let previous = data[data.count - 2]
@@ -390,7 +440,58 @@ class DashboardViewModel: ObservableObject {
let percentage = previous.value > 0
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
}
private func calculateLatestCheckInChange(
sources: [InvestmentSource],
snapshots: [Snapshot],
fallback: [(date: Date, value: Decimal)]
) -> PortfolioChange {
let completedMonths = MonthlyCheckInStore.allEntries()
.compactMap { entry -> Date? in
entry.entry.completionDate != nil ? entry.date.startOfMonth : nil
}
.sorted()
guard completedMonths.count >= 2 else {
return calculateLatestChange(from: fallback)
}
let lastMonth = completedMonths[completedMonths.count - 1]
let previousMonth = completedMonths[completedMonths.count - 2]
let lastCompletion = MonthlyCheckInStore.completionDate(for: lastMonth) ?? lastMonth.endOfMonth
let previousCompletion = MonthlyCheckInStore.completionDate(for: previousMonth) ?? previousMonth.endOfMonth
let lastValue = totalPortfolioValue(asOf: lastCompletion, sources: sources, snapshots: snapshots)
let previousValue = totalPortfolioValue(asOf: previousCompletion, sources: sources, snapshots: snapshots)
let absolute = lastValue - previousValue
let percentage = previousValue > 0
? NSDecimalNumber(decimal: absolute / previousValue).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in")
}
private func totalPortfolioValue(
asOf date: Date,
sources: [InvestmentSource],
snapshots: [Snapshot]
) -> Decimal {
let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id }
var total = Decimal.zero
for source in sources {
let sourceId = source.id
guard let sourceSnapshots = snapshotsBySource[sourceId] else { continue }
if let latest = sourceSnapshots
.filter({ $0.date <= date })
.max(by: { $0.date < $1.date }) {
total += latest.decimalValue
}
}
return total
}
private func updatePortfolioForecast() {
@@ -427,8 +528,19 @@ class DashboardViewModel: ObservableObject {
let totalGrowth = lastPoint.value - firstPoint.value
let monthlyGrowth = totalGrowth / Decimal(monthsBetween)
// Project 12 months ahead
let forecastValue = currentValue + (monthlyGrowth * 12)
// Project 12 months ahead using compound growth (matches CAGR semantics);
// fall back to the linear projection if the ratio is degenerate.
let firstD = NSDecimalNumber(decimal: firstPoint.value).doubleValue
let lastD = NSDecimalNumber(decimal: lastPoint.value).doubleValue
let currentD = NSDecimalNumber(decimal: currentValue).doubleValue
let forecastValue: Decimal
if firstD > 0, lastD > 0 {
let monthlyRate = pow(lastD / firstD, 1.0 / Double(monthsBetween))
let projected = currentD * pow(monthlyRate, 12)
forecastValue = projected.isFinite ? Decimal(projected) : currentValue + (monthlyGrowth * 12)
} else {
forecastValue = currentValue + (monthlyGrowth * 12)
}
// Calculate confidence interval based on volatility
let volatility = calculatePortfolioVolatility(from: values)
@@ -470,10 +582,49 @@ class DashboardViewModel: ObservableObject {
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let totalReturn = (last.value - first.value) / first.value
// Annualize: (1 + total_return)^(12/months) - 1
let monthlyReturn = totalReturn / Decimal(monthsBetween)
let annualized = monthlyReturn * 12
return annualized
// Annualize compounding: (1 + total_return)^(12/months) - 1.
// The previous linear approximation (monthly*12) overstated short histories.
let totalReturnD = NSDecimalNumber(decimal: totalReturn).doubleValue
guard totalReturnD > -1 else { return 0 }
let annualizedD = pow(1 + totalReturnD, 12.0 / Double(monthsBetween)) - 1
guard annualizedD.isFinite else { return 0 }
return Decimal(annualizedD)
}
private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int {
guard !evolutionData.isEmpty else { return 0 }
let calendar = Calendar.current
// Group into (year, month) set
let monthSet: Set<DateComponents> = Set(evolutionData.map { point in
let comps = calendar.dateComponents([.year, .month], from: point.date)
return DateComponents(year: comps.year, month: comps.month)
})
// Get sorted unique months descending
let sortedMonths = monthSet
.compactMap { comps -> Date? in calendar.date(from: comps) }
.sorted(by: >)
guard let mostRecent = sortedMonths.first else { return 0 }
var streak = 1
var current = mostRecent
for i in 1..<sortedMonths.count {
guard let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonths[i]
let prevComps = calendar.dateComponents([.year, .month], from: prev)
let expectedComps = calendar.dateComponents([.year, .month], from: expected)
if prevComps.year == expectedComps.year && prevComps.month == expectedComps.month {
streak += 1
current = prev
} else {
break
}
}
return streak
}
// Virtual snapshot for forecast calculations
@@ -544,6 +695,83 @@ class DashboardViewModel: ObservableObject {
sourcesNeedingUpdate.count
}
/// Total number of missing months across all detected internal gaps.
var totalMissingMonths: Int {
SnapshotGapDetector.totalMissingMonths(dataGaps)
}
/// Resolves an InvestmentSource for a detected gap so the fill flow can open
/// the add-snapshot sheet for it.
func source(withId id: UUID) -> InvestmentSource? {
sourceRepository.sources.first { $0.id == id }
}
/// Builds the data for the shareable monthly summary card from already-computed
/// dashboard state. Mood/rating come from the latest completed check-in entry.
func monthlySummaryShareData() -> ShareService.MonthlySummaryShareData {
let monthLabel: String = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let date = MonthlyCheckInStore.latestCompletionDate() ?? Date()
return formatter.string(from: date)
}()
let latestEntry = MonthlyCheckInStore.latestCompletionDate()
.flatMap { MonthlyCheckInStore.entry(for: $0) }
return ShareService.MonthlySummaryShareData(
monthLabel: monthLabel,
totalValue: portfolioSummary.formattedTotalValue,
changeAmount: latestPortfolioChange.formattedAbsolute,
changePercentage: latestPortfolioChange.formattedPercentage,
isPositive: latestPortfolioChange.absolute >= 0,
streak: updateStreak,
mood: latestEntry?.mood,
rating: latestEntry?.rating
)
}
/// Prioritized, on-device insights. Gathers local inputs and delegates the
/// rules to `InsightsEngine` (no network / no AI).
var insights: [PortfolioInsight] {
let total = portfolioSummary.totalValue
guard total > 0 else { return [] }
let categorySlices: [InsightsEngine.CategorySlice] = categoryMetrics.map { cat in
InsightsEngine.CategorySlice(
name: cat.categoryName,
percentageOfPortfolio: cat.percentageOfPortfolio,
targetPercentage: AllocationTargetStore.target(for: cat.id),
returnPercentage: NSDecimalNumber(decimal: cat.metrics.percentageReturn).doubleValue
)
}
let sources = filteredSources()
let totalDouble = NSDecimalNumber(decimal: total).doubleValue
let sourceSlices: [InsightsEngine.SourceSlice] = sources.compactMap { source in
let value = NSDecimalNumber(decimal: source.latestValue).doubleValue
guard value > 0, totalDouble > 0 else { return nil }
return InsightsEngine.SourceSlice(
name: source.name,
percentageOfPortfolio: value / totalDouble * 100
)
}
let input = InsightsEngine.Input(
totalValue: total,
categories: categorySlices,
sources: sourceSlices,
fundedCategoryCount: categoryMetrics.filter { $0.totalValue > 0 }.count,
fundedSourceCount: sourceSlices.count,
yearChangePercentage: portfolioSummary.yearChangePercentage,
allTimeReturn: portfolioSummary.allTimeReturn,
updateStreak: updateStreak,
forecast: portfolioForecast
)
return InsightsEngine.makeInsights(input)
}
var topCategories: [CategoryMetrics] {
Array(categoryMetrics.prefix(5))
}
@@ -581,4 +809,15 @@ class DashboardViewModel: ObservableObject {
var formattedLastUpdate: String {
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
}
/// Absolute last-check-in date (e.g. "7 Aug 2026") for the share card an
/// outsider needs a concrete date, not "2 days ago".
var shareAsOfDate: String? {
guard let date = portfolioSummary.lastUpdated else { return nil }
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.dateStyle = .medium
f.timeStyle = .none
return f.string(from: date)
}
}
@@ -68,6 +68,31 @@ class GoalsViewModel: ObservableObject {
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
}
func isAchieved(_ goal: Goal) -> Bool {
Self.isAchieved(progress: progress(for: goal))
}
static func isAchieved(progress: Double) -> Bool {
progress >= 0.999
}
static func urgencyLevel(
targetDate: Date?,
isBehind: Bool,
isAchieved: Bool,
referenceDate: Date = Date()
) -> GoalUrgencyLevel {
guard let targetDate else { return .normal }
guard !isAchieved else { return .normal }
guard isBehind else { return .normal }
let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay)
if daysUntilTarget < 0 {
return .critical
}
return .warning
}
func totalValue(for goal: Goal) -> Decimal {
if let accountId = goal.account?.safeId {
return sourceRepository.sources
@@ -136,71 +161,67 @@ class GoalsViewModel: ObservableObject {
goalRepository.deleteGoal(goal)
}
private func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
func archiveGoal(_ goal: Goal) {
goalRepository.updateGoal(goal, isActive: !goal.isActive)
}
func estimateCompletionDate(for goal: Goal) -> Date? {
projection(for: goal).projectedDate
}
/// Full forward projection for a goal, blending recent growth trend with the
/// monthly contributions of the relevant sources. Cached per goal.
func projection(for goal: Goal) -> GoalProjection.Result {
let sources = relevantSources(for: goal)
let currentValue = totalValue(for: goal)
guard !sources.isEmpty else {
let cached = GoalProjection.Result(
projectedDate: nil, monthsToReach: nil,
status: goal.targetDate == nil ? .noTargetDate : .unreachable,
monthlyGrowthRate: 0, sparkline: []
)
cachedCompletionDates[goal.id] = nil
return cached
}
let sources: [InvestmentSource]
let evolutionData = evolutionData(for: goal, sources: sources)
let rate = GoalProjection.monthlyGrowthRate(from: evolutionData)
let contribution = sources.reduce(Decimal.zero) {
$0 + ($1.monthlyContribution?.decimalValue ?? .zero)
}
let result = GoalProjection.project(
currentValue: currentValue,
targetAmount: goal.targetDecimal,
monthlyGrowthRate: rate,
monthlyContribution: contribution,
targetDate: goal.targetDate
)
cachedCompletionDates[goal.id] = result.projectedDate
return result
}
private func relevantSources(for goal: Goal) -> [InvestmentSource] {
if let accountId = goal.account?.safeId {
sources = sourceRepository.sources.filter { $0.account?.id == accountId }
} else {
sources = sourceRepository.sources
return sourceRepository.sources.filter { $0.account?.id == accountId }
}
return sourceRepository.sources
}
private func evolutionData(
for goal: Goal,
sources: [InvestmentSource]
) -> [(date: Date, value: Decimal)] {
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
if let cached = cachedEvolutionData[cacheKey] {
return cached
}
let sourceIds = sources.compactMap { $0.id }
guard !sourceIds.isEmpty else {
cachedCompletionDates[goal.id] = nil
return nil
}
// Performance: Use cached evolution data if available
let cacheKey = goal.account?.safeId ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let evolutionData: [(date: Date, value: Decimal)]
if let cached = cachedEvolutionData[cacheKey] {
evolutionData = cached
} else {
let snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
evolutionData = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = evolutionData
}
guard evolutionData.count >= 3,
let first = evolutionData.suffix(6).first,
let last = evolutionData.suffix(6).last else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let currentValue = totalValue(for: goal)
let remaining = goal.targetDecimal - currentValue
guard remaining > 0 else {
let result = Date()
cachedCompletionDates[goal.id] = result
return result
}
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
let monthsRounded = Int(ceil(months))
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
cachedCompletionDates[goal.id] = result
return result
let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths)
let data = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = data
return data
}
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
@@ -288,3 +309,9 @@ struct GoalPaceStatus {
let isBehind: Bool
let statusText: String
}
enum GoalUrgencyLevel: Equatable {
case normal
case warning
case critical
}
@@ -89,8 +89,7 @@ class MonthlyCheckInViewModel: ObservableObject {
)
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
let now = Date()
let targetDate = targetRange.contains(now) ? now : targetRange.end
let targetDate = Date()
for source in sources {
let sourceId = source.id
@@ -20,6 +20,8 @@ class SettingsViewModel: ObservableObject {
@Published var showingExportOptions = false
@Published var showingImportSheet = false
@Published var showingResetConfirmation = false
@Published var isBackupInProgress = false
@Published var isRestoreInProgress = false
@Published var errorMessage: String?
@Published var successMessage: String?
@@ -29,6 +31,14 @@ class SettingsViewModel: ObservableObject {
@Published var totalSnapshots = 0
@Published var totalCategories = 0
// MARK: - Backups
@Published var backupRetentionCount = 10
@Published var backups: [BackupRecord] = []
@Published var backupsEnabled = false
/// Auto-backup cadence in days: 0 = off, 1 = daily, 7 = weekly, 30 = monthly.
@Published var autoBackupFrequencyDays = 0
// MARK: - Dependencies
private let iapService: IAPService
@@ -37,6 +47,7 @@ class SettingsViewModel: ObservableObject {
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
private let backupsEnabledKey = "backupsEnabled"
// MARK: - Initialization
@@ -61,7 +72,10 @@ class SettingsViewModel: ObservableObject {
private func setupObservers() {
iapService.$isPremium
.receive(on: DispatchQueue.main)
.assign(to: &$isPremium)
.sink { [weak self] isPremium in
self?.handlePremiumChange(isPremium)
}
.store(in: &cancellables)
iapService.$isFamilyShared
.receive(on: DispatchQueue.main)
@@ -82,6 +96,14 @@ class SettingsViewModel: ObservableObject {
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
backupRetentionCount = loadBackupRetention()
backupsEnabled = loadBackupsEnabled()
autoBackupFrequencyDays = UserDefaults.standard.integer(forKey: "autoBackupFrequencyDays")
if backupsEnabled {
refreshBackups()
} else {
backups = []
}
// Load statistics directly from database to avoid async race conditions
loadStatistics()
@@ -297,7 +319,6 @@ class SettingsViewModel: ObservableObject {
"Asset",
"Account",
"Goal",
"Transaction",
"PredictionCache"
]
@@ -335,6 +356,7 @@ class SettingsViewModel: ObservableObject {
if accountCount == 0 {
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.id = Account.defaultAccountStableID
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
@@ -361,6 +383,98 @@ class SettingsViewModel: ObservableObject {
NotificationCenter.default.post(name: .didResetData, object: nil)
}
// MARK: - Backups
func updateBackupRetention(_ count: Int) {
guard backupsEnabled, isPremium else { return }
backupRetentionCount = count
UserDefaults.standard.set(count, forKey: "backupRetentionCount")
refreshBackups()
}
func refreshBackups() {
guard backupsEnabled, isPremium else { return }
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
backups = BackupService.shared.listAllBackups(includeICloud: includeICloud)
}
/// Auto-backup cadence (0 = off). Running one immediately when enabled gives
/// instant feedback that it works.
func updateAutoBackupFrequency(_ days: Int) {
guard backupsEnabled, isPremium else { return }
autoBackupFrequencyDays = days
UserDefaults.standard.set(days, forKey: "autoBackupFrequencyDays")
if days > 0 { createBackupNow() }
}
var lastAutoBackupDate: Date? {
UserDefaults.standard.object(forKey: "lastAutoBackupDate") as? Date
}
func createBackupNow() {
guard backupsEnabled, isPremium else { return }
guard !isBackupInProgress else { return }
isBackupInProgress = true
errorMessage = nil
Task {
let includeICloud = UserDefaults.standard.bool(forKey: "cloudSyncEnabled")
let records = BackupService.shared.createBackup(
retentionCount: backupRetentionCount,
includeICloud: includeICloud
)
await MainActor.run {
backups = records
isBackupInProgress = false
successMessage = "Backup saved"
}
}
}
func restoreBackup(_ backup: BackupRecord) {
guard backupsEnabled, isPremium else { return }
guard !isRestoreInProgress else { return }
isRestoreInProgress = true
errorMessage = nil
Task {
let content: String
do {
content = try String(contentsOf: backup.url, encoding: .utf8)
} catch {
await MainActor.run {
isRestoreInProgress = false
errorMessage = "Failed to read backup file."
}
return
}
await MainActor.run {
resetAllData()
}
let allowMultiple = iapService.isPremium
let result = await ImportService.shared.importDataAsync(
content: content,
format: .json,
allowMultipleAccounts: allowMultiple,
defaultAccountName: Account.defaultAccountName,
progress: { _ in }
)
await MainActor.run {
isRestoreInProgress = false
if result.errors.isEmpty {
successMessage = "Backup restored"
} else {
errorMessage = "Backup restored with warnings."
}
loadSettings()
refreshBackups()
}
}
}
// MARK: - Computed Properties
var appVersion: String {
@@ -405,4 +519,53 @@ class SettingsViewModel: ObservableObject {
return 0
}
}
private func loadBackupRetention() -> Int {
let value = UserDefaults.standard.integer(forKey: "backupRetentionCount")
let options = [5, 10, 20]
return options.contains(value) ? value : 10
}
private func loadBackupsEnabled() -> Bool {
let enabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
if !isPremium && enabled {
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
return false
}
return isPremium && enabled
}
private func handlePremiumChange(_ isPremium: Bool) {
self.isPremium = isPremium
if isPremium {
backupsEnabled = UserDefaults.standard.bool(forKey: backupsEnabledKey)
if backupsEnabled {
refreshBackups()
}
} else {
if backupsEnabled {
backupsEnabled = false
}
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
backups = []
}
}
func setBackupsEnabled(_ enabled: Bool) {
guard isPremium else {
backupsEnabled = false
UserDefaults.standard.set(false, forKey: backupsEnabledKey)
FirebaseService.shared.logPaywallShown(trigger: "backups")
showingPaywall = true
return
}
backupsEnabled = enabled
UserDefaults.standard.set(enabled, forKey: backupsEnabledKey)
if enabled {
refreshBackups()
} else {
backups = []
}
}
}
@@ -1,5 +1,6 @@
import Foundation
import Combine
import UIKit
@MainActor
class SnapshotFormViewModel: ObservableObject {
@@ -12,9 +13,12 @@ class SnapshotFormViewModel: ObservableObject {
@Published var includeContribution = false
@Published var inputMode: InputMode = .simple
@Published var currencySymbol = ""
private let currencyCode: String
@Published var isValid = false
@Published var errorMessage: String?
@Published var clipboardValue: String?
private var rawClipboardString: String?
// MARK: - Mode
@@ -26,19 +30,31 @@ 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>()
// MARK: - Initialization
init(source: InvestmentSource, mode: Mode = .add) {
init(source: InvestmentSource, mode: Mode = .add, initialDate: Date? = nil) {
self.source = source
self.mode = mode
if case .add = mode, let initialDate {
// Prefill for filling a specific missing month (gap fill flow).
// Clamp to today so the date picker's `...Date()` bound holds.
date = min(initialDate, Date())
}
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
currencyCode = accountCurrency
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
} else {
currencyCode = settings.currency
currencySymbol = settings.currencySymbol
}
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
@@ -57,6 +73,7 @@ class SnapshotFormViewModel: ObservableObject {
if let contribution = snapshot.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
originalContribution = contribution.decimalValue
}
notes = snapshot.notes ?? ""
}
@@ -88,8 +105,11 @@ class SnapshotFormViewModel: ObservableObject {
// Contribution is optional but must be valid if included
if includeContribution {
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
return false
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
guard let contribution = parseDecimal(trimmed), contribution >= 0 else {
return false
}
}
}
@@ -99,27 +119,11 @@ class SnapshotFormViewModel: ObservableObject {
// MARK: - Parsing
private func parseDecimal(_ string: String) -> Decimal? {
let cleaned = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US")
return formatter.number(from: cleaned)?.decimalValue
CurrencyFormatter.parseUserInput(string, currencySymbol: currencySymbol)
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
CurrencyFormatter.formatForInput(decimal, currencyCode: currencyCode)
}
// MARK: - Computed Properties
@@ -130,7 +134,15 @@ class SnapshotFormViewModel: ObservableObject {
var contribution: Decimal? {
guard includeContribution else { return nil }
return parseDecimal(contributionString)
let trimmed = contributionString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
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 {
@@ -207,6 +219,41 @@ class SnapshotFormViewModel: ObservableObject {
date = Date()
}
// MARK: - Clipboard
func checkClipboard() {
guard let raw = UIPasteboard.general.string else {
clipboardValue = nil
rawClipboardString = nil
return
}
guard let parsed = parseDecimal(raw), parsed > 0 else {
clipboardValue = nil
rawClipboardString = nil
return
}
// Don't suggest if it matches what's already typed
let formatted = formatDecimalForInput(parsed)
if formatted == valueString {
clipboardValue = nil
rawClipboardString = nil
return
}
rawClipboardString = raw
clipboardValue = CurrencyFormatter.format(parsed, style: .currency, maximumFractionDigits: 2)
}
func applyClipboardValue() {
guard let raw = rawClipboardString,
let parsed = parseDecimal(raw) else { return }
valueString = formatDecimalForInput(parsed)
clipboardValue = nil
rawClipboardString = nil
}
// MARK: - Date Validation
var isDateInFuture: Bool {
@@ -11,13 +11,12 @@ class SourceDetailViewModel: ObservableObject {
@Published var metrics: InvestmentMetrics = .empty
@Published var predictions: [Prediction] = []
@Published var predictionResult: PredictionResult?
@Published var transactions: [Transaction] = []
@Published var isDeleted = false
@Published var isLoading = false
@Published var showingAddSnapshot = false
@Published var showingEditSource = false
@Published var showingPaywall = false
@Published var showingAddTransaction = false
@Published var errorMessage: String?
// MARK: - Chart Data
@@ -28,7 +27,6 @@ class SourceDetailViewModel: ObservableObject {
private let snapshotRepository: SnapshotRepository
private let sourceRepository: InvestmentSourceRepository
private let transactionRepository: TransactionRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
@@ -37,6 +35,7 @@ class SourceDetailViewModel: ObservableObject {
private var isRefreshing = false
private var refreshQueued = false
private var refreshTask: Task<Void, Never>?
private let sourceName: String
// MARK: - Initialization
@@ -44,15 +43,14 @@ class SourceDetailViewModel: ObservableObject {
source: InvestmentSource,
snapshotRepository: SnapshotRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
transactionRepository: TransactionRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
) {
self.source = source
self.sourceName = source.name
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.transactionRepository = transactionRepository ?? TransactionRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
@@ -103,6 +101,11 @@ class SourceDetailViewModel: ObservableObject {
refreshTask = Task { [weak self] in
guard let self else { return }
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
self.isDeleted = true
self.isRefreshing = false
return
}
while self.refreshQueued && !Task.isCancelled {
self.refreshQueued = false
@@ -139,8 +142,6 @@ class SourceDetailViewModel: ObservableObject {
}
}
// Transactions update independently
self.transactions = transactionRepository.fetchTransactions(for: source)
}
self.isRefreshing = false
}
@@ -183,33 +184,6 @@ class SourceDetailViewModel: ObservableObject {
refreshData()
}
// MARK: - Transaction Actions
func addTransaction(
type: TransactionType,
date: Date,
shares: Decimal?,
price: Decimal?,
amount: Decimal?,
notes: String?
) {
transactionRepository.createTransaction(
source: source,
type: type,
date: date,
shares: shares,
price: price,
amount: amount,
notes: notes
)
refreshData()
}
func deleteTransaction(_ transaction: Transaction) {
transactionRepository.deleteTransaction(transaction)
refreshData()
}
// MARK: - Source Actions
func updateSource(
@@ -232,6 +206,23 @@ class SourceDetailViewModel: ObservableObject {
showingEditSource = false
}
func deleteSource() {
guard !isDeleted else { return }
// Mark deleted first so the view can dismiss before any further access.
isDeleted = true
snapshots = []
chartData = []
predictionResult = nil
// Cancel any pending notifications for this source
NotificationService.shared.cancelReminder(for: source)
// Log analytics before deletion
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
// Delete the source using the existing repository
sourceRepository.deleteSource(source)
}
// MARK: - Predictions
func showPredictions() {
@@ -256,6 +247,13 @@ class SourceDetailViewModel: ObservableObject {
currentValue.currencyString
}
var safeSourceName: String {
if source.isDeleted || source.managedObjectContext == nil {
return sourceName
}
return source.name
}
var totalReturn: Decimal {
metrics.absoluteReturn
}
@@ -8,7 +8,7 @@ class SourceListViewModel: ObservableObject {
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategory: Category?
@Published var selectedCategoryIds: Set<UUID> = []
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@@ -55,7 +55,7 @@ class SourceListViewModel: ObservableObject {
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategory,
$selectedCategoryIds,
$selectedAccount
)
.combineLatest($showAllAccounts)
@@ -88,9 +88,12 @@ class SourceListViewModel: ObservableObject {
filtered = filtered.filter { $0.account?.id == selectedAccountId }
}
// Filter by category
if let category = selectedCategory {
filtered = filtered.filter { $0.category?.id == category.id }
// Filter by category (multi-select)
if !selectedCategoryIds.isEmpty {
filtered = filtered.filter {
guard let id = $0.category?.id else { return false }
return selectedCategoryIds.contains(id)
}
}
// Filter by search text
@@ -195,26 +198,30 @@ class SourceListViewModel: ObservableObject {
totalValue.currencyString
}
var sourcesByCategory: [Category: [InvestmentSource]] {
Dictionary(grouping: sources) { $0.category ?? categories.first! }
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategory == nil
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
var isFiltered: Bool {
!searchText.isEmpty || selectedCategory != nil
!searchText.isEmpty || !selectedCategoryIds.isEmpty
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
selectedCategory = category
guard let category else {
selectedCategoryIds = []
return
}
if selectedCategoryIds.contains(category.id) {
selectedCategoryIds.remove(category.id)
} else {
selectedCategoryIds.insert(category.id)
}
}
func clearFilters() {
searchText = ""
selectedCategory = nil
selectedCategoryIds = []
}
}
@@ -3,8 +3,18 @@ import Charts
struct AllocationPieChart: View {
let data: [(category: String, value: Decimal, color: String)]
var title: String = "Asset Allocation"
var showsTargetsComparison: Bool = true
/// Drill-down: tapping the chevron on a legend row jumps to Evolution
/// filtered to that category/source.
var onDrillDown: ((String) -> Void)? = nil
@State private var selectedSlice: String?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var chartHeight: CGFloat {
horizontalSizeClass == .regular ? 190 : 130
}
var total: Decimal {
data.reduce(Decimal.zero) { $0 + $1.value }
@@ -12,93 +22,74 @@ struct AllocationPieChart: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Asset Allocation")
Text(title)
.font(.headline)
if !data.isEmpty {
HStack(alignment: .top, spacing: 20) {
// Pie Chart
Chart(data, id: \.category) { item in
SectorMark(
angle: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue),
innerRadius: .ratio(0.6),
angularInset: 1.5
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.chartLegend(.hidden)
.frame(width: 180, height: 180)
.overlay {
// Center content
VStack(spacing: 2) {
if let selected = selectedSlice,
let item = data.first(where: { $0.category == selected }) {
Text(selected)
.font(.caption)
.foregroundColor(.secondary)
Text(item.value.compactCurrencyString)
.font(.headline)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Total")
.font(.caption)
.foregroundColor(.secondary)
Text(total.compactCurrencyString)
.font(.headline)
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
// Simple tap detection
}
HStack(alignment: .center, spacing: 24) {
// Hemicycle (parliament-style) allocation gauge fills its
// half of the row so it scales up on iPad instead of sitting
// tiny in a corner.
SemicircleAllocation(
data: data,
total: total,
selectedSlice: $selectedSlice
)
.frame(maxWidth: .infinity)
.frame(height: chartHeight)
// Legend
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 10) {
ForEach(data, id: \.category) { item in
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.caption)
.foregroundColor(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption2)
.foregroundColor(.secondary)
HStack(spacing: 6) {
Button {
if selectedSlice == item.category {
selectedSlice = nil
} else {
selectedSlice = item.category
}
} label: {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 11, height: 11)
VStack(alignment: .leading, spacing: 0) {
Text(item.category)
.font(.subheadline)
.foregroundColor(.primary)
let percentage = total > 0
? NSDecimalNumber(decimal: item.value / total).doubleValue * 100
: 0
Text(String(format: "%.1f%%", percentage))
.font(.caption)
.foregroundColor(.secondary)
}
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
if let onDrillDown {
Button {
onDrillDown(item.category)
} label: {
Image(systemName: "chevron.right.circle.fill")
.font(.footnote)
.foregroundColor(.secondary.opacity(0.6))
}
.buttonStyle(.plain)
}
.opacity(selectedSlice == nil || selectedSlice == item.category ? 1 : 0.5)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
AllocationTargetsComparisonChart(data: data)
if showsTargetsComparison {
AllocationTargetsComparisonChart(data: data)
}
} else {
Text("No allocation data available")
.foregroundColor(.secondary)
@@ -113,6 +104,116 @@ struct AllocationPieChart: View {
}
}
// MARK: - Semicircle (hemicycle) allocation gauge
/// Parliament-style half-donut: segments sweep the top 180°, sized by share.
struct SemicircleAllocation: View {
let data: [(category: String, value: Decimal, color: String)]
let total: Decimal
@Binding var selectedSlice: String?
/// Cumulative start/end fractions [0,1] for each segment across the 180° arc.
private var segments: [(category: String, start: Double, end: Double, color: Color)] {
guard total > 0 else { return [] }
var acc = 0.0
return data.map { item in
let frac = NSDecimalNumber(decimal: item.value / total).doubleValue
let start = acc
acc += frac
return (item.category, start, min(acc, 1), Color(hex: item.color) ?? .gray)
}
}
private var centerItem: (label: String, value: String, sub: String?)? {
if let selected = selectedSlice, let item = data.first(where: { $0.category == selected }) {
let pct = total > 0 ? NSDecimalNumber(decimal: item.value / total).doubleValue * 100 : 0
return (selected, item.value.compactCurrencyString, String(format: "%.1f%%", pct))
}
return (String(localized: "allocation_total"), total.compactCurrencyString, nil)
}
var body: some View {
GeometryReader { geo in
// The semicircle is 2:1 (width:height). Fit it to whichever
// dimension binds so it scales up on iPad without clipping.
let w = min(geo.size.width, geo.size.height * 2)
let lineWidth = w * 0.17
let radius = (w - lineWidth) / 2
let center = CGPoint(x: geo.size.width / 2, y: (geo.size.height + w / 2) / 2)
ZStack {
// Track
SemicircleArc(startFraction: 0, endFraction: 1, radius: radius, center: center)
.stroke(Color(.systemGray5), style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
ForEach(segments, id: \.category) { seg in
SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center, inset: 0.006)
.stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
.contentShape(SemicircleArc(startFraction: seg.start, endFraction: seg.end, radius: radius, center: center).stroke(style: StrokeStyle(lineWidth: lineWidth)))
.onTapGesture {
selectedSlice = (selectedSlice == seg.category) ? nil : seg.category
}
}
// Percentage labels on the arc band (only where the slice is wide
// enough to fit the text).
ForEach(segments, id: \.category) { seg in
let frac = seg.end - seg.start
if frac >= 0.07 {
let mid = (seg.start + seg.end) / 2
let angle = (180 + mid * 180) * .pi / 180
let pos = CGPoint(x: center.x + radius * CoreGraphics.cos(angle),
y: center.y + radius * CoreGraphics.sin(angle))
Text("\(Int((frac * 100).rounded()))%")
.font(.system(size: lineWidth * 0.42, weight: .bold))
.foregroundColor(.white)
.position(pos)
.opacity(selectedSlice == nil || selectedSlice == seg.category ? 1 : 0.4)
}
}
// Center readout, sitting just under the arc
if let c = centerItem {
VStack(spacing: 1) {
Text(c.label)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
Text(c.value)
.font(.headline)
if let sub = c.sub {
Text(sub)
.font(.caption2)
.foregroundColor(.secondary)
}
}
.position(x: center.x, y: center.y - lineWidth * 0.25)
}
}
}
}
}
/// An arc along the TOP semicircle, parameterized by fraction [0,1] of the 180°
/// sweep (0 = left/9 o'clock, 1 = right/3 o'clock, passing through the top).
struct SemicircleArc: Shape {
let startFraction: Double
let endFraction: Double
let radius: CGFloat
var center: CGPoint? = nil
var inset: Double = 0
func path(in rect: CGRect) -> Path {
let c = center ?? CGPoint(x: rect.width / 2, y: rect.width / 2)
let start = Angle.degrees(180 + (startFraction + inset) * 180)
let end = Angle.degrees(180 + (endFraction - inset) * 180)
var p = Path()
p.addArc(center: c, radius: radius, startAngle: start, endAngle: end, clockwise: false)
return p
}
}
// MARK: - Allocation Targets Comparison
struct AllocationTargetsComparisonChart: View {
@@ -0,0 +1,242 @@
import SwiftUI
import Charts
struct AllocationSimulatorView: View {
@Environment(\.chartImageExport) private var chartImageExport
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Allocation Simulator")
.font(.headline)
if viewModel.simulatorSources.isEmpty {
emptyStateView
} else {
allocationCard
chartSection
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No sources available")
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 300)
}
// MARK: - Allocation Card
private var allocationCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Adjust Allocation")
.font(.subheadline.weight(.semibold))
Spacer()
totalAllocationBadge
resetButton
}
ForEach(Array(viewModel.simulatorSources.enumerated()), id: \.element.id) { index, source in
sourceSliderRow(index: index, source: source)
}
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private var totalAllocationBadge: some View {
let total = viewModel.simulatorSources.reduce(0) { $0 + $1.simulatedPct }
let isNear100 = abs(total - 100) < 5
return Text(String(format: "%.0f%%", total))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(isNear100 ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))
.foregroundColor(isNear100 ? .green : .orange)
.cornerRadius(8)
}
private var resetButton: some View {
Button {
var updated = viewModel.simulatorSources
for i in updated.indices {
updated[i] = ChartsViewModel.SimulatorSource(
id: updated[i].id,
name: updated[i].name,
currentPct: updated[i].currentPct,
simulatedPct: updated[i].currentPct,
colorHex: updated[i].colorHex
)
}
viewModel.simulatorSources = updated
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
private func sourceSliderRow(index: Int, source: ChartsViewModel.SimulatorSource) -> some View {
VStack(spacing: 4) {
HStack {
Circle()
.fill(Color(hex: source.colorHex) ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
Spacer()
Text(String(format: "%.0f%%", source.simulatedPct))
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.frame(width: 36, alignment: .trailing)
Text(String(format: "(%.0f%%)", source.currentPct))
.font(.caption2)
.foregroundColor(.secondary)
}
Slider(
value: Binding<Double>(
get: { source.simulatedPct },
set: { newValue in
var updated = viewModel.simulatorSources
if index < updated.count {
updated[index] = ChartsViewModel.SimulatorSource(
id: source.id,
name: source.name,
currentPct: source.currentPct,
simulatedPct: newValue,
colorHex: source.colorHex
)
viewModel.simulatorSources = updated
}
}
),
in: 0...100,
step: 1
)
.tint(Color(hex: source.colorHex) ?? .appPrimary)
}
}
// MARK: - Chart Section
private var chartSection: some View {
VStack(alignment: .leading, spacing: 8) {
if viewModel.simulatorActualData.isEmpty && viewModel.simulatorData.isEmpty {
Text("Not enough data to simulate.")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(height: 220)
} else {
simulationChart
chartLegend
}
}
}
private var simulationChart: some View {
Chart {
ForEach(viewModel.simulatorActualData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Actual")
)
.foregroundStyle(Color.blue)
.interpolationMethod(.catmullRom)
}
ForEach(viewModel.simulatorData, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Value", item.value),
series: .value("Series", "Simulated")
)
.foregroundStyle(Color.orange)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 3]))
.interpolationMethod(.catmullRom)
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.0f", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 220)
.chartDrawingGroup(disabledForExport: chartImageExport)
}
private var chartLegend: some View {
HStack(spacing: 20) {
legendItem(color: .blue, label: "Actual", dashed: false)
legendItem(color: .orange, label: "Simulated", dashed: true)
Spacer()
}
}
private func legendItem(color: Color, label: String, dashed: Bool) -> some View {
HStack(spacing: 6) {
if dashed {
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 3)
}
} else {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 20, height: 3)
}
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let count = max(viewModel.simulatorActualData.count, viewModel.simulatorData.count)
switch count {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
}
@@ -0,0 +1,147 @@
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]
/// On regular width the charts container surfaces these stats in the KPI
/// header above the chart hide the in-card duplicate row. Charts without a
/// header equivalent (e.g. Year vs Year) opt back in.
var showsOnRegularWidth = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass != .regular || showsOnRegularWidth {
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)
}
@@ -0,0 +1,97 @@
import SwiftUI
import Charts
// MARK: - Point value labels for line charts
//
// Shared pieces for the "points + value labels" behavior across every line chart
// in the Charts tab: permanent labels when the series is short (they carry a lot
// of value at a glance), and a tap/drag selection bubble when it's dense.
enum ChartLabels {
/// A series with at most this many points shows its value labels permanently.
static let alwaysShowThreshold = 12
/// Max permanent labels to render before thinning fewer on the narrow
/// iPhone screen where crammed labels overlap and become unreadable.
static func maxLabels(compact: Bool) -> Int { compact ? 5 : 8 }
/// Stride between labelled points so at most `maxLabels` show.
static func stride(count: Int, compact: Bool) -> Int {
let maxN = maxLabels(compact: compact)
guard count > maxN else { return 1 }
return Int(ceil(Double(count) / Double(maxN)))
}
/// Whether point `index` should carry a permanent label: always the first
/// and last (the endpoints matter most), plus every `stride`-th in between.
/// On iPhone (compact) permanent labels collide with the axis on the narrow
/// screen, so only the endpoint keeps its label exact values live in the
/// scrub header instead.
static func showsLabel(index: Int, count: Int, compact: Bool) -> Bool {
guard count > 0 else { return false }
if compact {
return count <= 6 ? true : index == count - 1
}
if index == 0 || index == count - 1 { return true }
let s = stride(count: count, compact: compact)
// Avoid a label landing right next to the forced last one.
if index >= count - 1 - (s / 2) { return false }
return index % s == 0
}
static func compactCurrency(_ value: Decimal) -> String {
value.compactCurrencyString
}
static func percent(_ value: Double, decimals: Int = 1) -> String {
String(format: "%+.\(decimals)f%%", value)
}
}
/// Small capsule bubble used both for permanent point labels and the selection popover.
struct ChartValueBubble: View {
let text: String
var color: Color = .appPrimary
var prominent: Bool = false
var body: some View {
Text(text)
.font(prominent ? .caption.weight(.bold) : .caption2.weight(.semibold))
.foregroundColor(prominent ? .white : color)
.padding(.horizontal, prominent ? 8 : 5)
.padding(.vertical, prominent ? 4 : 2)
.background(
Capsule().fill(prominent ? color : color.opacity(0.12))
)
.fixedSize()
}
}
/// Multi-line selection card (one row per series) used by multi-series charts.
struct ChartSelectionCard: View {
let title: String
let rows: [(label: String, value: String, color: Color)]
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(spacing: 5) {
Circle().fill(row.color).frame(width: 6, height: 6)
Text(row.label)
.font(.caption2)
.foregroundColor(.secondary)
Text(row.value)
.font(.caption2.weight(.semibold))
.foregroundColor(.primary)
}
}
}
.padding(8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.2), lineWidth: 0.5))
.fixedSize()
}
}
@@ -0,0 +1,422 @@
import SwiftUI
import Charts
import UIKit
// MARK: - Time-series chart zoom (window model)
//
// Shared VISUAL zoom for every chart with a Date X axis: pinch anchored on the
// window center, native horizontal pan once zoomed, double-tap to reset.
//
// The DATA range is a separate, global concern: the range brush
// (`ChartRangeBrush`) lives in the period bar and drives
// `ChartsViewModel.customRange`, filtering every chart at once. Keeping it out
// of the chart content also keeps it out of the chart-swipe gesture's reach.
/// View-side window state for a time-series chart. `spanSeconds == nil` means fit-all.
struct ChartZoomModel {
/// Visible X-domain length in seconds; nil = show everything.
var spanSeconds: TimeInterval?
/// Start of the visible window (chart scroll position).
var startX: Date = .distantPast
/// (span, center) captured when a pinch begins.
var pinchAnchor: (span: TimeInterval, center: Date)?
static let minSpan: TimeInterval = 60 * 60 * 24 * 45 // ~1.5 months
/// Padding after the last point so it isn't clipped at the window edge.
static let endPadding: TimeInterval = 60 * 60 * 24 * 15 // half month
init() {}
var isZoomed: Bool { spanSeconds != nil }
/// The currently visible window, given the full data domain.
func visibleWindow(dates: [Date]) -> ClosedRange<Date>? {
guard let first = dates.first, let last = dates.last else { return nil }
guard let span = spanSeconds else { return first...last }
let start = max(first, min(startX, last))
let end = min(last, start.addingTimeInterval(span))
return start <= end ? start...end : first...last
}
func clamped(_ span: TimeInterval, fullSpan: TimeInterval) -> TimeInterval? {
let upper = max(fullSpan + Self.endPadding, Self.minSpan)
let clamped = min(max(span, Self.minSpan), upper)
// Snap back to fit-all when zoomed (almost) fully out.
return clamped >= upper * 0.98 ? nil : clamped
}
mutating func reset() {
spanSeconds = nil
pinchAnchor = nil
}
}
// MARK: - Haptics
enum ChartHaptics {
private static let selection = UISelectionFeedbackGenerator()
private static let impact = UIImpactFeedbackGenerator(style: .light)
static func tick() { selection.selectionChanged() }
static func bump() { impact.impactOccurred() }
}
extension View {
/// Applies zoomable window behavior to a Chart with a Date X axis.
@ViewBuilder
func zoomableTimeSeries(dates: [Date], zoom: Binding<ChartZoomModel>) -> some View {
modifier(ZoomableTimeSeriesModifier(dates: dates, zoom: zoom))
}
}
private struct ZoomableTimeSeriesModifier: ViewModifier {
let dates: [Date]
@Binding var zoom: ChartZoomModel
private var fullSpan: TimeInterval {
guard let min = dates.min(), let max = dates.max(), max > min else {
return ChartZoomModel.minSpan
}
return max.timeIntervalSince(min)
}
func body(content: Content) -> some View {
Group {
if let span = zoom.spanSeconds {
content
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: span)
.chartScrollPosition(x: $zoom.startX)
} else {
content
}
}
.simultaneousGesture(pinchGesture)
.simultaneousGesture(doubleTapGesture)
}
// Pinch anchored on the window center: the point you're looking at stays put.
private var pinchGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
var model = zoom
if model.pinchAnchor == nil {
let window = model.visibleWindow(dates: dates)
let span = model.spanSeconds ?? (fullSpan + ChartZoomModel.endPadding)
let center = window.map {
$0.lowerBound.addingTimeInterval($0.upperBound.timeIntervalSince($0.lowerBound) / 2)
} ?? Date()
model.pinchAnchor = (span, center)
}
guard let anchor = model.pinchAnchor, value.magnification > 0 else { return }
let newSpan = model.clamped(anchor.span / value.magnification, fullSpan: fullSpan)
model.spanSeconds = newSpan
if let newSpan, let first = dates.first, let last = dates.last {
let start = anchor.center.addingTimeInterval(-newSpan / 2)
let maxStart = last.addingTimeInterval(-newSpan + ChartZoomModel.endPadding)
model.startX = max(first, min(start, maxStart))
}
zoom = model
}
.onEnded { _ in
zoom.pinchAnchor = nil
}
}
private var doubleTapGesture: some Gesture {
TapGesture(count: 2)
.onEnded {
guard zoom.isZoomed else { return }
withAnimation(.snappy) { zoom.reset() }
ChartHaptics.bump()
}
}
}
// MARK: - Range brush (full-history sparkline + month-snapping handles)
//
// Index-based over the months that actually have data. Bound to a
// ClosedRange<Int> selection owned by the caller (the charts container maps it
// to ChartsViewModel.customRange). Selecting everything = "no filter".
struct ChartRangeBrush: View {
let dates: [Date]
let values: [Double]
@Binding var selection: ClosedRange<Int>
@State private var dragStartIndices: (lo: Int, hi: Int)?
@State private var dragTarget: DragTarget?
@State private var lastTickedIndex: Int = -1
@State private var showingPickers = false
/// What a drag manipulates, decided ONCE at touch-down by where it starts.
/// One unified gesture instead of per-element gestures: with a narrow
/// window (e.g. 2 months) the handles' hit areas used to swallow the whole
/// window and moving it was impossible.
private enum DragTarget { case lowerHandle, upperHandle, moveWindow }
private static let labelFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMM yyyy")
return f
}()
private var clampedSelection: (lo: Int, hi: Int) {
guard !dates.isEmpty else { return (0, 0) }
let lo = max(0, min(selection.lowerBound, dates.count - 1))
let hi = max(lo, min(selection.upperBound, dates.count - 1))
return (lo, hi)
}
var body: some View {
VStack(spacing: 6) {
rangeLabel
GeometryReader { geo in
brushBody(width: geo.size.width)
}
.frame(height: 44)
}
.sheet(isPresented: $showingPickers) {
ChartRangePickerSheet(dates: dates, selection: $selection)
.presentationDetents([.height(320)])
}
}
private var rangeLabel: some View {
let (lo, hi) = clampedSelection
return Button {
showingPickers = true
} label: {
HStack(spacing: 5) {
Text("\(Self.labelFormatter.string(from: dates[lo])) \(Self.labelFormatter.string(from: dates[hi]))")
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
.foregroundColor(.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "chart_range_button"))
}
@ViewBuilder
private func brushBody(width: CGFloat) -> some View {
let (lo, hi) = clampedSelection
let xLo = xPosition(index: lo, width: width)
let xHi = xPosition(index: hi, width: width)
ZStack(alignment: .leading) {
// Full-history sparkline (widget-style: gradient line, no axes)
SparklinePath(values: values)
.stroke(
LinearGradient(colors: [.appPrimary, .cyan],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)
)
.opacity(0.85)
// Dimming outside the window
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, xLo))
Rectangle()
.fill(Color(.systemBackground).opacity(0.72))
.frame(width: max(0, width - xHi))
.offset(x: xHi)
// Window frame
RoundedRectangle(cornerRadius: 6)
.stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.appPrimary.opacity(0.06))
)
.frame(width: max(10, xHi - xLo))
.offset(x: xLo)
// Handles (visual only hit-testing lives in the unified gesture)
handle(at: xLo)
handle(at: xHi)
}
.contentShape(Rectangle())
.gesture(unifiedGesture(width: width))
}
private func handle(at x: CGFloat) -> some View {
Capsule()
.fill(Color.appPrimary)
.frame(width: 5, height: 26)
.overlay(
Capsule().stroke(Color(.systemBackground), lineWidth: 1.5)
)
.frame(height: 44)
.offset(x: x - 2.5)
.allowsHitTesting(false)
}
// MARK: Geometry helpers
private func xPosition(index: Int, width: CGFloat) -> CGFloat {
guard dates.count > 1 else { return 0 }
return width * CGFloat(index) / CGFloat(dates.count - 1)
}
private func index(atX x: CGFloat, width: CGFloat) -> Int {
guard dates.count > 1, width > 0 else { return 0 }
let frac = max(0, min(1, x / width))
return Int((frac * CGFloat(dates.count - 1)).rounded())
}
// MARK: Unified gesture
//
// Decided at touch-down: grabbing near a handle resizes that edge; grabbing
// anywhere else (inside the window OR on the dimmed history) slides the
// whole window keeping its width select JanMar, then swipe to land on
// JunAug. Narrow windows stay movable because the edge grip only wins in
// its 18pt zone (from outside when the window is too narrow to share).
private func unifiedGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
let (lo, hi) = dragStartIndices ?? clampedSelection
if dragStartIndices == nil {
dragStartIndices = (lo, hi)
dragTarget = target(
forX: value.startLocation.x,
xLo: xPosition(index: lo, width: width),
xHi: xPosition(index: hi, width: width)
)
}
guard let target = dragTarget else { return }
var newLo = lo
var newHi = hi
switch target {
case .lowerHandle:
newLo = min(index(atX: value.location.x, width: width), hi)
case .upperHandle:
newHi = max(index(atX: value.location.x, width: width), lo)
case .moveWindow:
let span = hi - lo
let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded())
newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span))
newHi = newLo + span
}
let ticked = (target == .upperHandle) ? newHi : newLo
if ticked != lastTickedIndex {
lastTickedIndex = ticked
ChartHaptics.tick()
}
selection = newLo...newHi
}
.onEnded { _ in
dragStartIndices = nil
dragTarget = nil
lastTickedIndex = -1
}
}
private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget {
let grip: CGFloat = 18
let windowWidth = xHi - xLo
if windowWidth >= grip * 3 {
// Wide window: edges grab from either side of the handle.
if abs(x - xLo) <= grip { return .lowerHandle }
if abs(x - xHi) <= grip { return .upperHandle }
} else {
// Narrow window: the inside belongs to MOVE; edges only from outside.
if x < xLo && xLo - x <= grip { return .lowerHandle }
if x > xHi && x - xHi <= grip { return .upperHandle }
}
return .moveWindow
}
}
/// Minimal normalized line path for the brush background.
private struct SparklinePath: Shape {
let values: [Double]
func path(in rect: CGRect) -> Path {
var path = Path()
guard values.count > 1 else { return path }
let minV = values.min() ?? 0
let maxV = values.max() ?? 1
let range = maxV - minV
let inset: CGFloat = 4
func point(_ i: Int) -> CGPoint {
let x = rect.width * CGFloat(i) / CGFloat(values.count - 1)
let norm = range > 0 ? (values[i] - minV) / range : 0.5
let y = inset + (rect.height - inset * 2) * (1 - CGFloat(norm))
return CGPoint(x: x, y: y)
}
path.move(to: point(0))
for i in 1..<values.count {
path.addLine(to: point(i))
}
return path
}
}
// MARK: - Precise month/year pickers
/// Two wheel pickers over the months that actually have data the precise,
/// non-gesture way to land exactly on e.g. FebMar 2025.
struct ChartRangePickerSheet: View {
let dates: [Date]
@Binding var selection: ClosedRange<Int>
@Environment(\.dismiss) private var dismiss
@State private var fromIndex = 0
@State private var toIndex = 0
private static let optionFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate("MMMM yyyy")
return f
}()
var body: some View {
NavigationStack {
HStack(spacing: 0) {
picker(title: String(localized: "chart_range_from"), selection: $fromIndex)
picker(title: String(localized: "chart_range_to"), selection: $toIndex)
}
.navigationTitle(String(localized: "chart_range_button"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("OK") {
selection = min(fromIndex, toIndex)...max(fromIndex, toIndex)
ChartHaptics.bump()
dismiss()
}
}
}
}
.onAppear {
fromIndex = max(0, min(selection.lowerBound, dates.count - 1))
toIndex = max(fromIndex, min(selection.upperBound, dates.count - 1))
}
.onChange(of: fromIndex) { _, new in
if new > toIndex { toIndex = new }
}
.onChange(of: toIndex) { _, new in
if new < fromIndex { fromIndex = new }
}
}
private func picker(title: String, selection: Binding<Int>) -> some View {
VStack(spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
Picker(title, selection: selection) {
ForEach(dates.indices, id: \.self) { i in
Text(Self.optionFormatter.string(from: dates[i])).tag(i)
}
}
.pickerStyle(.wheel)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
import SwiftUI
import Charts
struct ComparisonChartView: View {
@Environment(\.chartImageExport) private var chartImageExport
@State private var selectedDate: Date?
private static let selectionDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM yyyy"
return f
}()
@ObservedObject var viewModel: ChartsViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Compare Sources")
.font(.headline)
sourceSelector
Picker("", selection: $viewModel.comparisonDisplayMode) {
ForEach(ChartsViewModel.ComparisonDisplayMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Source Selector
private var sourceSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
let chipColor = Color(hex: ChartsViewModel.sourceColorHexesPublic[index % ChartsViewModel.sourceColorHexesPublic.count]) ?? .appPrimary
Button {
if isSelected {
viewModel.comparisonSelectedSourceIds.remove(source.id)
} else {
viewModel.comparisonSelectedSourceIds.insert(source.id)
}
} label: {
HStack(spacing: 6) {
Circle()
.fill(chipColor)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(isSelected ? chipColor.opacity(0.15) : Color.gray.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? chipColor : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.comparisonSelectedSourceIds.isEmpty {
emptySelectionView
} else if viewModel.comparisonData.isEmpty {
noDataView
} else {
lineChart
}
}
private var emptySelectionView: some View {
VStack(spacing: 12) {
Image(systemName: "chart.xyaxis.line")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("Select 2+ sources to compare")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
private var noDataView: some View {
Text("No data available for selected sources in this period.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.frame(height: 260)
}
/// Multi-series comparison: labelling every point of every series overlaps
/// badly. Instead we label only each series' END point (where it landed),
/// which is the value the comparison is actually about.
private func selectionRows(for date: Date) -> [(label: String, value: String, color: Color)] {
viewModel.comparisonData.compactMap { series in
guard let point = series.points.min(by: {
abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
}) else { return nil }
return (series.name, yAxisLabel(for: point.value), Color(hex: series.colorHex) ?? .appPrimary)
}
}
private var lineChart: some View {
VStack(spacing: 12) {
Chart {
ForEach(viewModel.comparisonData, id: \.id) { series in
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
let lastDate = series.points.last?.date
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
y: .value("Value", point.value),
series: .value("Source", series.name)
)
.foregroundStyle(seriesColor)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", point.date),
y: .value("Value", point.value)
)
.foregroundStyle(seriesColor)
.symbolSize(point.date == lastDate ? 30 : 14)
.annotation(position: .trailing, spacing: 2) {
if point.date == lastDate {
ChartValueBubble(text: yAxisLabel(for: point.value), color: seriesColor)
}
}
}
}
if let selectedDate {
RuleMark(x: .value("Selected", selectedDate))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
.annotation(position: .top, spacing: 4) {
ChartSelectionCard(
title: Self.selectionDateFormatter.string(from: selectedDate),
rows: selectionRows(for: selectedDate)
)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: xAxisStride)) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
.font(.caption2)
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(yAxisLabel(for: d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
legend
}
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(viewModel.comparisonData, id: \.id) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series.colorHex) ?? .appPrimary)
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Helpers
private var xAxisStride: Int {
let maxPoints = viewModel.comparisonData.map { $0.points.count }.max() ?? 0
switch maxPoints {
case ...6: return 1
case ...12: return 2
case ...24: return 3
default: return 6
}
}
private func yAxisLabel(for value: Double) -> String {
switch viewModel.comparisonDisplayMode {
case .indexed:
return String(format: "%.0f", value)
case .returnPct:
return String(format: "%.1f%%", value)
case .absolute:
return Decimal(value).shortCurrencyString
case .monthlyReturn:
return String(format: "%.1f%%", value)
}
}
}
+188 -57
View File
@@ -3,6 +3,19 @@ import Charts
struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, drawdown: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
var maxDrawdown: Double {
abs(data.map { $0.drawdown }.min() ?? 0)
@@ -15,8 +28,10 @@ struct DrawdownChart: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
if showsTitle {
Text("Drawdown Analysis")
.font(.headline)
}
Spacer()
@@ -35,27 +50,58 @@ struct DrawdownChart: View {
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(
LinearGradient(
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
Chart {
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(
LinearGradient(
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
LineMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.interpolationMethod(.catmullRom)
LineMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(20)
.annotation(position: .bottom, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
}
}
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Drawdown", sel.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(70)
.annotation(position: .bottom, spacing: 4) {
ChartValueBubble(text: ChartLabels.percent(sel.drawdown), color: .negativeRed, prominent: true)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
@@ -74,6 +120,7 @@ struct DrawdownChart: View {
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Statistics
HStack(spacing: 20) {
@@ -96,6 +143,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 +168,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 {
@@ -139,6 +211,19 @@ struct DrawdownStatView: View {
struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, volatility: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
var currentVolatility: Double {
data.last?.volatility ?? 0
@@ -151,19 +236,12 @@ struct VolatilityChartView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
if showsTitle {
HStack {
Text("Volatility")
.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))
Spacer()
}
}
@@ -172,26 +250,41 @@ struct VolatilityChartView: View {
.foregroundColor(.secondary)
if data.count >= 2 {
Chart(data, id: \.date) { item in
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
Chart {
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(20)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
}
}
}
// Average line
RuleMark(y: .value("Average", averageVolatility))
@@ -202,7 +295,23 @@ struct VolatilityChartView: View {
.font(.caption2)
.foregroundColor(.secondary)
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Volatility", sel.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(70)
.annotation(position: .top, spacing: 4) {
ChartValueBubble(text: String(format: "%.1f%%", sel.volatility), prominent: true)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
@@ -220,14 +329,21 @@ struct VolatilityChartView: View {
}
}
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// 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 +367,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 {
@@ -3,10 +3,21 @@ import Charts
struct PerformanceBarChart: View {
let data: [(category: String, cagr: Double, color: String)]
var title: String = "Performance by Category"
/// Drill-down: tapping a row jumps to Evolution filtered to it.
var onDrillDown: ((String) -> Void)? = nil
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// On iPhone, vertical bars collide once there are more than a handful of
/// categories (labels + annotations fight for width). Horizontal bars give
/// every name a full line.
private var useHorizontalBars: Bool {
horizontalSizeClass != .regular && data.count > 6
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
Text(title)
.font(.headline)
Text("Compound Annual Growth Rate (CAGR)")
@@ -14,60 +25,20 @@ struct PerformanceBarChart: View {
.foregroundColor(.secondary)
if !data.isEmpty {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
ChartStatsRow(stats: perfStats)
}
// Legend / Details
if !data.isEmpty {
if useHorizontalBars {
horizontalChart
} else {
verticalChart
}
// Legend / Details rows drill down when the owner wires it up.
VStack(spacing: 8) {
ForEach(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
legendRow(item)
}
}
.padding(.top, 8)
@@ -83,12 +54,139 @@ struct PerformanceBarChart: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var verticalChart: some View {
Chart(data, id: \.category) { item in
BarMark(
x: .value("Category", item.category),
y: .value("CAGR", item.cagr)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .top : .bottom) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
}
private var horizontalChart: some View {
Chart(data.sorted(by: { $0.cagr > $1.cagr }), id: \.category) { item in
BarMark(
x: .value("CAGR", item.cagr),
y: .value("Category", item.category)
)
.foregroundStyle(Color(hex: item.color) ?? .gray)
.cornerRadius(4)
.annotation(position: item.cagr >= 0 ? .trailing : .leading) {
Text(String(format: "%.1f%%", item.cagr))
.font(.caption2)
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
}
}
.chartXAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisValueLabel {
if let category = value.as(String.self) {
Text(category)
.font(.caption)
.lineLimit(1)
}
}
}
}
.frame(height: CGFloat(data.count) * 34 + 40)
}
@ViewBuilder
private func legendRow(_ item: (category: String, cagr: Double, color: String)) -> some View {
let row = HStack {
Circle()
.fill(Color(hex: item.color) ?? .gray)
.frame(width: 10, height: 10)
Text(item.category)
.font(.subheadline)
Spacer()
Text(String(format: "%.2f%%", item.cagr))
.font(.subheadline.weight(.semibold))
.foregroundColor(item.cagr >= 0 ? .positiveGreen : .negativeRed)
if onDrillDown != nil {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
}
}
if let onDrillDown {
Button {
onDrillDown(item.category)
} label: {
row.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
row
}
}
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
struct HorizontalPerformanceChart: View {
let data: [(category: String, cagr: Double, color: String)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
var sortedData: [(category: String, cagr: Double, color: String)] {
data.sorted { $0.cagr > $1.cagr }
@@ -100,8 +198,10 @@ struct HorizontalPerformanceChart: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Performance by Category")
.font(.headline)
if showsTitle {
Text("Performance by Category")
.font(.headline)
}
ForEach(sortedData, id: \.category) { item in
VStack(alignment: .leading, spacing: 4) {
@@ -0,0 +1,343 @@
import SwiftUI
import Charts
struct PeriodComparisonChartView: View {
@Environment(\.chartImageExport) private var chartImageExport
@State private var selectedMonth: Int?
/// Period charts are short by design (a handful of months) labels always help.
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
viewModel.periodComparisonData.compactMap { series in
guard let point = series.points.first(where: { $0.monthOffset == month }) else { return nil }
return (series.label, ChartLabels.percent(point.returnPct), Color(hex: series.colorHex) ?? .gray)
}
}
@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")
.font(.headline)
periodPickersSection
chartContent
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Period Pickers
private var periodPickersSection: some View {
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(
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: 10) {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 3, height: 30)
Text(label)
.font(.caption.weight(.bold))
.foregroundColor(color)
.frame(width: 60, alignment: .leading)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(start.wrappedValue) },
set: { start.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
Text("")
.font(.caption)
.foregroundColor(.secondary)
DatePicker(
"",
selection: Binding<Date>(
get: { firstOfMonth(end.wrappedValue) },
set: { end.wrappedValue = firstOfMonth($0) }
),
displayedComponents: [.date]
)
.datePickerStyle(.compact)
.labelsHidden()
.frame(maxWidth: .infinity)
}
}
// MARK: - Chart Content
@ViewBuilder
private var chartContent: some View {
if viewModel.periodComparisonData.isEmpty {
emptyView
} else {
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")
.font(.system(size: 36))
.foregroundColor(.secondary)
Text("No data available for the selected periods.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.frame(height: 260)
}
// MARK: - Flat data for Chart
private struct PeriodPoint: Identifiable {
let id: String
let seriesId: String
let seriesLabel: String
let colorHex: String
let monthOffset: Int
let returnPct: Double
}
private var flatPoints: [PeriodPoint] {
var result: [PeriodPoint] = []
for series in viewModel.periodComparisonData {
for point in series.points {
result.append(PeriodPoint(
id: "\(series.id)-\(point.monthOffset)",
seriesId: series.id,
seriesLabel: series.label,
colorHex: series.colorHex,
monthOffset: point.monthOffset,
returnPct: point.returnPct
))
}
}
return result
}
/// Last month offset per series used to label only each series' endpoint.
private var lastOffsetBySeries: [String: Int] {
var m: [String: Int] = [:]
for s in viewModel.periodComparisonData {
m[s.id] = s.points.map(\.monthOffset).max()
}
return m
}
private var periodChart: some View {
let points = flatPoints
let endpoints = lastOffsetBySeries
let seriesIds = viewModel.periodComparisonData.map { $0.id }
let seriesColors: [Color] = viewModel.periodComparisonData.map {
Color(hex: $0.colorHex) ?? .gray
}
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(point.monthOffset == endpoints[point.seriesId] ? 38 : 22)
.annotation(position: point.seriesLabel == viewModel.periodComparisonData.first?.label ? .top : .bottom,
spacing: 3) {
if point.monthOffset == endpoints[point.seriesId] {
ChartValueBubble(
text: ChartLabels.percent(point.returnPct),
color: Color(hex: point.colorHex) ?? .gray
)
}
}
}
RuleMark(y: .value("Zero", 0))
.foregroundStyle(Color.secondary.opacity(0.4))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
if let selectedMonth {
RuleMark(x: .value("Selected", selectedMonth))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
.annotation(position: .top, spacing: 4) {
ChartSelectionCard(
title: "M\(selectedMonth + 1)",
rows: selectionRows(for: selectedMonth)
)
}
}
}
.chartXSelection(value: $selectedMonth)
.chartForegroundStyleScale(
domain: viewModel.periodComparisonData.map { $0.label },
range: seriesColors
)
.chartXAxis {
AxisMarks(values: xAxisValues) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.2))
AxisValueLabel {
if let idx = value.as(Int.self) {
Text("M\(idx + 1)")
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%.1f%%", d))
.font(.caption2)
}
}
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
.foregroundStyle(Color.secondary.opacity(0.15))
}
}
.frame(height: 260)
.chartDrawingGroup(disabledForExport: chartImageExport)
.onChange(of: seriesIds) { _, _ in }
}
private var legend: some View {
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()
}
}
// MARK: - Helpers
private var xAxisValues: [Int] {
let maxOffset = viewModel.periodComparisonData
.flatMap { $0.points }
.map { $0.monthOffset }
.max() ?? 0
return Array(0...maxOffset)
}
private func firstOfMonth(_ date: Date) -> Date {
let calendar = Calendar.current
var components = calendar.dateComponents([.year, .month], from: date)
components.day = 1
return calendar.date(from: components) ?? date
}
}
@@ -4,12 +4,31 @@ import Charts
struct PredictionChartView: View {
let predictions: [Prediction]
let historicalData: [(date: Date, value: Decimal)]
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
/// Nearest point (historical or predicted) to the selected date.
private var selectedPoint: (date: Date, value: Decimal, isPrediction: Bool)? {
guard let selectedDate else { return nil }
let all: [(date: Date, value: Decimal, isPrediction: Bool)] =
historicalData.map { ($0.date, $0.value, false) } +
predictions.map { ($0.date, $0.predictedValue, true) }
return all.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
if showsTitle {
Text(predictions.isEmpty ? "Prediction" : "\(predictions.count)-Month Prediction")
.font(.headline)
}
Spacer()
@@ -33,8 +52,9 @@ struct PredictionChartView: View {
if !predictions.isEmpty && historicalData.count >= 2 {
Chart {
// Historical data
ForEach(historicalData, id: \.date) { item in
// Historical data thin labels to at most a handful.
ForEach(Array(historicalData.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Value", NSDecimalNumber(decimal: item.value).doubleValue)
@@ -48,6 +68,11 @@ struct PredictionChartView: View {
)
.foregroundStyle(Color.appPrimary)
.symbolSize(24)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: historicalData.count, compact: labelsCompact) {
ChartValueBubble(text: item.value.compactCurrencyString)
}
}
}
// Confidence interval area
@@ -74,7 +99,35 @@ struct PredictionChartView: View {
y: .value("Predicted", NSDecimalNumber(decimal: prediction.predictedValue).doubleValue)
)
.foregroundStyle(Color.appSecondary)
.symbolSize(24)
.symbolSize(prediction.id == predictions.last?.id ? 34 : 24)
.annotation(position: .top, spacing: 3) {
// Only the final forecast point (the KPIs cover the rest).
if prediction.id == predictions.last?.id {
ChartValueBubble(text: prediction.predictedValue.compactCurrencyString, color: .appSecondary)
}
}
}
// 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
@@ -96,7 +149,27 @@ struct PredictionChartView: View {
.foregroundStyle(Color.appSecondary)
.lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 5]))
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Value", NSDecimalNumber(decimal: sel.value).doubleValue)
)
.foregroundStyle(sel.isPrediction ? Color.appSecondary : Color.appPrimary)
.symbolSize(80)
.annotation(position: .top, spacing: 4) {
ChartValueBubble(
text: sel.value.compactCurrencyString,
color: sel.isPrediction ? .appSecondary : .appPrimary,
prominent: true
)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 3)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated).year(.twoDigits))
@@ -114,6 +187,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 +234,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 +297,14 @@ struct PredictionChartView: View {
}
}
}
Divider().padding(.vertical, 4)
ChartDataTable(
rows: predictionTableRows,
valueHeader: "Base",
deltaPrevHeader: "Bull",
deltaFirstHeader: "Bear"
)
}
} else {
VStack(spacing: 12) {
@@ -220,6 +330,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,325 @@
import SwiftUI
import Charts
struct YearOverYearChartView: View {
@ObservedObject var viewModel: ChartsViewModel
/// Hidden when the host (e.g. a dashboard card) already shows a title.
var showsTitle: Bool = true
@State private var selectedMonthIdx: Int?
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) {
if showsTitle {
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
/// Last month index that has data, per series id label only that endpoint
/// (the year's cumulative return) to avoid 24 overlapping labels.
private func lastValidMonth(_ series: ChartsViewModel.YearSeries) -> Int? {
(0..<12).last { !series.values[$0].isNaN }
}
private func selectionRows(for month: Int) -> [(label: String, value: String, color: Color)] {
selectedSeries.compactMap { series in
let value = series.values[month]
guard !value.isNaN else { return nil }
let idx = allYears.firstIndex(where: { $0.id == series.id }) ?? 0
return (String(series.year), ChartLabels.percent(value), lineColors[idx % lineColors.count])
}
}
@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]
let seriesPosition = selectedSeries.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let endMonth = lastValidMonth(yearSeries)
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)
PointMark(
x: .value("Month", monthIdx),
y: .value("Return", value)
)
.foregroundStyle(color)
.symbolSize(monthIdx == endMonth ? 34 : 18)
.annotation(position: seriesPosition == 0 ? .top : .bottom, spacing: 2) {
if monthIdx == endMonth {
ChartValueBubble(text: ChartLabels.percent(value), color: color)
}
}
}
}
}
if let selectedMonthIdx, selectedMonthIdx >= 0, selectedMonthIdx < 12 {
RuleMark(x: .value("Selected", selectedMonthIdx))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
.annotation(position: .top, spacing: 4) {
ChartSelectionCard(
title: monthAbbreviations[selectedMonthIdx],
rows: selectionRows(for: selectedMonthIdx)
)
}
}
}
.chartXSelection(value: $selectedMonthIdx)
.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),
], showsOnRegularWidth: true)
}
}
@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
)
}
}
}
@@ -1,32 +1,13 @@
import SwiftUI
// Calm 2.0: plain system grouped background depth comes from content
// hierarchy, not decoration (the old gradient + overflowing shapes also
// intercepted touches on sibling views; see 1.4.2 build 50).
struct AppBackground: View {
var body: some View {
GeometryReader { proxy in
ZStack {
LinearGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appSecondary.opacity(0.12),
Color.appAccent.opacity(0.1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(Color.appPrimary.opacity(0.18))
.frame(width: proxy.size.width * 0.7)
.offset(x: -proxy.size.width * 0.35, y: -proxy.size.height * 0.35)
RoundedRectangle(cornerRadius: 120, style: .continuous)
.fill(Color.appAccent.opacity(0.12))
.frame(width: proxy.size.width * 0.8, height: proxy.size.height * 0.35)
.rotationEffect(.degrees(-12))
.offset(x: proxy.size.width * 0.2, y: proxy.size.height * 0.45)
}
Color(.systemGroupedBackground)
.ignoresSafeArea()
}
.allowsHitTesting(false)
}
}

Some files were not shown because too many files have changed in this diff Show More