86c95a9e73
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
344 lines
13 KiB
Swift
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
|
|
}
|
|
}
|