Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/PeriodComparisonChartView.swift
T
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

344 lines
13 KiB
Swift

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
}
}